Listmonk mailing list control (#1289)

## Description
Add listmonk mailing list subscribe and unsubscribe

## Tests
Tested getSubscriberID, unsubscribe, and subscribe using a locally
running listmonk server with different parameters.
- [x] A new subscriber is created and added to the listmonk campaign
mailing list on listmonkSubscribe() for the given list IDs
- [x] Subscriber is removed from listmonk campaign mailing list on
listmonkUnsubscribe() for the given list IDs
- [x] The old email address is unsubscribed, and the new email is
subscribed when a user updates the email
This commit is contained in:
Manav Rathi 2024-04-04 17:21:55 +05:30 committed by GitHub
commit b6177a5bc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 243 additions and 32 deletions

View file

@ -237,6 +237,14 @@ zoho:
list-key:
topic-ids:
# Listmonk Campaigns config (optional)
# Use case: Sending emails
listmonk:
server-url:
username:
password:
list-ids:
# Various low-level configuration options
internal:
# If false (the default), then museum will notify the external world of

View file

@ -5,7 +5,7 @@ import (
"net/url"
"strings"
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/external/listmonk"
"github.com/ente-io/museum/pkg/external/zoho"
"github.com/ente-io/stacktrace"
log "github.com/sirupsen/logrus"
@ -21,10 +21,12 @@ import (
//
// See also: Syncing emails with Zoho Campaigns
type MailingListsController struct {
zohoAccessToken string
zohoListKey string
zohoTopicIds string
zohoCredentials zoho.Credentials
zohoAccessToken string
zohoListKey string
zohoTopicIds string
zohoCredentials zoho.Credentials
listmonkListIDs []int
listmonkCredentials listmonk.Credentials
}
// Return a new instance of MailingListsController
@ -57,15 +59,28 @@ func NewMailingListsController() *MailingListsController {
// we'll use the refresh token to create an access token on demand.
zohoAccessToken := viper.GetString("zoho.access_token")
listmonkCredentials := listmonk.Credentials{
BaseURL: viper.GetString("listmonk.server-url"),
Username: viper.GetString("listmonk.username"),
Password: viper.GetString("listmonk.password"),
}
// An array of integer values indicating the id of listmonk campaign
// mailing list to which the subscriber needs to added
listmonkListIDs := viper.GetIntSlice("listmonk.list-ids")
return &MailingListsController{
zohoCredentials: zohoCredentials,
zohoListKey: zohoListKey,
zohoTopicIds: zohoTopicIds,
zohoAccessToken: zohoAccessToken,
zohoCredentials: zohoCredentials,
zohoListKey: zohoListKey,
zohoTopicIds: zohoTopicIds,
zohoAccessToken: zohoAccessToken,
listmonkCredentials: listmonkCredentials,
listmonkListIDs: listmonkListIDs,
}
}
// Add the given email address to our default Zoho Campaigns list.
// Add the given email address to our default Zoho Campaigns list
// or Listmonk Campaigns List
//
// It is valid to resubscribe an email that has previously been unsubscribe.
//
@ -76,37 +91,72 @@ func NewMailingListsController() *MailingListsController {
// the email addresses of our customers in a Zoho Campaign "list", and subscribe
// or unsubscribe them to this list.
func (c *MailingListsController) Subscribe(email string) error {
if c.shouldSkip() {
return stacktrace.Propagate(ente.ErrNotImplemented, "")
if !(c.shouldSkipZoho()) {
// Need to set "Signup Form Disabled" in the list settings since we use this
// list to keep track of emails that have already been verified.
//
// > You can use this API to add contacts to your mailing lists. For signup
// form enabled mailing lists, the contacts will receive a confirmation
// email. For signup form disabled lists, contacts will be added without
// any confirmations.
//
// https://www.zoho.com/campaigns/help/developers/contact-subscribe.html
err := c.doListActionZoho("listsubscribe", email)
if err != nil {
return stacktrace.Propagate(err, "")
}
}
// Need to set "Signup Form Disabled" in the list settings since we use this
// list to keep track of emails that have already been verified.
//
// > You can use this API to add contacts to your mailing lists. For signup
// form enabled mailing lists, the contacts will receive a confirmation
// email. For signup form disabled lists, contacts will be added without
// any confirmations.
//
// https://www.zoho.com/campaigns/help/developers/contact-subscribe.html
return c.doListAction("listsubscribe", email)
if !(c.shouldSkipListmonk()) {
err := c.listmonkSubscribe(email)
if err != nil {
return stacktrace.Propagate(err, "")
}
}
return nil
}
// Unsubscribe the given email address to our default Zoho Campaigns list.
// Unsubscribe the given email address to our default Zoho Campaigns list
// or Listmonk Campaigns List
//
// See: [Note: Syncing emails with Zoho Campaigns]
func (c *MailingListsController) Unsubscribe(email string) error {
if c.shouldSkip() {
return stacktrace.Propagate(ente.ErrNotImplemented, "")
if !(c.shouldSkipZoho()) {
// https://www.zoho.com/campaigns/help/developers/contact-unsubscribe.html
err := c.doListActionZoho("listunsubscribe", email)
if err != nil {
return stacktrace.Propagate(err, "")
}
}
// https://www.zoho.com/campaigns/help/developers/contact-unsubscribe.html
return c.doListAction("listunsubscribe", email)
if !(c.shouldSkipListmonk()) {
err := c.listmonkUnsubscribe(email)
if err != nil {
return stacktrace.Propagate(err, "")
}
}
return nil
}
func (c *MailingListsController) shouldSkip() bool {
// shouldSkipZoho() checks if the MailingListsController
// should be skipped due to missing credentials.
func (c *MailingListsController) shouldSkipZoho() bool {
if c.zohoCredentials.RefreshToken == "" {
log.Info("Skipping mailing list update because credentials are not configured")
log.Info("Skipping Zoho mailing list update because credentials are not configured")
return true
}
return false
}
// shouldSkipListmonk() checks if the Listmonk mailing list
// should be skipped due to missing credentials
// listmonklistIDs value.
//
// ListmonkListIDs is an optional field for subscribing an email address
// (user gets added to the default list),
// but is a required field for unsubscribing an email address
func (c *MailingListsController) shouldSkipListmonk() bool {
if c.listmonkCredentials.BaseURL == "" || c.listmonkCredentials.Username == "" ||
c.listmonkCredentials.Password == "" || len(c.listmonkListIDs) == 0 {
log.Info("Skipping Listmonk mailing list because credentials are not configured")
return true
}
return false
@ -114,7 +164,7 @@ func (c *MailingListsController) shouldSkip() bool {
// Both the listsubscribe and listunsubscribe Zoho Campaigns API endpoints work
// similarly, so use this function to keep the common code.
func (c *MailingListsController) doListAction(action string, email string) error {
func (c *MailingListsController) doListActionZoho(action string, email string) error {
// Query escape the email so that any pluses get converted to %2B.
escapedEmail := url.QueryEscape(email)
contactInfo := fmt.Sprintf("{Contact+Email: \"%s\"}", escapedEmail)
@ -158,3 +208,38 @@ func (c *MailingListsController) doListAction(action string, email string) error
return stacktrace.Propagate(err, "")
}
// Subscribes an email address to a particular listmonk campaign mailing list
func (c *MailingListsController) listmonkSubscribe(email string) error {
data := map[string]interface{}{
"email": email,
"lists": c.listmonkListIDs,
}
return listmonk.SendRequest("POST", c.listmonkCredentials.BaseURL+"/api/subscribers", data,
c.listmonkCredentials.Username, c.listmonkCredentials.Password)
}
// Unsubscribes an email address to a particular listmonk campaign mailing list
func (c *MailingListsController) listmonkUnsubscribe(email string) error {
// Listmonk dosen't provide an endpoint for unsubscribing users
// from a particular list directly via their email
//
// Thus, fetching subscriberID through email address,
// and then calling endpoint to modify subscription in a list
id, err := listmonk.GetSubscriberID(c.listmonkCredentials.BaseURL+"/api/subscribers",
c.listmonkCredentials.Username, c.listmonkCredentials.Password, email)
if err != nil {
stacktrace.Propagate(err, "")
}
// API endpoint expects an array of subscriber id as parameter
subscriberID := []int{id}
data := map[string]interface{}{
"ids": subscriberID,
"action": "remove",
"target_list_ids": c.listmonkListIDs,
}
return listmonk.SendRequest("PUT", c.listmonkCredentials.BaseURL+"/api/subscribers/lists", data,
c.listmonkCredentials.Username, c.listmonkCredentials.Password)
}

118
server/pkg/external/listmonk/api.go vendored Normal file
View file

@ -0,0 +1,118 @@
package listmonk
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/ente-io/stacktrace"
)
// Listmonk credentials to interact with the Listmonk API.
// It specifies BaseURL (url of the running listmonk server,
// Listmonk Username and Password.
// Visit https://listmonk.app/ to learn more about running
// Listmonk locally
type Credentials struct {
BaseURL string
Username string
Password string
}
// GetSubscriberID returns subscriber id of the provided email address,
// else returns an error if email was not found
func GetSubscriberID(endpoint string, username string, password string, subscriberEmail string) (int, error) {
// Struct for the received API response.
// Can define other fields as well that can be
// extracted from response JSON
type SubscriberResponse struct {
Data struct {
Results []struct {
ID int `json:"id"`
} `json:"results"`
} `json:"data"`
}
// Constructing query parameters
queryParams := url.Values{}
queryParams.Set("query", fmt.Sprintf("subscribers.email = '%s'", subscriberEmail))
// Constructing the URL with query parameters
endpointURL, err := url.Parse(endpoint)
if err != nil {
return 0, stacktrace.Propagate(err, "")
}
endpointURL.RawQuery = queryParams.Encode()
req, err := http.NewRequest("GET", endpointURL.String(), nil)
if err != nil {
return 0, stacktrace.Propagate(err, "")
}
req.SetBasicAuth(username, password)
// Sending the HTTP request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return 0, stacktrace.Propagate(err, "")
}
defer resp.Body.Close()
// Reading the response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, stacktrace.Propagate(err, "")
}
// Parsing the JSON response
var subscriberResp SubscriberResponse
if err := json.Unmarshal(body, &subscriberResp); err != nil {
return 0, stacktrace.Propagate(err, "")
}
// Checking if there are any subscribers found
if len(subscriberResp.Data.Results) == 0 {
return 0, stacktrace.Propagate(err, "")
}
// Extracting the ID from the response
id := subscriberResp.Data.Results[0].ID
return id, nil
}
// SendRequest sends a request to the specified Listmonk API endpoint
// with the provided method and data
// after authentication with the provided credentials (username, password)
func SendRequest(method string, url string, data interface{}, username string, password string) error {
jsonData, err := json.Marshal(data)
if err != nil {
return stacktrace.Propagate(err, "")
}
client := &http.Client{}
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
if err != nil {
return stacktrace.Propagate(err, "")
}
req.SetBasicAuth(username, password)
req.Header.Set("Content-Type", "application/json")
// Send request
resp, err := client.Do(req)
if err != nil {
return stacktrace.Propagate(err, "")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return stacktrace.Propagate(err, "")
}
return nil
}