diff --git a/services/web/app/src/Features/Newsletter/MailChimpProvider.js b/services/web/app/src/Features/Newsletter/MailChimpProvider.js new file mode 100644 index 0000000000..4c56a8ec60 --- /dev/null +++ b/services/web/app/src/Features/Newsletter/MailChimpProvider.js @@ -0,0 +1,260 @@ +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' + ) + } +} diff --git a/services/web/app/src/Features/Newsletter/NewsletterManager.js b/services/web/app/src/Features/Newsletter/NewsletterManager.js index 18ac40dd5f..ebae9f288f 100644 --- a/services/web/app/src/Features/Newsletter/NewsletterManager.js +++ b/services/web/app/src/Features/Newsletter/NewsletterManager.js @@ -1,248 +1,9 @@ -const { callbackify } = require('util') -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 MailchimpProvider = require('./MailChimpProvider') -const provider = getProvider() +const provider = MailchimpProvider.make( + 'newsletter', + Settings.mailchimp ? Settings.mailchimp.list_id : null +) -module.exports = { - subscribed: callbackify(provider.subscribed), - 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.debug('Using newsletter provider: mailchimp') - return makeMailchimpProvider() - } else { - logger.debug('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 { - 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, - }) - } - } - - 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 }, '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.debug( - { 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.debug( - { 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.debug( - { 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.debug('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 }, 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 { - subscribed, - subscribe, - unsubscribe, - changeEmail, - } - - async function subscribed(user) { - logger.debug( - { user }, - 'Not checking user because no newsletter provider is configured' - ) - return false - } - - async function subscribe(user) { - logger.debug( - { user }, - 'Not subscribing user to newsletter because no newsletter provider is configured' - ) - } - - async function unsubscribe(user) { - logger.debug( - { user }, - 'Not unsubscribing user from newsletter because no newsletter provider is configured' - ) - } - - async function changeEmail(oldEmail, newEmail) { - logger.debug( - { oldEmail, newEmail }, - 'Not changing email in newsletter for user because no newsletter provider is configured' - ) - } -} +module.exports = provider diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 70625fa739..8a7e635ebf 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -765,7 +765,7 @@ const ProjectController = { ) User.findById( userId, - 'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace', + 'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace labsProgram labsProgramGalileo', (err, user) => { // Handle case of deleted user if (user == null) { @@ -1197,6 +1197,8 @@ const ProjectController = { refProviders: _.mapValues(user.refProviders, Boolean), alphaProgram: user.alphaProgram, betaProgram: user.betaProgram, + labsProgram: user.labsProgram, + labsProgramGalileo: user.labsProgramGalileo, isAdmin: hasAdminAccess(user), }, userSettings: { diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index 8495376413..bd7e8d8e81 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -290,8 +290,16 @@ const UserController = { OError.tag(err, 'error unsubscribing to newsletter') return next(err) } - return res.json({ - message: req.i18n.translate('thanks_settings_updated'), + // TODO: figure out why things go wrong if we import at the top + const Modules = require('../../infrastructure/Modules') + Modules.hooks.fire('newsletterUnsubscribed', user, err => { + if (err) { + OError.tag(err, 'error firing "newsletterUnsubscribed" hook') + return next(err) + } + return res.json({ + message: req.i18n.translate('thanks_settings_updated'), + }) }) }) }) diff --git a/services/web/app/src/Features/User/UserPagesController.js b/services/web/app/src/Features/User/UserPagesController.js index 39fe4e494f..c6feb07b42 100644 --- a/services/web/app/src/Features/User/UserPagesController.js +++ b/services/web/app/src/Features/User/UserPagesController.js @@ -73,6 +73,7 @@ async function settingsPage(req, res) { last_name: user.last_name, alphaProgram: user.alphaProgram, betaProgram: user.betaProgram, + labsProgram: user.labsProgram, features: { dropbox: user.features.dropbox, github: user.features.github, diff --git a/services/web/app/src/Features/User/UserUpdater.js b/services/web/app/src/Features/User/UserUpdater.js index ee77bd9c08..03da274ff5 100644 --- a/services/web/app/src/Features/User/UserUpdater.js +++ b/services/web/app/src/Features/User/UserUpdater.js @@ -217,6 +217,16 @@ async function setDefaultEmailAddress( 'Failed to change email in newsletter subscription' ) } + try { + // TODO: figure out why things go wrong if we import at the top + const Modules = require('../../infrastructure/Modules') + await Modules.promises.hooks.fire('userEmailChanged', user, email) + } catch (err) { + logger.error( + { err, oldEmail, newEmail: email }, + 'Failed to fire "userEmailChanged" hook' + ) + } try { await RecurlyWrapper.promises.updateAccountEmailAddress(user._id, email) diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js index 1d6b8089bc..27db546836 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.js +++ b/services/web/app/src/infrastructure/ExpressLocals.js @@ -411,6 +411,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) { Settings.analytics.ga.tokenV4, cookieDomain: Settings.cookieDomain, templateLinks: Settings.templateLinks, + labsEnabled: Settings.labs && Settings.labs.enable, } next() }) diff --git a/services/web/app/src/infrastructure/Modules.js b/services/web/app/src/infrastructure/Modules.js index 0157a4b852..cb2c8e99fa 100644 --- a/services/web/app/src/infrastructure/Modules.js +++ b/services/web/app/src/infrastructure/Modules.js @@ -21,7 +21,7 @@ function loadModules() { require(settingsCheckModule) } - for (const moduleName of Settings.moduleImportSequence) { + for (const moduleName of Settings.moduleImportSequence || []) { const loadedModule = require(Path.join( MODULE_BASE_PATH, moduleName, diff --git a/services/web/app/src/models/User.js b/services/web/app/src/models/User.js index 200c19a2aa..89f6293000 100644 --- a/services/web/app/src/models/User.js +++ b/services/web/app/src/models/User.js @@ -163,6 +163,8 @@ const UserSchema = new Schema({ }, alphaProgram: { type: Boolean, default: false }, // experimental features betaProgram: { type: Boolean, default: false }, + labsProgram: { type: Boolean, default: false }, + labsProgramGalileo: { type: Boolean, default: false }, overleaf: { id: { type: Number }, accessToken: { type: String }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 2acd136e31..653c6fc247 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -409,6 +409,11 @@ "or": "", "other_logs_and_files": "", "other_output_files": "", + "overleaf_labs": "", + "labs_program_benefits": "", + "labs_program_already_participating": "", + "labs_program_not_participating": "", + "manage_labs_program_membership": "", "owner": "", "page_current": "", "pagination_navigation": "", diff --git a/services/web/frontend/js/features/settings/components/labs-program-section.tsx b/services/web/frontend/js/features/settings/components/labs-program-section.tsx new file mode 100644 index 0000000000..ccb8ea2794 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/labs-program-section.tsx @@ -0,0 +1,28 @@ +import { useTranslation, Trans } from 'react-i18next' +import { useUserContext } from '../../../shared/context/user-context' + +function LabsProgramSection() { + const { t } = useTranslation() + const { labsProgram } = useUserContext() + + return ( + <> +
+
+ {labsProgram + ? t('labs_program_already_participating') + : t('labs_program_not_participating')} +
+ {t('manage_labs_program_membership')} + > + ) +} + +export default LabsProgramSection diff --git a/services/web/frontend/js/features/settings/components/root.tsx b/services/web/frontend/js/features/settings/components/root.tsx index db92f2bfc1..9f421a0813 100644 --- a/services/web/frontend/js/features/settings/components/root.tsx +++ b/services/web/frontend/js/features/settings/components/root.tsx @@ -6,6 +6,7 @@ import AccountInfoSection from './account-info-section' import PasswordSection from './password-section' import LinkingSection from './linking-section' import BetaProgramSection from './beta-program-section' +import LabsProgramSection from './labs-program-section' import SessionsSection from './sessions-section' import NewsletterSection from './newsletter-section' import LeaveSection from './leave-section' @@ -38,7 +39,9 @@ function SettingsPageRoot() { function SettingsPageContent() { const { t } = useTranslation() - const { isOverleaf } = getMeta('ol-ExposedSettings') as ExposedSettings + const { isOverleaf, labsEnabled } = getMeta( + 'ol-ExposedSettings' + ) as ExposedSettings return (