mailing_lists.go 9.1 KB

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