mailing_lists.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. package controller
  2. import (
  3. "fmt"
  4. "net/url"
  5. "strconv"
  6. "strings"
  7. "github.com/ente-io/museum/pkg/external/listmonk"
  8. "github.com/ente-io/museum/pkg/external/zoho"
  9. "github.com/ente-io/stacktrace"
  10. log "github.com/sirupsen/logrus"
  11. "github.com/spf13/viper"
  12. )
  13. // MailingListsController is used to keeping the external mailing lists in sync
  14. // with customer email changes.
  15. //
  16. // MailingListsController contains methods for keeping external mailing lists in
  17. // sync when new users sign up, or update their email, or delete their account.
  18. // Currently, these mailing lists are hosted on Zoho Campaigns.
  19. //
  20. // See also: Syncing emails with Zoho Campaigns
  21. type MailingListsController struct {
  22. zohoAccessToken string
  23. zohoListKey string
  24. zohoTopicIds string
  25. zohoCredentials zoho.Credentials
  26. listmonkListIDs []int
  27. listmonkCredentials listmonk.Credentials
  28. }
  29. // Return a new instance of MailingListsController
  30. func NewMailingListsController() *MailingListsController {
  31. zohoCredentials := zoho.Credentials{
  32. ClientID: viper.GetString("zoho.client-id"),
  33. ClientSecret: viper.GetString("zoho.client-secret"),
  34. RefreshToken: viper.GetString("zoho.refresh-token"),
  35. }
  36. // The Zoho "List Key" identifies a particular list of email IDs that are
  37. // stored in Zoho. All the actions that we perform (adding, removing and
  38. // updating emails) are done on this list.
  39. //
  40. // https://www.zoho.com/campaigns/help/developers/list-management.html
  41. zohoListKey := viper.GetString("zoho.list-key")
  42. // List of topics to which emails are sent.
  43. //
  44. // Ostensibly, we can get them from their API
  45. // https://www.zoho.com/campaigns/oldhelp/api/get-topics.html
  46. //
  47. // But that doesn't currently work, luckily we can get these IDs by looking
  48. // at the HTML source of the topic update dashboard page.
  49. zohoTopicIds := viper.GetString("zoho.topic-ids")
  50. // Zoho has a rate limit on the number of access tokens that can created
  51. // within a given time period. So as an aid in debugging, allow the access
  52. // token to be passed in. This will not be present in production - there
  53. // we'll use the refresh token to create an access token on demand.
  54. zohoAccessToken := viper.GetString("zoho.access_token")
  55. listmonkCredentials := listmonk.Credentials{
  56. BaseURL: viper.GetString("listmonk.server-url"),
  57. Username: viper.GetString("listmonk.username"),
  58. Password: viper.GetString("listmonk.password"),
  59. }
  60. // An array of integer values indicating the id of listmonk campaign
  61. // mailing list to which the subscriber needs to added
  62. listmonkListIDs := viper.GetIntSlice("listmonk.list-ids")
  63. return &MailingListsController{
  64. zohoCredentials: zohoCredentials,
  65. zohoListKey: zohoListKey,
  66. zohoTopicIds: zohoTopicIds,
  67. zohoAccessToken: zohoAccessToken,
  68. listmonkCredentials: listmonkCredentials,
  69. listmonkListIDs: listmonkListIDs,
  70. }
  71. }
  72. // Add the given email address to our default Zoho Campaigns list
  73. // or Listmonk Campaigns List
  74. //
  75. // It is valid to resubscribe an email that has previously been unsubscribe.
  76. //
  77. // # Syncing emails with Zoho Campaigns
  78. //
  79. // Zoho Campaigns does not support maintaining a list of raw email addresses
  80. // that can be later updated or deleted via their API. So instead, we maintain
  81. // the email addresses of our customers in a Zoho Campaign "list", and subscribe
  82. // or unsubscribe them to this list.
  83. func (c *MailingListsController) Subscribe(email string) error {
  84. if !(c.shouldSkipZoho()) {
  85. // Need to set "Signup Form Disabled" in the list settings since we use this
  86. // list to keep track of emails that have already been verified.
  87. //
  88. // > You can use this API to add contacts to your mailing lists. For signup
  89. // form enabled mailing lists, the contacts will receive a confirmation
  90. // email. For signup form disabled lists, contacts will be added without
  91. // any confirmations.
  92. //
  93. // https://www.zoho.com/campaigns/help/developers/contact-subscribe.html
  94. err := c.doListActionZoho("listsubscribe", email)
  95. if err != nil {
  96. return stacktrace.Propagate(err, "")
  97. }
  98. }
  99. if !(c.shouldSkipListmonk()) {
  100. err := c.listmonkSubscribe(email)
  101. if err != nil {
  102. return stacktrace.Propagate(err, "")
  103. }
  104. }
  105. return nil
  106. }
  107. // Unsubscribe the given email address to our default Zoho Campaigns list
  108. // or Listmonk Campaigns List
  109. //
  110. // See: [Note: Syncing emails with Zoho Campaigns]
  111. func (c *MailingListsController) Unsubscribe(email string) error {
  112. if !(c.shouldSkipZoho()) {
  113. // https://www.zoho.com/campaigns/help/developers/contact-unsubscribe.html
  114. err := c.doListActionZoho("listunsubscribe", email)
  115. if err != nil {
  116. return stacktrace.Propagate(err, "")
  117. }
  118. }
  119. if !(c.shouldSkipListmonk()) {
  120. err := c.listmonkUnsubscribe(email)
  121. if err != nil {
  122. return stacktrace.Propagate(err, "")
  123. }
  124. }
  125. return nil
  126. }
  127. // shouldSkipZoho() checks if the MailingListsController
  128. // should be skipped due to missing credentials.
  129. func (c *MailingListsController) shouldSkipZoho() bool {
  130. if c.zohoCredentials.RefreshToken == "" {
  131. log.Info("Skipping Zoho mailing list update because credentials are not configured")
  132. return true
  133. }
  134. return false
  135. }
  136. // shouldSkipListmonk() checks if the Listmonk mailing list
  137. // should be skipped due to missing credentials
  138. // listmonklistIDs value.
  139. //
  140. // ListmonkListIDs is an optional field for subscribing an email address
  141. // (user gets added to the default list),
  142. // but is a required field for unsubscribing an email address
  143. func (c *MailingListsController) shouldSkipListmonk() bool {
  144. if c.listmonkCredentials.BaseURL == "" || c.listmonkCredentials.Username == "" ||
  145. c.listmonkCredentials.Password == "" || len(c.listmonkListIDs) == 0 {
  146. log.Info("Skipping Listmonk mailing list because credentials are not configured")
  147. return true
  148. }
  149. return false
  150. }
  151. // Both the listsubscribe and listunsubscribe Zoho Campaigns API endpoints work
  152. // similarly, so use this function to keep the common code.
  153. func (c *MailingListsController) doListActionZoho(action string, email string) error {
  154. // Query escape the email so that any pluses get converted to %2B.
  155. escapedEmail := url.QueryEscape(email)
  156. contactInfo := fmt.Sprintf("{Contact+Email: \"%s\"}", escapedEmail)
  157. // Instead of using QueryEscape, use PathEscape. QueryEscape escapes the "+"
  158. // character, which causes Zoho API to not recognize the parameter.
  159. escapedContactInfo := url.PathEscape(contactInfo)
  160. url := fmt.Sprintf(
  161. "https://campaigns.zoho.com/api/v1.1/json/%s?resfmt=JSON&listkey=%s&contactinfo=%s&topic_id=%s",
  162. action, c.zohoListKey, escapedContactInfo, c.zohoTopicIds)
  163. zohoAccessToken, err := zoho.DoRequest("POST", url, c.zohoAccessToken, c.zohoCredentials)
  164. c.zohoAccessToken = zohoAccessToken
  165. if err != nil {
  166. // This is not necessarily an error, and can happen when the customer
  167. // had earlier unsubscribed from our organization emails in Zoho,
  168. // selecting the "Erase my data" option. This causes Zoho to remove the
  169. // customer's entire record from their database.
  170. //
  171. // Then later, say if the customer deletes their account from ente, we
  172. // would try to unsubscribe their email but it wouldn't be present in
  173. // Zoho, and this API call would've failed.
  174. //
  175. // In such a case, Zoho will return the following response:
  176. //
  177. // { code":"2103",
  178. // "message":"Contact does not exist.",
  179. // "version":"1.1",
  180. // "uri":"/api/v1.1/json/listunsubscribe",
  181. // "status":"error"}
  182. //
  183. // Special case these to reduce the severity level so as to not cause
  184. // error log spam.
  185. if strings.Contains(err.Error(), "Contact does not exist") {
  186. log.Warnf("Zoho - Could not %s '%s': %s", action, email, err)
  187. } else {
  188. log.Errorf("Zoho - Could not %s '%s': %s", action, email, err)
  189. }
  190. }
  191. return stacktrace.Propagate(err, "")
  192. }
  193. // Subscribes an email address to a particular listmonk campaign mailing list
  194. func (c *MailingListsController) listmonkSubscribe(email string) error {
  195. data := map[string]interface{}{
  196. "email": email,
  197. "lists": c.listmonkListIDs,
  198. }
  199. return listmonk.SendRequest("POST", c.listmonkCredentials.BaseURL+"/api/subscribers", data,
  200. c.listmonkCredentials.Username, c.listmonkCredentials.Password)
  201. }
  202. // Unsubscribes an email address to a particular listmonk campaign mailing list
  203. func (c *MailingListsController) listmonkUnsubscribe(email string) error {
  204. // Listmonk doesn't provide an endpoint for unsubscribing users
  205. // from a particular list directly via their email
  206. //
  207. // Thus, fetching subscriberID through email address,
  208. // and then calling the endpoint to delete that user
  209. id, err := listmonk.GetSubscriberID(c.listmonkCredentials.BaseURL+"/api/subscribers",
  210. c.listmonkCredentials.Username, c.listmonkCredentials.Password, email)
  211. if err != nil {
  212. stacktrace.Propagate(err, "")
  213. }
  214. return listmonk.SendRequest("DELETE", c.listmonkCredentials.BaseURL+"/api/subscribers/"+strconv.Itoa(id),
  215. map[string]interface{}{}, c.listmonkCredentials.Username, c.listmonkCredentials.Password)
  216. }