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..c7eb0f1f3 100644 --- a/server/pkg/controller/mailing_lists.go +++ b/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) +} diff --git a/server/pkg/external/listmonk/api.go b/server/pkg/external/listmonk/api.go new file mode 100644 index 000000000..338a54c3e --- /dev/null +++ b/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 +}