MailChimpProvider.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. const logger = require('@overleaf/logger')
  2. const Settings = require('@overleaf/settings')
  3. const crypto = require('crypto')
  4. const Mailchimp = require('mailchimp-api-v3')
  5. const OError = require('@overleaf/o-error')
  6. const { callbackify } = require('util')
  7. function mailchimpIsConfigured() {
  8. return Settings.mailchimp != null && Settings.mailchimp.api_key != null
  9. }
  10. function make(listName, listId) {
  11. let provider
  12. if (!mailchimpIsConfigured() || !listId) {
  13. logger.debug({ listName }, 'Using newsletter provider: none')
  14. provider = makeNullProvider(listName)
  15. } else {
  16. logger.debug({ listName }, 'Using newsletter provider: mailchimp')
  17. provider = makeMailchimpProvider(listName, listId)
  18. }
  19. return {
  20. subscribed: callbackify(provider.subscribed),
  21. subscribe: callbackify(provider.subscribe),
  22. unsubscribe: callbackify(provider.unsubscribe),
  23. changeEmail: callbackify(provider.changeEmail),
  24. promises: provider,
  25. }
  26. }
  27. module.exports = {
  28. make,
  29. }
  30. class NonFatalEmailUpdateError extends OError {
  31. constructor(message, oldEmail, newEmail) {
  32. super(message, { oldEmail, newEmail })
  33. }
  34. }
  35. function makeMailchimpProvider(listName, listId) {
  36. const mailchimp = new Mailchimp(Settings.mailchimp.api_key)
  37. const MAILCHIMP_LIST_ID = listId
  38. return {
  39. subscribed,
  40. subscribe,
  41. unsubscribe,
  42. changeEmail,
  43. }
  44. async function subscribed(user) {
  45. try {
  46. const path = getSubscriberPath(user.email)
  47. const result = await mailchimp.get(path)
  48. return result?.status === 'subscribed'
  49. } catch (err) {
  50. if (err.status === 404) {
  51. return false
  52. }
  53. throw OError.tag(err, 'error getting newsletter subscriptions status', {
  54. userId: user._id,
  55. listName,
  56. })
  57. }
  58. }
  59. async function subscribe(user) {
  60. try {
  61. const path = getSubscriberPath(user.email)
  62. await mailchimp.put(path, {
  63. email_address: user.email,
  64. status: 'subscribed',
  65. status_if_new: 'subscribed',
  66. merge_fields: getMergeFields(user),
  67. })
  68. logger.debug(
  69. { user, listName },
  70. 'finished subscribing user to newsletter'
  71. )
  72. } catch (err) {
  73. throw OError.tag(err, 'error subscribing user to newsletter', {
  74. userId: user._id,
  75. listName,
  76. })
  77. }
  78. }
  79. async function unsubscribe(user, options = {}) {
  80. try {
  81. const path = getSubscriberPath(user.email)
  82. if (options.delete) {
  83. await mailchimp.delete(path)
  84. } else {
  85. await mailchimp.patch(path, {
  86. status: 'unsubscribed',
  87. merge_fields: getMergeFields(user),
  88. })
  89. }
  90. logger.debug(
  91. { user, options, listName },
  92. 'finished unsubscribing user from newsletter'
  93. )
  94. } catch (err) {
  95. if (err.status === 404 || err.status === 405) {
  96. // silently ignore users who were never subscribed (404) or previously deleted (405)
  97. return
  98. }
  99. if (err.message.includes('looks fake or invalid')) {
  100. logger.debug(
  101. { err, user, options, listName },
  102. 'Mailchimp declined to unsubscribe user because it finds the email looks fake'
  103. )
  104. return
  105. }
  106. throw OError.tag(err, 'error unsubscribing user from newsletter', {
  107. userId: user._id,
  108. listName,
  109. })
  110. }
  111. }
  112. async function changeEmail(user, newEmail) {
  113. const oldEmail = user.email
  114. try {
  115. await updateEmailInMailchimp(user, newEmail)
  116. } catch (updateError) {
  117. // if we failed to update the user, delete their old email address so that
  118. // we don't leave it stuck in mailchimp
  119. logger.debug(
  120. { oldEmail, newEmail, updateError, listName },
  121. 'unable to change email in newsletter, removing old mail'
  122. )
  123. try {
  124. await unsubscribe(user, { delete: true })
  125. } catch (unsubscribeError) {
  126. // something went wrong removing the user's address
  127. throw OError.tag(
  128. unsubscribeError,
  129. 'error unsubscribing old email in response to email change failure',
  130. { oldEmail, newEmail, updateError, listName }
  131. )
  132. }
  133. if (!(updateError instanceof NonFatalEmailUpdateError)) {
  134. throw updateError
  135. }
  136. }
  137. }
  138. async function updateEmailInMailchimp(user, newEmail) {
  139. const oldEmail = user.email
  140. // mailchimp doesn't give us error codes, so we have to parse the message :'(
  141. const errors = {
  142. 'merge fields were invalid': 'user has never subscribed',
  143. 'could not be validated':
  144. 'user has previously unsubscribed or new email already exist on list',
  145. 'is already a list member': 'new email is already on mailing list',
  146. 'looks fake or invalid': 'mail looks fake to mailchimp',
  147. }
  148. try {
  149. const path = getSubscriberPath(oldEmail)
  150. await mailchimp.patch(path, {
  151. email_address: newEmail,
  152. merge_fields: getMergeFields(user),
  153. })
  154. logger.debug(
  155. { newEmail, listName },
  156. 'finished changing email in the newsletter'
  157. )
  158. } catch (err) {
  159. // silently ignore users who were never subscribed
  160. if (err.status === 404) {
  161. return
  162. }
  163. // look through expected mailchimp errors and log if we find one
  164. Object.keys(errors).forEach(key => {
  165. if (err.message.includes(key)) {
  166. const message = `unable to change email in newsletter, ${errors[key]}`
  167. logger.debug({ oldEmail, newEmail, listName }, message)
  168. throw new NonFatalEmailUpdateError(
  169. message,
  170. oldEmail,
  171. newEmail
  172. ).withCause(err)
  173. }
  174. })
  175. // if we didn't find an expected error, generate something to throw
  176. throw OError.tag(err, 'error changing email in newsletter', {
  177. oldEmail,
  178. newEmail,
  179. listName,
  180. })
  181. }
  182. }
  183. function getSubscriberPath(email) {
  184. const emailHash = hashEmail(email)
  185. return `/lists/${MAILCHIMP_LIST_ID}/members/${emailHash}`
  186. }
  187. function hashEmail(email) {
  188. return crypto.createHash('md5').update(email.toLowerCase()).digest('hex')
  189. }
  190. function getMergeFields(user) {
  191. return {
  192. FNAME: user.first_name,
  193. LNAME: user.last_name,
  194. MONGO_ID: user._id.toString(),
  195. }
  196. }
  197. }
  198. function makeNullProvider(listName) {
  199. return {
  200. subscribed,
  201. subscribe,
  202. unsubscribe,
  203. changeEmail,
  204. }
  205. async function subscribed(user) {
  206. logger.debug(
  207. { user, listName },
  208. 'Not checking user because no newsletter provider is configured'
  209. )
  210. return false
  211. }
  212. async function subscribe(user) {
  213. logger.debug(
  214. { user, listName },
  215. 'Not subscribing user to newsletter because no newsletter provider is configured'
  216. )
  217. }
  218. async function unsubscribe(user) {
  219. logger.debug(
  220. { user, listName },
  221. 'Not unsubscribing user from newsletter because no newsletter provider is configured'
  222. )
  223. }
  224. async function changeEmail(user, newEmail) {
  225. logger.debug(
  226. { userId: user._id, newEmail, listName },
  227. 'Not changing email in newsletter for user because no newsletter provider is configured'
  228. )
  229. }
  230. }