| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260 |
- const logger = require('@overleaf/logger')
- const Settings = require('@overleaf/settings')
- const crypto = require('crypto')
- const Mailchimp = require('mailchimp-api-v3')
- const OError = require('@overleaf/o-error')
- const { callbackify } = require('util')
- function mailchimpIsConfigured() {
- return Settings.mailchimp != null && Settings.mailchimp.api_key != null
- }
- function make(listName, listId) {
- let provider
- if (!mailchimpIsConfigured() || !listId) {
- logger.debug({ listName }, 'Using newsletter provider: none')
- provider = makeNullProvider(listName)
- } else {
- logger.debug({ listName }, 'Using newsletter provider: mailchimp')
- provider = makeMailchimpProvider(listName, listId)
- }
- return {
- subscribed: callbackify(provider.subscribed),
- subscribe: callbackify(provider.subscribe),
- unsubscribe: callbackify(provider.unsubscribe),
- changeEmail: callbackify(provider.changeEmail),
- promises: provider,
- }
- }
- module.exports = {
- make,
- }
- class NonFatalEmailUpdateError extends OError {
- constructor(message, oldEmail, newEmail) {
- super(message, { oldEmail, newEmail })
- }
- }
- function makeMailchimpProvider(listName, listId) {
- const mailchimp = new Mailchimp(Settings.mailchimp.api_key)
- const MAILCHIMP_LIST_ID = listId
- return {
- subscribed,
- subscribe,
- unsubscribe,
- changeEmail,
- }
- async function subscribed(user) {
- try {
- const path = getSubscriberPath(user.email)
- const result = await mailchimp.get(path)
- return result?.status === 'subscribed'
- } catch (err) {
- if (err.status === 404) {
- return false
- }
- throw OError.tag(err, 'error getting newsletter subscriptions status', {
- userId: user._id,
- listName,
- })
- }
- }
- async function subscribe(user) {
- try {
- const path = getSubscriberPath(user.email)
- await mailchimp.put(path, {
- email_address: user.email,
- status: 'subscribed',
- status_if_new: 'subscribed',
- merge_fields: getMergeFields(user),
- })
- logger.debug(
- { user, listName },
- 'finished subscribing user to newsletter'
- )
- } catch (err) {
- throw OError.tag(err, 'error subscribing user to newsletter', {
- userId: user._id,
- listName,
- })
- }
- }
- async function unsubscribe(user, options = {}) {
- try {
- const path = getSubscriberPath(user.email)
- if (options.delete) {
- await mailchimp.delete(path)
- } else {
- await mailchimp.patch(path, {
- status: 'unsubscribed',
- merge_fields: getMergeFields(user),
- })
- }
- logger.debug(
- { user, options, listName },
- 'finished unsubscribing user from newsletter'
- )
- } catch (err) {
- if (err.status === 404 || err.status === 405) {
- // silently ignore users who were never subscribed (404) or previously deleted (405)
- return
- }
- if (err.message.includes('looks fake or invalid')) {
- logger.debug(
- { err, user, options, listName },
- 'Mailchimp declined to unsubscribe user because it finds the email looks fake'
- )
- return
- }
- throw OError.tag(err, 'error unsubscribing user from newsletter', {
- userId: user._id,
- listName,
- })
- }
- }
- async function changeEmail(user, newEmail) {
- const oldEmail = user.email
- try {
- await updateEmailInMailchimp(user, newEmail)
- } catch (updateError) {
- // if we failed to update the user, delete their old email address so that
- // we don't leave it stuck in mailchimp
- logger.debug(
- { oldEmail, newEmail, updateError, listName },
- 'unable to change email in newsletter, removing old mail'
- )
- try {
- await unsubscribe(user, { delete: true })
- } catch (unsubscribeError) {
- // something went wrong removing the user's address
- throw OError.tag(
- unsubscribeError,
- 'error unsubscribing old email in response to email change failure',
- { oldEmail, newEmail, updateError, listName }
- )
- }
- if (!(updateError instanceof NonFatalEmailUpdateError)) {
- throw updateError
- }
- }
- }
- async function updateEmailInMailchimp(user, newEmail) {
- const oldEmail = user.email
- // mailchimp doesn't give us error codes, so we have to parse the message :'(
- const errors = {
- 'merge fields were invalid': 'user has never subscribed',
- 'could not be validated':
- 'user has previously unsubscribed or new email already exist on list',
- 'is already a list member': 'new email is already on mailing list',
- 'looks fake or invalid': 'mail looks fake to mailchimp',
- }
- try {
- const path = getSubscriberPath(oldEmail)
- await mailchimp.patch(path, {
- email_address: newEmail,
- merge_fields: getMergeFields(user),
- })
- logger.debug(
- { newEmail, listName },
- 'finished changing email in the newsletter'
- )
- } catch (err) {
- // silently ignore users who were never subscribed
- if (err.status === 404) {
- return
- }
- // look through expected mailchimp errors and log if we find one
- Object.keys(errors).forEach(key => {
- if (err.message.includes(key)) {
- const message = `unable to change email in newsletter, ${errors[key]}`
- logger.debug({ oldEmail, newEmail, listName }, message)
- throw new NonFatalEmailUpdateError(
- message,
- oldEmail,
- newEmail
- ).withCause(err)
- }
- })
- // if we didn't find an expected error, generate something to throw
- throw OError.tag(err, 'error changing email in newsletter', {
- oldEmail,
- newEmail,
- listName,
- })
- }
- }
- function getSubscriberPath(email) {
- const emailHash = hashEmail(email)
- return `/lists/${MAILCHIMP_LIST_ID}/members/${emailHash}`
- }
- function hashEmail(email) {
- return crypto.createHash('md5').update(email.toLowerCase()).digest('hex')
- }
- function getMergeFields(user) {
- return {
- FNAME: user.first_name,
- LNAME: user.last_name,
- MONGO_ID: user._id.toString(),
- }
- }
- }
- function makeNullProvider(listName) {
- return {
- subscribed,
- subscribe,
- unsubscribe,
- changeEmail,
- }
- async function subscribed(user) {
- logger.debug(
- { user, listName },
- 'Not checking user because no newsletter provider is configured'
- )
- return false
- }
- async function subscribe(user) {
- logger.debug(
- { user, listName },
- 'Not subscribing user to newsletter because no newsletter provider is configured'
- )
- }
- async function unsubscribe(user) {
- logger.debug(
- { user, listName },
- 'Not unsubscribing user from newsletter because no newsletter provider is configured'
- )
- }
- async function changeEmail(user, newEmail) {
- logger.debug(
- { userId: user._id, newEmail, listName },
- 'Not changing email in newsletter for user because no newsletter provider is configured'
- )
- }
- }
|