NewsletterManager.js 6.7 KB

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