123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256 |
- 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
- }
- // 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")
- return &MailingListsController{
- zohoCredentials: zohoCredentials,
- zohoListKey: zohoListKey,
- zohoTopicIds: zohoTopicIds,
- zohoAccessToken: zohoAccessToken,
- }
- }
- // 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 config file credentials
- 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.
- //
- // # 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 {
- listmonkController := NewListmonkMailingListsController()
- var err error
- // Checking if either listmonk or zoho credentials are configured
- if c.shouldSkipZoho() {
- err = stacktrace.Propagate(ente.ErrNotImplemented, "")
- if listmonkController.shouldSkipListmonk() {
- err = stacktrace.Propagate(ente.ErrNotImplemented, "")
- } else {
- err = listmonkController.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
- err = c.doListActionZoho("listsubscribe", email)
- }
- return err
- }
- // 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 {
- listmonkController := NewListmonkMailingListsController()
- var err error
- if c.shouldSkipZoho() {
- err = stacktrace.Propagate(ente.ErrNotImplemented, "")
- if listmonkController.shouldSkipListmonk() {
- err = stacktrace.Propagate(ente.ErrNotImplemented, "")
- } else {
- err = listmonkController.doListActionListmonk("listunsubscribe", email)
- }
- } else {
- // https://www.zoho.com/campaigns/help/developers/contact-unsubscribe.html
- err = c.doListActionZoho("listunsubscribe", email)
- }
- return err
- }
- // 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
- }
- // 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
- func (c *ListmonkMailingListsController) doListActionListmonk(action string, email string) error {
- if action == "listsubscribe" {
- data := map[string]interface{}{
- "email": email,
- "lists": c.ListIDs,
- }
- return listmonk.SendRequest("POST", c.BaseURL+"/api/subscribers", data,
- c.Username, c.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.BaseURL+"/api/subscribers", c.Username, c.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.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
- }
|