package controller import ( "fmt" "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" "github.com/spf13/viper" ) // MailingListsController is used to keeping the external mailing lists in sync // with customer email changes. // // MailingListsController 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 { zohoAccessToken string zohoListKey string zohoTopicIds string zohoCredentials zoho.Credentials listmonkListIDs []int listmonkCredentials listmonk.Credentials } // Return a new instance of MailingListsController func NewMailingListsController() *MailingListsController { zohoCredentials := zoho.Credentials{ ClientID: viper.GetString("zoho.client-id"), ClientSecret: viper.GetString("zoho.client-secret"), RefreshToken: viper.GetString("zoho.refresh-token"), } // The Zoho "List Key" identifies a particular list of email IDs that are // stored in Zoho. All the actions that we perform (adding, removing and // updating emails) are done on this list. // // https://www.zoho.com/campaigns/help/developers/list-management.html zohoListKey := viper.GetString("zoho.list-key") // List of topics to which emails are sent. // // Ostensibly, we can get them from their API // https://www.zoho.com/campaigns/oldhelp/api/get-topics.html // // But that doesn't currently work, luckily we can get these IDs by looking // at the HTML source of the topic update dashboard page. zohoTopicIds := viper.GetString("zoho.topic-ids") // Zoho has a rate limit on the number of access tokens that can created // within a given time period. So as an aid in debugging, allow the access // token to be passed in. This will not be present in production - there // 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, listmonkCredentials: listmonkCredentials, listmonkListIDs: listmonkListIDs, } } // 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. // // # Syncing emails with Zoho Campaigns // // Zoho Campaigns does not support maintaining a list of raw email addresses // 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 { // Checking if either listmonk or zoho credentials are configured if c.shouldSkipZoho() { if c.shouldSkipListmonk() { return stacktrace.Propagate(ente.ErrNotImplemented, "") } else { return c.doListActionListmonk("listsubscribe", email) } } else { // 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.doListActionZoho("listsubscribe", email) } } // 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.shouldSkipZoho() { if c.shouldSkipListmonk() { return stacktrace.Propagate(ente.ErrNotImplemented, "") } else { return c.doListActionListmonk("listunsubscribe", email) } } else { // https://www.zoho.com/campaigns/help/developers/contact-unsubscribe.html return c.doListActionZoho("listunsubscribe", email) } } // shouldSkipZoho() checks if the MailingListsController // should be skipped due to missing credentials. func (c *MailingListsController) shouldSkipZoho() bool { if c.zohoCredentials.RefreshToken == "" { 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 } // Both the listsubscribe and listunsubscribe Zoho Campaigns API endpoints work // similarly, so use this function to keep the common code. 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) // Instead of using QueryEscape, use PathEscape. QueryEscape escapes the "+" // character, which causes Zoho API to not recognize the parameter. escapedContactInfo := url.PathEscape(contactInfo) url := fmt.Sprintf( "https://campaigns.zoho.com/api/v1.1/json/%s?resfmt=JSON&listkey=%s&contactinfo=%s&topic_id=%s", action, c.zohoListKey, escapedContactInfo, c.zohoTopicIds) zohoAccessToken, err := zoho.DoRequest("POST", url, c.zohoAccessToken, c.zohoCredentials) c.zohoAccessToken = zohoAccessToken if err != nil { // This is not necessarily an error, and can happen when the customer // had earlier unsubscribed from our organization emails in Zoho, // selecting the "Erase my data" option. This causes Zoho to remove the // customer's entire record from their database. // // Then later, say if the customer deletes their account from ente, we // would try to unsubscribe their email but it wouldn't be present in // Zoho, and this API call would've failed. // // In such a case, Zoho will return the following response: // // { code":"2103", // "message":"Contact does not exist.", // "version":"1.1", // "uri":"/api/v1.1/json/listunsubscribe", // "status":"error"} // // Special case these to reduce the severity level so as to not cause // error log spam. if strings.Contains(err.Error(), "Contact does not exist") { log.Warnf("Zoho - Could not %s '%s': %s", action, email, err) } else { log.Errorf("Zoho - Could not %s '%s': %s", action, email, err) } } return stacktrace.Propagate(err, "") } // doListActionListmonk() subscribes or unsubscribes an email address // to a particular mailing list // based on the action parameter ("listsubscribe" or "listunsubscribe") func (c *MailingListsController) doListActionListmonk(action string, email string) error { if action == "listsubscribe" { 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) } else { // 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": "unsubscribe", "target_list_ids": c.listmonkListIDs, } return listmonk.SendRequest("PUT", c.listmonkCredentials.BaseURL+"/api/subscribers/lists", data, c.listmonkCredentials.Username, c.listmonkCredentials.Password) } }