浏览代码

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
Manav Rathi 1 年之前
父节点
当前提交
b6177a5bc3
共有 3 个文件被更改,包括 243 次插入32 次删除
  1. 8 0
      server/configurations/local.yaml
  2. 117 32
      server/pkg/controller/mailing_lists.go
  3. 118 0
      server/pkg/external/listmonk/api.go

+ 8 - 0
server/configurations/local.yaml

@@ -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

+ 117 - 32
server/pkg/controller/mailing_lists.go

@@ -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 - 0
server/pkg/external/listmonk/api.go

@@ -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
+}