mailing_lists.go 9.0 KB

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