overleaf/services/web/app/src/Features/Newsletter/NewsletterManager.js

223 lines
6 KiB
JavaScript
Raw Normal View History

const { callbackify } = require('util')
const logger = require('logger-sharelatex')
const Settings = require('settings-sharelatex')
const crypto = require('crypto')
const Mailchimp = require('mailchimp-api-v3')
const OError = require('@overleaf/o-error')
const provider = getProvider()
module.exports = {
subscribe: callbackify(provider.subscribe),
unsubscribe: callbackify(provider.unsubscribe),
changeEmail: callbackify(provider.changeEmail),
promises: provider,
}
class NonFatalEmailUpdateError extends OError {
constructor(message, oldEmail, newEmail) {
super(message, { oldEmail, newEmail })
}
}
function getProvider() {
if (mailchimpIsConfigured()) {
logger.info('Using newsletter provider: mailchimp')
return makeMailchimpProvider()
} else {
logger.info('Using newsletter provider: none')
return makeNullProvider()
}
}
function mailchimpIsConfigured() {
return Settings.mailchimp != null && Settings.mailchimp.api_key != null
}
function makeMailchimpProvider() {
const mailchimp = new Mailchimp(Settings.mailchimp.api_key)
const MAILCHIMP_LIST_ID = Settings.mailchimp.list_id
return {
subscribe,
unsubscribe,
changeEmail,
}
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.info({ user }, 'finished subscribing user to newsletter')
} catch (err) {
throw OError.tag(err, 'error subscribing user to newsletter', {
userId: user._id,
})
}
}
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.info(
{ user, options },
'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.info(
{ err, user, options },
'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,
})
}
}
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.info(
{ oldEmail, newEmail, updateError },
'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 }
)
}
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.info('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.info({ oldEmail, newEmail }, 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,
})
}
}
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() {
return {
subscribe,
unsubscribe,
changeEmail,
}
async function subscribe(user) {
logger.info(
{ user },
'Not subscribing user to newsletter because no newsletter provider is configured'
)
}
async function unsubscribe(user) {
logger.info(
{ user },
'Not unsubscribing user from newsletter because no newsletter provider is configured'
)
}
async function changeEmail(oldEmail, newEmail) {
logger.info(
{ oldEmail, newEmail },
'Not changing email in newsletter for user because no newsletter provider is configured'
)
}
}