Merge pull request #30788 from overleaf/rh-cio-sub-topics

Replace Mailchimp newsletter subscriptions with customer.io topic subscriptions

GitOrigin-RevId: de536fb7ef24ff44c494659b2ef5ede5b33cc977
This commit is contained in:
roo hutton
2026-02-04 09:06:24 +00:00
committed by Copybot
parent 061cc99dad
commit a0a621f727
24 changed files with 409 additions and 875 deletions
+8 -7
View File
@@ -10483,9 +10483,9 @@
}
},
"node_modules/@customerio/cdp-analytics-core": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@customerio/cdp-analytics-core/-/cdp-analytics-core-0.3.0.tgz",
"integrity": "sha512-5BJ8VgUkLT2YuDZ7Dr+oWnpFNObF5tgfj1hU/E01+kxUAvzUoOiLNL6NjKy9AeZYIFuzqgzYCfmR/1mGYdngsw==",
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@customerio/cdp-analytics-core/-/cdp-analytics-core-0.3.9.tgz",
"integrity": "sha512-AjMB48tTu8JN4NAgmbOoGAS1veE7AvLUyZD+qKXZLD/muWy6Vou4MEfXLVzhQP/cIbf3VBPGUxMnugjwZFc6Sw==",
"license": "MIT",
"dependencies": {
"@lukeed/uuid": "^2.0.0",
@@ -10494,11 +10494,11 @@
}
},
"node_modules/@customerio/cdp-analytics-node": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@customerio/cdp-analytics-node/-/cdp-analytics-node-0.3.0.tgz",
"integrity": "sha512-p8WCtj+O3JoOooaFeENQzGPD8SDJ8x7z3P6s6xHtIaxsYjPFVfFVRV1avJQb3YTA+Z4Ak5h1oK/zayTIBJA6pQ==",
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@customerio/cdp-analytics-node/-/cdp-analytics-node-0.3.9.tgz",
"integrity": "sha512-+4DPQDCt0y+KhzqHoCrokicJhchiCPsnaC7W7h4ebrahc0zoLuovLKEn4pXeygi6yS5D2oKJ0bzi6xisb21mtQ==",
"dependencies": {
"@customerio/cdp-analytics-core": "0.3.0",
"@customerio/cdp-analytics-core": "0.3.9",
"@lukeed/uuid": "^2.0.0",
"buffer": "^6.0.3",
"node-fetch": "^2.6.7",
@@ -58218,6 +58218,7 @@
"@aws-sdk/client-ses": "^3.864.0",
"@contentful/rich-text-html-renderer": "^16.0.2",
"@contentful/rich-text-types": "^16.0.2",
"@customerio/cdp-analytics-node": "^0.3.9",
"@google-cloud/bigquery": "^6.0.1",
"@google-cloud/storage": "^6.10.1",
"@node-oauth/oauth2-server": "^5.1.0",
@@ -1,68 +0,0 @@
import { fetchJson, fetchNothing } from '@overleaf/fetch-utils'
class MailChimpClient {
constructor(apiKey) {
this.apiKey = apiKey
this.dc = apiKey.split('-')[1]
this.baseUrl = `https://${this.dc}.api.mailchimp.com/3.0/`
this.fetchOptions = {
method: 'GET',
basicAuth: {
user: 'any',
password: this.apiKey,
},
}
}
async request(path, options) {
try {
const requestUrl = `${this.baseUrl}${path}`
if (options.method === 'GET') {
return await fetchJson(requestUrl, options)
}
await fetchNothing(requestUrl, options)
} catch (err) {
// if there's a json body in the response, expose it in the error (for compatibility with node-mailchimp)
const errorBody = err.body ? JSON.parse(err.body) : {}
const errWithBody = Object.assign(err, errorBody)
throw errWithBody
}
}
async get(path) {
return await this.request(path, this.fetchOptions)
}
async put(path, body) {
const options = Object.assign({}, this.fetchOptions)
options.method = 'PUT'
options.json = body
return await this.request(path, options)
}
async post(path, body) {
const options = Object.assign({}, this.fetchOptions)
options.method = 'POST'
options.json = body
return await this.request(path, options)
}
async delete(path) {
const options = Object.assign({}, this.fetchOptions)
options.method = 'DELETE'
return await this.request(path, options)
}
async patch(path, body) {
const options = Object.assign({}, this.fetchOptions)
options.method = 'PATCH'
options.json = body
return await this.request(path, options)
}
}
export default MailChimpClient
@@ -1,315 +0,0 @@
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import crypto from 'node:crypto'
import OError from '@overleaf/o-error'
import { callbackify } from 'node:util'
import MailChimpClient from './MailChimpClient.mjs'
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),
tag: callbackify(provider.tag),
removeTag: callbackify(provider.removeTag),
promises: provider,
}
}
export default {
make,
}
class NonFatalEmailUpdateError extends OError {
constructor(message, oldEmail, newEmail) {
super(message, { oldEmail, newEmail })
}
}
function makeMailchimpProvider(listName, listId) {
const mailchimp = new MailChimpClient(Settings.mailchimp.api_key)
const MAILCHIMP_LIST_ID = listId
return {
subscribed,
subscribe,
unsubscribe,
changeEmail,
tag,
removeTag,
}
async function subscribed(user) {
try {
const path = getSubscriberPath(user.email)
const result = await mailchimp.get(path)
return result?.status === 'subscribed'
} catch (err) {
if (err?.response?.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 tag(user, tag) {
try {
const path = getMemberTagsPath(user.email)
await mailchimp.post(path, {
tags: [{ name: tag, status: 'active' }],
})
logger.debug({ user, listName }, `finished adding ${tag} to user`)
} catch (err) {
throw OError.tag(err, `error adding ${tag} to user`, {
userId: user._id,
listName,
tag,
})
}
}
async function removeTag(user, tag) {
try {
const path = getMemberTagsPath(user.email)
await mailchimp.post(path, {
tags: [{ name: tag, status: 'inactive' }],
})
logger.debug({ user, listName }, `finished removing ${tag} from user`)
} catch (err) {
throw OError.tag(err, `error removing ${tag} from user`, {
userId: user._id,
listName,
tag,
})
}
}
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 ([404, 405].includes(err?.response?.status)) {
// 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 getMemberTagsPath(email) {
const emailHash = hashEmail(email)
return `/lists/${MAILCHIMP_LIST_ID}/members/${emailHash}/tags`
}
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,
tag,
removeTag,
}
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'
)
}
async function tag(user, tag) {
logger.debug(
{ userId: user._id, tag, listName },
'Not tagging user because no newsletter provider is configured'
)
}
async function removeTag(user, tag) {
logger.debug(
{ userId: user._id, tag, listName },
'Not removing tag for user because no newsletter provider is configured'
)
}
}
@@ -1,9 +0,0 @@
import Settings from '@overleaf/settings'
import MailchimpProvider from './MailChimpProvider.mjs'
const provider = MailchimpProvider.make(
'newsletter',
Settings.mailchimp ? Settings.mailchimp.list_id : null
)
export default provider
@@ -2,7 +2,6 @@ import UserHandler from './UserHandler.mjs'
import UserDeleter from './UserDeleter.mjs'
import UserGetter from './UserGetter.mjs'
import { User } from '../../models/User.mjs'
import NewsletterManager from '../Newsletter/NewsletterManager.mjs'
import logger from '@overleaf/logger'
import metrics from '@overleaf/metrics'
import AuthenticationManager from '../Authentication/AuthenticationManager.mjs'
@@ -300,15 +299,16 @@ async function subscribe(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
req.logger.addFields({ userId })
const user = await UserGetter.promises.getUser(userId, {
_id: 1,
email: 1,
first_name: 1,
last_name: 1,
})
await NewsletterManager.promises.subscribe(user)
await Modules.promises.hooks.fire(
'updateTopicSubscription',
userId,
'newsletter',
true
)
res.json({
message: req.i18n.translate('thanks_settings_updated'),
subscribed: true,
})
}
@@ -316,16 +316,16 @@ async function unsubscribe(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
req.logger.addFields({ userId })
const user = await UserGetter.promises.getUser(userId, {
_id: 1,
email: 1,
first_name: 1,
last_name: 1,
})
await NewsletterManager.promises.unsubscribe(user)
await Modules.promises.hooks.fire('newsletterUnsubscribed', user)
await Modules.promises.hooks.fire(
'updateTopicSubscription',
userId,
'newsletter',
false
)
res.json({
message: req.i18n.translate('thanks_settings_updated'),
subscribed: false,
})
}
@@ -4,7 +4,6 @@ import Settings from '@overleaf/settings'
import { User } from '../../models/User.mjs'
import { DeletedUser } from '../../models/DeletedUser.mjs'
import { UserAuditLogEntry } from '../../models/UserAuditLogEntry.mjs'
import NewsletterManager from '../Newsletter/NewsletterManager.mjs'
import ProjectDeleter from '../Project/ProjectDeleter.mjs'
import SubscriptionHandler from '../Subscription/SubscriptionHandler.mjs'
import SubscriptionUpdater from '../Subscription/SubscriptionUpdater.mjs'
@@ -212,8 +211,6 @@ async function _cleanupUser(user) {
logger.info({ userId }, '[cleanupUser] removing user sessions from Redis')
await UserSessionsManager.promises.removeSessionsFromRedis(user)
if (Features.hasFeature('saas')) {
logger.info({ userId }, '[cleanupUser] unsubscribing from newsletters')
await NewsletterManager.promises.unsubscribe(user, { delete: true })
logger.info({ userId }, '[cleanupUser] cancelling subscription')
await SubscriptionHandler.promises.cancelSubscription(user)
logger.info({ userId }, '[cleanupUser] deleting affiliations')
@@ -5,8 +5,8 @@ import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import AuthenticationController from '../Authentication/AuthenticationController.mjs'
import SessionManager from '../Authentication/SessionManager.mjs'
import NewsletterManager from '../Newsletter/NewsletterManager.mjs'
import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs'
import UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.mjs'
import _ from 'lodash'
import { expressify } from '@overleaf/promise-utils'
import Features from '../../infrastructure/Features.mjs'
@@ -199,6 +199,39 @@ async function reconfirmAccountPage(req, res) {
res.render('user/reconfirm', pageData)
}
async function emailPreferencesPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const user = await UserGetter.promises.getUser(userId, {
_id: 1,
email: 1,
first_name: 1,
last_name: 1,
})
if (!user) {
throw new Error('User not found')
}
let subscribed = false
const analyticsId = await UserAnalyticsIdCache.get(userId)
if (analyticsId) {
const [preferences] = await Modules.promises.hooks.fire(
'getSubscriptionPreferences',
analyticsId
)
subscribed = Boolean(preferences?.newsletter)
}
res.render('user/email-preferences', {
title: 'newsletter_info_title',
customerIoEnabled: true,
subscribed,
user,
})
}
const UserPagesController = {
accountSuspended: expressify(accountSuspended),
@@ -279,28 +312,7 @@ const UserPagesController = {
)
},
emailPreferencesPage(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
UserGetter.getUser(
userId,
{ _id: 1, email: 1, first_name: 1, last_name: 1 },
(err, user) => {
if (err != null) {
return next(err)
}
NewsletterManager.subscribed(user, (err, subscribed) => {
if (err != null) {
OError.tag(err, 'error getting newsletter subscription status')
return next(err)
}
res.render('user/email-preferences', {
title: 'newsletter_info_title',
subscribed,
})
})
}
)
},
emailPreferencesPage: expressify(emailPreferencesPage),
async compromisedPasswordPage(req, res) {
res.render('user/compromised_password')
@@ -2,7 +2,7 @@ import { User } from '../../models/User.mjs'
import UserCreator from './UserCreator.mjs'
import UserGetter from './UserGetter.mjs'
import AuthenticationManager from '../Authentication/AuthenticationManager.mjs'
import NewsletterManager from '../Newsletter/NewsletterManager.mjs'
import Modules from '../../infrastructure/Modules.mjs'
import logger from '@overleaf/logger'
import crypto from 'node:crypto'
import EmailHandler from '../Email/EmailHandler.mjs'
@@ -72,7 +72,12 @@ const UserRegistrationHandler = {
if (userDetails.subscribeToNewsletter === 'true') {
try {
NewsletterManager.subscribe(user)
await Modules.promises.hooks.fire(
'updateTopicSubscription',
user._id,
'newsletter',
true
)
} catch (error) {
logger.warn(
{ err: error, user },
@@ -10,7 +10,6 @@ import FeaturesUpdater from '../Subscription/FeaturesUpdater.mjs'
import EmailHandler from '../Email/EmailHandler.mjs'
import EmailHelper from '../Helpers/EmailHelper.mjs'
import Errors from '../Errors/Errors.js'
import NewsletterManager from '../Newsletter/NewsletterManager.mjs'
import UserAuditLogHandler from './UserAuditLogHandler.mjs'
import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
import EmailChangeHelper from '../Analytics/EmailChangeHelper.mjs'
@@ -295,14 +294,6 @@ async function setDefaultEmailAddress(
})
}
try {
await NewsletterManager.promises.changeEmail(user, email)
} catch (error) {
logger.warn(
{ err: error, oldEmail, newEmail: email },
'Failed to change email in newsletter subscription'
)
}
try {
await Modules.promises.hooks.fire('userEmailChanged', user, email)
} catch (err) {
+3 -1
View File
@@ -3,7 +3,7 @@ if(customerIoEnabled && ExposedSettings.cioWriteKey && ExposedSettings.cioSiteId
function boolAttr(value) {
return value !== undefined ? String(value) : null;
}
script(type="text/javascript", id="cio-loader", nonce=scriptNonce, data-best-subscription=(usersBestSubscription && usersBestSubscription.type), data-ai-blocked=boolAttr(aiBlocked), data-has-ai-assist=boolAttr(hasAiAssist), data-cio-write-key=ExposedSettings.cioWriteKey, data-cio-site-id=ExposedSettings.cioSiteId, data-session-analytics-id=getSessionAnalyticsId(), data-user-id=getLoggedInUserId(), data-last-active=lastActive, data-sign-up-date=signUpDate, data-subject-area=subjectArea, data-role=role, data-used-latex=usedLatex, data-primary-occupation=primaryOccupation, data-country=countryCode, data-commons-institution=commonsInstitution, data-group-role=groupRole, data-features=user.features, data-is-managed-user=boolAttr(isManagedUser)).
script(type="text/javascript", id="cio-loader", nonce=scriptNonce, data-best-subscription=(usersBestSubscription && usersBestSubscription.type), data-ai-blocked=boolAttr(aiBlocked), data-has-ai-assist=boolAttr(hasAiAssist), data-cio-write-key=ExposedSettings.cioWriteKey, data-cio-site-id=ExposedSettings.cioSiteId, data-session-analytics-id=getSessionAnalyticsId(), data-user-id=getLoggedInUserId(), data-last-active=lastActive, data-sign-up-date=signUpDate, data-subject-area=subjectArea, data-role=role, data-used-latex=usedLatex, data-primary-occupation=primaryOccupation, data-country=countryCode, data-commons-institution=commonsInstitution, data-group-role=groupRole, data-features=user.features, data-email=user.email, data-is-managed-user=boolAttr(isManagedUser)).
function parseBool(value) {
return value === 'true' ? true : value === 'false' ? false : undefined;
@@ -24,6 +24,7 @@ if(customerIoEnabled && ExposedSettings.cioWriteKey && ExposedSettings.cioSiteId
var primaryOccupation = cioSettings.primaryOccupation;
var usedLatex = cioSettings.usedLatex;
var features = cioSettings.features;
var email = cioSettings.email;
var countryCode = cioSettings.country;
var commonsInstitution = cioSettings.commonsInstitution;
@@ -65,6 +66,7 @@ if(customerIoEnabled && ExposedSettings.cioWriteKey && ExposedSettings.cioSiteId
addIfDefined(identifyData, 'groupRole', groupRole);
addIfDefined(identifyData, 'isManagedUser', isManagedUser);
addIfDefined(identifyData, 'features', features);
addIfDefined(identifyData, 'email', email);
analytics.identify(analyticsId, identifyData);
}}();
@@ -1,48 +1,16 @@
extends ../layout-website-redesign
include ../_mixins/back_to_btns
block entrypointVar
- entrypoint = 'pages/user/email-preferences'
block append meta
meta(name='ol-newsletter-subscribed' data-type='boolean' content=subscribed)
meta(name='ol-user' data-type='json' content=user)
block vars
- isWebsiteRedesign = true
block content
main#main-content.content
.container
.row
.col-lg-10.offset-lg-1.col-xl-8.offset-xl-2
.page-header
h1 #{translate("newsletter_info_title")}
p #{translate("newsletter_info_summary")}
- var submitAction
if subscribed
- submitAction = '/user/newsletter/unsubscribe'
p !{translate("newsletter_info_subscribed", {}, ['strong'])}
else
- submitAction = '/user/newsletter/subscribe'
p !{translate("newsletter_info_unsubscribed", {}, ['strong'])}
form(
name='newsletterForm'
data-ol-async-form
data-ol-reload-on-success
action=submitAction
method='POST'
)
input(name='_csrf' type='hidden' value=csrfToken)
+formMessages
p.actions.text-center
if subscribed
button.btn-danger.btn(type='submit' data-ol-disabled-inflight)
span(data-ol-inflight='idle') #{translate("unsubscribe")}
span(hidden data-ol-inflight='pending') #{translate("saving")}…
else
button.btn-primary.btn(type='submit' data-ol-disabled-inflight)
span(data-ol-inflight='idle') #{translate("subscribe")}
span(hidden data-ol-inflight='pending') #{translate("saving")}…
if subscribed
p #{translate("newsletter_info_note")}
.page-separator
+back-to-btns
main#main-content.content.content-alt
#email-preferences-root
@@ -1158,6 +1158,10 @@
"new_tag_name": "",
"newsletter": "",
"newsletter_info_note": "",
"newsletter_info_subscribed": "",
"newsletter_info_summary": "",
"newsletter_info_title": "",
"newsletter_info_unsubscribed": "",
"newsletter_onboarding_accept": "",
"next": "",
"next_page": "",
@@ -0,0 +1,85 @@
import { useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { postJSON, getUserFacingMessage } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
import getMeta from '@/utils/meta'
function EmailPreferencesForm() {
const { t } = useTranslation()
const initialSubscribed = getMeta('ol-newsletter-subscribed')
const [subscribed, setSubscribed] = useState(initialSubscribed)
const { isLoading, isSuccess, isError, error, runAsync } = useAsync()
const handleToggleSubscription = () => {
const endpoint = subscribed
? '/user/newsletter/unsubscribe'
: '/user/newsletter/subscribe'
runAsync(postJSON<{ subscribed: boolean }>(endpoint))
.then(response => setSubscribed(response.subscribed))
.catch(() => {})
}
return (
<>
{isError && (
<OLNotification
type="error"
content={getUserFacingMessage(error)}
className="mb-3"
/>
)}
{isSuccess && (
<OLNotification
type="success"
content={t('thanks_settings_updated')}
className="mb-3"
/>
)}
<p>
{subscribed ? (
<Trans
i18nKey="newsletter_info_subscribed"
components={{ 0: <strong /> }}
/>
) : (
<Trans
i18nKey="newsletter_info_unsubscribed"
components={{ 0: <strong /> }}
/>
)}
</p>
<p className="text-center">
{subscribed ? (
<OLButton
variant="danger"
onClick={handleToggleSubscription}
disabled={isLoading}
isLoading={isLoading}
loadingLabel={`${t('saving')}…`}
>
{t('unsubscribe')}
</OLButton>
) : (
<OLButton
variant="primary"
onClick={handleToggleSubscription}
disabled={isLoading}
isLoading={isLoading}
loadingLabel={`${t('saving')}…`}
>
{t('subscribe')}
</OLButton>
)}
</p>
{subscribed && <p>{t('newsletter_info_note')}</p>}
</>
)
}
export default EmailPreferencesForm
@@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import OLPageContentCard from '@/shared/components/ol/ol-page-content-card'
import EmailPreferencesForm from './email-preferences-form'
function EmailPreferencesRoot() {
const { isReady } = useWaitForI18n()
return (
<div className="container">
<OLRow>
<OLCol xl={{ span: 10, offset: 1 }}>
{isReady ? <EmailPreferencesContent /> : null}
</OLCol>
</OLRow>
</div>
)
}
function EmailPreferencesContent() {
const { t } = useTranslation()
return (
<OLPageContentCard>
<div className="page-header">
<h1>{t('newsletter_info_title')}</h1>
</div>
<p>{t('newsletter_info_summary')}</p>
<EmailPreferencesForm />
</OLPageContentCard>
)
}
export default EmailPreferencesRoot
@@ -0,0 +1,13 @@
import { renderInReactLayout } from '@/react'
import '@/utils/meta'
import '@/utils/webpack-public-path'
import '@/infrastructure/error-reporter'
import '@/i18n'
import EmailPreferencesRoot from '@/features/settings/components/email-preferences/root'
import { UserProvider } from '@/shared/context/user-context'
renderInReactLayout('email-preferences-root', () => (
<UserProvider>
<EmailPreferencesRoot />
</UserProvider>
))
+1
View File
@@ -207,6 +207,7 @@ export interface Meta {
'ol-memberOfSSOEnabledGroups': GroupSSOLinkingStatus[]
'ol-members': MinimalUser[]
'ol-navbar': DefaultNavbarMetadata
'ol-newsletter-subscribed': boolean
'ol-no-single-dollar': boolean
'ol-notifications': NotificationType[]
'ol-notificationsInstitution': InstitutionType[]
+3 -2
View File
@@ -85,6 +85,7 @@
"@aws-sdk/client-ses": "^3.864.0",
"@contentful/rich-text-html-renderer": "^16.0.2",
"@contentful/rich-text-types": "^16.0.2",
"@customerio/cdp-analytics-node": "^0.3.9",
"@google-cloud/bigquery": "^6.0.1",
"@google-cloud/storage": "^6.10.1",
"@node-oauth/oauth2-server": "^5.1.0",
@@ -133,10 +134,10 @@
"ejs": "^3.1.10",
"email-addresses": "^5.0.0",
"eventsource-parser": "^1.1.2",
"express": "4.22.1",
"express-bearer-token": "^2.4.0",
"express-http-proxy": "^1.6.0",
"express-session": "^1.17.1",
"express": "4.22.1",
"file-type": "^21.0.0",
"focus-trap-react": "^11.0.4",
"globby": "^5.0.0",
@@ -198,7 +199,6 @@
"zod-validation-error": "^4.0.1"
},
"devDependencies": {
"5to6-codemod": "^1.8.0",
"@ai-sdk/react": "^3.0.2",
"@babel/core": "^7.28.5",
"@babel/plugin-proposal-decorators": "^7.28.0",
@@ -284,6 +284,7 @@
"@writefull/core": "^1.27.27",
"@writefull/ui": "^1.27.27",
"@writefull/utils": "^1.27.27",
"5to6-codemod": "^1.8.0",
"abort-controller": "^3.0.0",
"acorn": "^7.1.1",
"acorn-walk": "^7.1.1",
@@ -0,0 +1,147 @@
import { expect } from 'chai'
import { fireEvent, screen, render } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import EmailPreferencesForm from '../../../../../../frontend/js/features/settings/components/email-preferences/email-preferences-form'
describe('<EmailPreferencesForm />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-newsletter-subscribed', true)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows subscribed state with unsubscribe button', function () {
render(<EmailPreferencesForm />)
screen.getByText('You are', { exact: false })
screen.getByRole('button', { name: 'Unsubscribe' })
screen.getByText('Please note: you will still receive important emails', {
exact: false,
})
})
it('shows unsubscribed state with subscribe button', function () {
window.metaAttributesCache.set('ol-newsletter-subscribed', false)
render(<EmailPreferencesForm />)
screen.getByText('You are', { exact: false })
screen.getByRole('button', { name: 'Subscribe' })
expect(
screen.queryByText(
'Please note: you will still receive important emails',
{ exact: false }
)
).to.not.exist
})
it('calls unsubscribe endpoint when clicking unsubscribe', async function () {
const unsubscribeMock = fetchMock.post('/user/newsletter/unsubscribe', {
status: 200,
body: { subscribed: false },
})
render(<EmailPreferencesForm />)
const button = screen.getByRole('button', { name: 'Unsubscribe' })
fireEvent.click(button)
expect(unsubscribeMock.callHistory.called()).to.be.true
expect(unsubscribeMock.callHistory.calls().at(-1)?.url).to.equal(
'https://www.test-overleaf.com/user/newsletter/unsubscribe'
)
})
it('calls subscribe endpoint when clicking subscribe', async function () {
window.metaAttributesCache.set('ol-newsletter-subscribed', false)
const subscribeMock = fetchMock.post('/user/newsletter/subscribe', {
status: 200,
body: { subscribed: true },
})
render(<EmailPreferencesForm />)
const button = screen.getByRole('button', { name: 'Subscribe' })
fireEvent.click(button)
expect(subscribeMock.callHistory.called()).to.be.true
expect(subscribeMock.callHistory.calls().at(-1)?.url).to.equal(
'https://www.test-overleaf.com/user/newsletter/subscribe'
)
})
it('shows loading state while request is in flight', async function () {
let finishRequest: (value: any) => void = () => {}
fetchMock.post(
'/user/newsletter/unsubscribe',
new Promise(resolve => (finishRequest = resolve))
)
render(<EmailPreferencesForm />)
const button = screen.getByRole('button', { name: 'Unsubscribe' })
fireEvent.click(button)
await screen.findByRole('button', { name: 'Saving…' })
finishRequest({ status: 200, body: { subscribed: false } })
await screen.findByRole('button', { name: 'Subscribe' })
})
it('shows success notification after successful action', async function () {
fetchMock.post('/user/newsletter/unsubscribe', {
status: 200,
body: { subscribed: false },
})
render(<EmailPreferencesForm />)
const button = screen.getByRole('button', { name: 'Unsubscribe' })
fireEvent.click(button)
await screen.findByText('Thanks, your settings have been updated.')
})
it('shows error notification on server error', async function () {
fetchMock.post('/user/newsletter/unsubscribe', 500)
render(<EmailPreferencesForm />)
const button = screen.getByRole('button', { name: 'Unsubscribe' })
fireEvent.click(button)
await screen.findByText('Something went wrong. Please try again.')
})
it('shows error notification with message on 4xx error', async function () {
fetchMock.post('/user/newsletter/unsubscribe', {
status: 400,
body: { message: 'Unable to update preferences' },
})
render(<EmailPreferencesForm />)
const button = screen.getByRole('button', { name: 'Unsubscribe' })
fireEvent.click(button)
await screen.findByText('Unable to update preferences')
})
it('toggles between subscribed and unsubscribed states', async function () {
fetchMock.post('/user/newsletter/unsubscribe', {
status: 200,
body: { subscribed: false },
})
fetchMock.post('/user/newsletter/subscribe', {
status: 200,
body: { subscribed: true },
})
render(<EmailPreferencesForm />)
// Initially subscribed
screen.getByRole('button', { name: 'Unsubscribe' })
// Unsubscribe
fireEvent.click(screen.getByRole('button', { name: 'Unsubscribe' }))
await screen.findByRole('button', { name: 'Subscribe' })
// Subscribe again
fireEvent.click(screen.getByRole('button', { name: 'Subscribe' }))
await screen.findByRole('button', { name: 'Unsubscribe' })
})
})
@@ -1,215 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import { RequestFailedError } from '@overleaf/fetch-utils'
const MODULE_PATH = '../../../../app/src/Features/Newsletter/NewsletterManager'
describe('NewsletterManager', function () {
beforeEach(async function (ctx) {
ctx.Settings = {
mailchimp: {
api_key: 'api_key',
list_id: 'list_id',
},
}
ctx.mailchimp = {
get: sinon.stub(),
put: sinon.stub(),
patch: sinon.stub(),
delete: sinon.stub(),
}
ctx.Mailchimp = sinon.stub().returns(ctx.mailchimp)
ctx.mergeFields = {
FNAME: 'Overleaf',
LNAME: 'Duck',
MONGO_ID: 'user_id',
}
vi.doMock(
'../../../../app/src/Features/Newsletter/MailChimpClient',
() => ({
default: ctx.Mailchimp,
})
)
vi.doMock('@overleaf/settings', () => ({ default: ctx.Settings }))
ctx.NewsletterManager = (await import(MODULE_PATH)).default.promises
ctx.NewsletterManager.get = sinon.stub()
ctx.NewsletterManager.delete = sinon.stub()
ctx.user = {
_id: 'user_id',
email: 'overleaf.duck@example.com',
first_name: 'Overleaf',
last_name: 'Duck',
}
// MD5 sum of the user email
ctx.emailHash = 'c02f60ed0ef51818186274e406c9a48f'
})
describe('subscribed', function () {
it('calls Mailchimp to get the user status', async function (ctx) {
await ctx.NewsletterManager.subscribed(ctx.user)
expect(ctx.mailchimp.get).to.have.been.calledWith(
`/lists/list_id/members/${ctx.emailHash}`
)
})
it('returns true when subscribed', async function (ctx) {
ctx.mailchimp.get.resolves({ status: 'subscribed' })
const subscribed = await ctx.NewsletterManager.subscribed(ctx.user)
expect(subscribed).to.be.true
})
it('returns false on 404', async function (ctx) {
ctx.mailchimp.get.rejects(
new RequestFailedError(
'http://some-url',
{},
{ status: 404 },
'Not found'
)
)
const subscribed = await ctx.NewsletterManager.subscribed(ctx.user)
expect(subscribed).to.be.false
})
})
describe('subscribe', function () {
it('calls Mailchimp to subscribe the user', async function (ctx) {
await ctx.NewsletterManager.subscribe(ctx.user)
expect(ctx.mailchimp.put).to.have.been.calledWith(
`/lists/list_id/members/${ctx.emailHash}`,
{
email_address: ctx.user.email,
status: 'subscribed',
status_if_new: 'subscribed',
merge_fields: ctx.mergeFields,
}
)
})
})
describe('unsubscribe', function () {
describe('when unsubscribing normally', function () {
it('calls Mailchimp to unsubscribe the user', async function (ctx) {
await ctx.NewsletterManager.unsubscribe(ctx.user)
expect(ctx.mailchimp.patch).to.have.been.calledWith(
`/lists/list_id/members/${ctx.emailHash}`,
{
status: 'unsubscribed',
merge_fields: ctx.mergeFields,
}
)
})
it('ignores a Mailchimp error about fake emails', async function (ctx) {
ctx.mailchimp.patch.rejects(
new Error(
'overleaf.duck@example.com looks fake or invalid, please enter a real email address'
)
)
await expect(ctx.NewsletterManager.unsubscribe(ctx.user)).to.be
.fulfilled
})
it('rejects on other errors', async function (ctx) {
ctx.mailchimp.patch.rejects(
new Error('something really wrong is happening')
)
await expect(ctx.NewsletterManager.unsubscribe(ctx.user)).to.be.rejected
})
})
describe('when deleting', function () {
it('calls Mailchimp to delete the user', async function (ctx) {
await ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true })
expect(ctx.mailchimp.delete).to.have.been.calledWith(
`/lists/list_id/members/${ctx.emailHash}`
)
})
it('ignores a Mailchimp error about fake emails', async function (ctx) {
ctx.mailchimp.delete.rejects(
new Error(
'overleaf.duck@example.com looks fake or invalid, please enter a real email address'
)
)
await expect(
ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true })
).to.be.fulfilled
})
it('rejects on other errors', async function (ctx) {
ctx.mailchimp.delete.rejects(
new Error('something really wrong is happening')
)
await expect(
ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true })
).to.be.rejected
})
})
})
describe('changeEmail', function () {
it('calls Mailchimp to change the subscriber email', async function (ctx) {
await ctx.NewsletterManager.changeEmail(
ctx.user,
'overleaf.squirrel@example.com'
)
expect(ctx.mailchimp.patch).to.have.been.calledWith(
`/lists/list_id/members/${ctx.emailHash}`,
{
email_address: 'overleaf.squirrel@example.com',
merge_fields: ctx.mergeFields,
}
)
})
it('deletes the old email if changing the address fails', async function (ctx) {
ctx.mailchimp.patch
.withArgs(`/lists/list_id/members/${ctx.emailHash}`, {
email_address: 'overleaf.squirrel@example.com',
merge_fields: ctx.mergeFields,
})
.rejects(new Error('that did not work'))
await expect(
ctx.NewsletterManager.changeEmail(
ctx.user,
'overleaf.squirrel@example.com'
)
).to.be.rejected
expect(ctx.mailchimp.delete).to.have.been.calledWith(
`/lists/list_id/members/${ctx.emailHash}`
)
})
it('does not reject on non-fatal error ', async function (ctx) {
const nonFatalError = new Error('merge fields were invalid')
ctx.mailchimp.patch.rejects(nonFatalError)
await expect(
ctx.NewsletterManager.changeEmail(
ctx.user,
'overleaf.squirrel@example.com'
)
).to.be.fulfilled
})
it('rejects on any other error', async function (ctx) {
const fatalError = new Error('fatal error')
ctx.mailchimp.patch.rejects(fatalError)
await expect(
ctx.NewsletterManager.changeEmail(
ctx.user,
'overleaf.squirrel@example.com'
)
).to.be.rejected
})
})
})
@@ -52,11 +52,8 @@ describe('UserController', function () {
findById: sinon.stub().returns({ exec: sinon.stub().resolves(ctx.user) }),
}
ctx.NewsLetterManager = {
promises: {
subscribe: sinon.stub().resolves(),
unsubscribe: sinon.stub().resolves(),
},
ctx.AnalyticsManager = {
recordEventForUserInBackground: sinon.stub(),
}
ctx.SessionManager = {
@@ -143,6 +140,13 @@ describe('UserController', function () {
},
}
vi.doMock(
'../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({
default: ctx.AnalyticsManager,
})
)
vi.doMock('../../../../app/src/Features/Helpers/UrlHelper', () => ({
default: ctx.UrlHelper,
}))
@@ -163,13 +167,6 @@ describe('UserController', function () {
User: ctx.User,
}))
vi.doMock(
'../../../../app/src/Features/Newsletter/NewsletterManager',
() => ({
default: ctx.NewsLetterManager,
})
)
vi.doMock(
'../../../../app/src/Features/Authentication/AuthenticationController',
() => ({
@@ -429,36 +426,6 @@ describe('UserController', function () {
})
})
describe('subscribe', function () {
it('should send the user to subscribe', function (ctx) {
return new Promise(resolve => {
ctx.res.json = data => {
expect(data.message).to.equal('thanks_settings_updated')
ctx.NewsLetterManager.promises.subscribe.should.have.been.calledWith(
ctx.user
)
resolve()
}
ctx.UserController.subscribe(ctx.req, ctx.res)
})
})
})
describe('unsubscribe', function () {
it('should send the user to unsubscribe', function (ctx) {
return new Promise(resolve => {
ctx.res.json = data => {
expect(data.message).to.equal('thanks_settings_updated')
ctx.NewsLetterManager.promises.unsubscribe.should.have.been.calledWith(
ctx.user
)
resolve()
}
ctx.UserController.unsubscribe(ctx.req, ctx.res, ctx.next)
})
})
})
describe('updateUserSettings', function () {
beforeEach(function (ctx) {
ctx.auditLog = { initiatorId: ctx.user_id, ipAddress: ctx.req.ip }
@@ -42,12 +42,6 @@ describe('UserDeleter', function () {
)
ctx.user = ctx.mockedUser.object
ctx.NewsletterManager = {
promises: {
unsubscribe: sinon.stub().resolves(),
},
}
ctx.ProjectDeleter = {
promises: {
deleteUsersProjects: sinon.stub().resolves(),
@@ -123,13 +117,6 @@ describe('UserDeleter', function () {
DeletedUser,
}))
vi.doMock(
'../../../../app/src/Features/Newsletter/NewsletterManager',
() => ({
default: ctx.NewsletterManager,
})
)
vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({
default: ctx.UserSessionsManager,
}))
@@ -261,15 +248,6 @@ describe('UserDeleter', function () {
ctx.UserMock.verify()
})
it('should delete the user from mailchimp', async function (ctx) {
await ctx.UserDeleter.promises.deleteUser(ctx.userId, {
ipAddress: ctx.ipAddress,
})
expect(
ctx.NewsletterManager.promises.unsubscribe
).to.have.been.calledWith(ctx.user, { delete: true })
})
it('should delete all the projects of a user', async function (ctx) {
await ctx.UserDeleter.promises.deleteUser(ctx.userId, {
ipAddress: ctx.ipAddress,
@@ -426,23 +404,6 @@ describe('UserDeleter', function () {
})
})
describe('when unsubscribing from mailchimp fails', function () {
beforeEach(function (ctx) {
ctx.NewsletterManager.promises.unsubscribe.rejects(
new Error('something went wrong')
)
})
it('should return an error and not delete the user', async function (ctx) {
await expect(
ctx.UserDeleter.promises.deleteUser(ctx.userId, {
ipAddress: ctx.ipAddress,
})
).to.be.rejected
ctx.UserMock.verify()
})
})
describe('when called as a callback', function () {
beforeEach(function (ctx) {
ctx.UserMock.expects('deleteOne')
@@ -51,9 +51,6 @@ describe('UserPagesController', function () {
getLoggedInUserId: sinon.stub().returns(ctx.user._id),
getSessionUser: sinon.stub().returns(ctx.user),
}
ctx.NewsletterManager = {
subscribed: sinon.stub().yields(),
}
ctx.AuthenticationController = {
getRedirectFromSession: sinon.stub(),
setRedirectInSession: sinon.stub(),
@@ -95,13 +92,6 @@ describe('UserPagesController', function () {
default: ctx.UserSessionsManager,
}))
vi.doMock(
'../../../../app/src/Features/Newsletter/NewsletterManager',
() => ({
default: ctx.NewsletterManager,
})
)
vi.doMock('../../../../app/src/Features/Errors/ErrorController', () => ({
default: ctx.ErrorController,
}))
@@ -350,46 +340,6 @@ describe('UserPagesController', function () {
})
})
describe('emailPreferencesPage', function () {
beforeEach(function (ctx) {
ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user)
})
it('render page with subscribed status', async function (ctx) {
ctx.NewsletterManager.subscribed.yields(null, true)
await new Promise((resolve, reject) => {
ctx.res.callback = () => {
ctx.res.renderedTemplate.should.equal('user/email-preferences')
ctx.res.renderedVariables.title.should.equal('newsletter_info_title')
ctx.res.renderedVariables.subscribed.should.equal(true)
resolve()
}
ctx.UserPagesController.emailPreferencesPage(
ctx.req,
ctx.res,
ctx.rejectOnError(reject)
)
})
})
it('render page with unsubscribed status', async function (ctx) {
ctx.NewsletterManager.subscribed.yields(null, false)
await new Promise((resolve, reject) => {
ctx.res.callback = () => {
ctx.res.renderedTemplate.should.equal('user/email-preferences')
ctx.res.renderedVariables.title.should.equal('newsletter_info_title')
ctx.res.renderedVariables.subscribed.should.equal(false)
resolve()
}
ctx.UserPagesController.emailPreferencesPage(
ctx.req,
ctx.res,
ctx.rejectOnError(reject)
)
})
})
})
describe('settingsPage', function () {
beforeEach(function (ctx) {
ctx.request.get = sinon
@@ -32,8 +32,12 @@ describe('UserRegistrationHandler', function () {
setUserPassword: sinon.stub().resolves(ctx.user),
},
}
ctx.NewsLetterManager = {
subscribe: sinon.stub(),
ctx.Modules = {
promises: {
hooks: {
fire: sinon.stub().resolves([]),
},
},
}
ctx.EmailHandler = {
promises: { sendEmail: sinon.stub().resolves() },
@@ -59,12 +63,9 @@ describe('UserRegistrationHandler', function () {
})
)
vi.doMock(
'../../../../app/src/Features/Newsletter/NewsletterManager',
() => ({
default: ctx.NewsLetterManager,
})
)
vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
default: ctx.Modules,
}))
vi.doMock('crypto', () => ({
default: (ctx.crypto = {}),
@@ -238,12 +239,22 @@ describe('UserRegistrationHandler', function () {
it('should add the user to the newsletter if accepted terms', async function (ctx) {
ctx.passingRequest.subscribeToNewsletter = 'true'
await ctx.handler.promises.registerNewUser(ctx.passingRequest)
ctx.NewsLetterManager.subscribe.calledWith(ctx.user).should.equal(true)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'updateTopicSubscription',
ctx.user._id,
'newsletter',
true
)
})
it('should not add the user to the newsletter if not accepted terms', async function (ctx) {
await ctx.handler.promises.registerNewUser(ctx.passingRequest)
ctx.NewsLetterManager.subscribe.calledWith(ctx.user).should.equal(false)
expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
'updateTopicSubscription',
sinon.match.any,
sinon.match.any,
true
)
})
})
})
@@ -60,11 +60,6 @@ describe('UserUpdater', function () {
.withArgs(ctx.user._id)
.resolves(ctx.user.email)
ctx.NewsletterManager = {
promises: {
changeEmail: sinon.stub().resolves(),
},
}
ctx.AnalyticsManager = {
recordEventForUserInBackground: sinon.stub(),
}
@@ -162,13 +157,6 @@ describe('UserUpdater', function () {
default: (ctx.settings = {}),
}))
vi.doMock(
'../../../../app/src/Features/Newsletter/NewsletterManager',
() => ({
default: ctx.NewsletterManager,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/RecurlyWrapper',
() => ({
@@ -349,10 +337,12 @@ describe('UserUpdater', function () {
)
})
it('sets the new email in the newsletter', function (ctx) {
expect(
ctx.NewsletterManager.promises.changeEmail
).to.have.been.calledWith(ctx.user, ctx.newEmail)
it('fires userEmailChanged hook', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'userEmailChanged',
ctx.user,
ctx.newEmail
)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'updateAccountEmailAddress',
ctx.user._id,
@@ -715,16 +705,18 @@ describe('UserUpdater', function () {
)
})
it('sets the changed email in the newsletter', async function (ctx) {
it('fires userEmailChanged hook', async function (ctx) {
await ctx.UserUpdater.promises.setDefaultEmailAddress(
ctx.user._id,
ctx.newEmail,
false,
ctx.auditLog
)
expect(
ctx.NewsletterManager.promises.changeEmail
).to.have.been.calledWith(ctx.user, ctx.newEmail)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'userEmailChanged',
ctx.user,
ctx.newEmail
)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'updateAccountEmailAddress',
ctx.user._id,
@@ -833,8 +825,6 @@ describe('UserUpdater', function () {
)
).to.be.rejectedWith(Errors.UnconfirmedEmailError)
expect(ctx.db.users.updateOne).to.not.have.been.called
expect(ctx.NewsletterManager.promises.changeEmail).to.not.have.been
.called
})
})
@@ -854,7 +844,6 @@ describe('UserUpdater', function () {
expect(error).to.exist
expect(error.name).to.equal('Error')
ctx.UserUpdater.promises.updateUser.callCount.should.equal(0)
ctx.NewsletterManager.promises.changeEmail.callCount.should.equal(0)
}
)
})