diff --git a/server/configurations/local.yaml b/server/configurations/local.yaml index 97dd353e1..2cfdba18b 100644 --- a/server/configurations/local.yaml +++ b/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 diff --git a/server/pkg/controller/mailing_lists.go b/server/pkg/controller/mailing_lists.go index 0cd51e54f..f0b60adb5 100644 --- a/server/pkg/controller/mailing_lists.go +++ b/server/pkg/controller/mailing_lists.go @@ -6,29 +6,30 @@ import ( "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" "github.com/spf13/viper" ) -// MailingListsController is used to keeping the external mailing lists in sync +// ZohoMailingListsController is used to keeping the external mailing lists in sync // with customer email changes. // -// MailingListsController contains methods for keeping external mailing lists in +// ZohoMailingListsController contains methods for keeping external mailing lists in // sync when new users sign up, or update their email, or delete their account. // Currently, these mailing lists are hosted on Zoho Campaigns. // // See also: Syncing emails with Zoho Campaigns -type MailingListsController struct { +type ZohoMailingListsController struct { zohoAccessToken string zohoListKey string zohoTopicIds string zohoCredentials zoho.Credentials } -// Return a new instance of MailingListsController -func NewMailingListsController() *MailingListsController { +// Return a new instance of ZohoMailingListsController +func NewZohoMailingListsController() *ZohoMailingListsController { zohoCredentials := zoho.Credentials{ ClientID: viper.GetString("zoho.client-id"), ClientSecret: viper.GetString("zoho.client-secret"), @@ -57,7 +58,7 @@ func NewMailingListsController() *MailingListsController { // we'll use the refresh token to create an access token on demand. zohoAccessToken := viper.GetString("zoho.access_token") - return &MailingListsController{ + return &ZohoMailingListsController{ zohoCredentials: zohoCredentials, zohoListKey: zohoListKey, zohoTopicIds: zohoTopicIds, @@ -65,6 +66,31 @@ func NewMailingListsController() *MailingListsController { } } +// ListmonkMailingListsController is used to interact with the Listmonk API. +// +// It specifies BaseURL (URL of your listmonk server), +// your listmonk Username and Password +// and ListIDs (an array of integer values indicating the id of listmonk campaign mailing list +// to which the subscriber needs to added) +type ListmonkMailingListsController struct { + BaseURL string + Username string + Password string + ListIDs []int +} + +// NewListmonkMailingListsController creates a new instance of ListmonkMailingListsController +// with the API credentials provided in config file +func NewListmonkMailingListsController() *ListmonkMailingListsController { + credentials := &ListmonkMailingListsController{ + BaseURL: viper.GetString("listmonk.server-url"), + Username: viper.GetString("listmonk.username"), + Password: viper.GetString("listmonk.password"), + ListIDs: viper.GetIntSlice("listmonk.list-ids"), + } + return credentials +} + // Add the given email address to our default Zoho Campaigns list. // // It is valid to resubscribe an email that has previously been unsubscribe. @@ -75,8 +101,8 @@ func NewMailingListsController() *MailingListsController { // that can be later updated or deleted via their API. So instead, we maintain // 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() { +func (c *ZohoMailingListsController) Subscribe(email string) error { + if c.shouldSkipZoho() { return stacktrace.Propagate(ente.ErrNotImplemented, "") } @@ -89,24 +115,26 @@ func (c *MailingListsController) Subscribe(email string) error { // any confirmations. // // https://www.zoho.com/campaigns/help/developers/contact-subscribe.html - return c.doListAction("listsubscribe", email) + return c.doListActionZoho("listsubscribe", email) } // Unsubscribe the given email address to our default Zoho Campaigns list. // // See: [Note: Syncing emails with Zoho Campaigns] -func (c *MailingListsController) Unsubscribe(email string) error { - if c.shouldSkip() { +func (c *ZohoMailingListsController) Unsubscribe(email string) error { + if c.shouldSkipZoho() { return stacktrace.Propagate(ente.ErrNotImplemented, "") } // https://www.zoho.com/campaigns/help/developers/contact-unsubscribe.html - return c.doListAction("listunsubscribe", email) + return c.doListActionZoho("listunsubscribe", email) } -func (c *MailingListsController) shouldSkip() bool { +// shouldSkipZoho checks if the ZohoMailingListsController should be skipped +// due to missing credentials. +func (c *ZohoMailingListsController) 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 @@ -114,7 +142,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 *ZohoMailingListsController) 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 +186,56 @@ func (c *MailingListsController) doListAction(action string, email string) error return stacktrace.Propagate(err, "") } + +// Add or subscribe an email to listmonk mailing list +func (c *ListmonkMailingListsController) Subscribe(email string) error { + if c.shouldSkipListmonk() { + return stacktrace.Propagate(ente.ErrNotImplemented, "") + } + + data := map[string]interface{}{ + "email": email, + "lists": c.ListIDs, + } + + return listmonk.SendRequest("POST", c.BaseURL+"/api/subscribers", data, + c.Username, c.Password) +} + +// Remove or unsubscribe an email from listmonk mailing list +func (c *ListmonkMailingListsController) Unsubscribe(email string) error { + if c.shouldSkipListmonk() { + return stacktrace.Propagate(ente.ErrNotImplemented, "") + } + + // 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.BaseURL+"/api/subscribers", c.Username, c.Password, email) + if err != nil { + stacktrace.Propagate(err, "") + } + // API endpoint expects an array of subscriber id as paarmeter + subscriberID := []int{id} + + data := map[string]interface{}{ + "ids": subscriberID, + "action": "unsubscribe", + "target_list_ids": c.ListIDs, + } + + return listmonk.SendRequest("PUT", c.BaseURL+"/api/subscribers/lists", data, + c.Username, c.Password) +} + +// shouldSkipListmonk checks if the ListmonkMailingListsController should be skipped +// due to missing credentials. +func (c *ListmonkMailingListsController) shouldSkipListmonk() bool { + if c.BaseURL == "" || c.Username == "" || c.Password == "" { + log.Info("Skipping Listmonk mailing list because credentials are not configured") + return true + } + return false +} diff --git a/server/pkg/external/listmonk/api.go b/server/pkg/external/listmonk/api.go new file mode 100644 index 000000000..13750cc67 --- /dev/null +++ b/server/pkg/external/listmonk/api.go @@ -0,0 +1,104 @@ +package listmonk + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/ente-io/stacktrace" +) + +// 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 +}