Przeglądaj źródła

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

Replace Mailchimp newsletter subscriptions with customer.io topic subscriptions

GitOrigin-RevId: de536fb7ef24ff44c494659b2ef5ede5b33cc977
roo hutton 6 miesięcy temu
rodzic
commit
a0a621f727
24 zmienionych plików z 409 dodań i 875 usunięć
  1. 8 7
      package-lock.json
  2. 0 68
      services/web/app/src/Features/Newsletter/MailChimpClient.mjs
  3. 0 315
      services/web/app/src/Features/Newsletter/MailChimpProvider.mjs
  4. 0 9
      services/web/app/src/Features/Newsletter/NewsletterManager.mjs
  5. 16 16
      services/web/app/src/Features/User/UserController.mjs
  6. 0 3
      services/web/app/src/Features/User/UserDeleter.mjs
  7. 35 23
      services/web/app/src/Features/User/UserPagesController.mjs
  8. 7 2
      services/web/app/src/Features/User/UserRegistrationHandler.mjs
  9. 0 9
      services/web/app/src/Features/User/UserUpdater.mjs
  10. 3 1
      services/web/app/views/_customer_io.pug
  11. 9 41
      services/web/app/views/user/email-preferences.pug
  12. 4 0
      services/web/frontend/extracted-translations.json
  13. 85 0
      services/web/frontend/js/features/settings/components/email-preferences/email-preferences-form.tsx
  14. 36 0
      services/web/frontend/js/features/settings/components/email-preferences/root.tsx
  15. 13 0
      services/web/frontend/js/pages/user/email-preferences.tsx
  16. 1 0
      services/web/frontend/js/utils/meta.ts
  17. 3 2
      services/web/package.json
  18. 147 0
      services/web/test/frontend/features/settings/components/email-preferences/email-preferences-form.test.tsx
  19. 0 215
      services/web/test/unit/src/Newsletter/NewsletterManager.test.mjs
  20. 9 42
      services/web/test/unit/src/User/UserController.test.mjs
  21. 0 39
      services/web/test/unit/src/User/UserDeleter.test.mjs
  22. 0 50
      services/web/test/unit/src/User/UserPagesController.test.mjs
  23. 21 10
      services/web/test/unit/src/User/UserRegistrationHandler.test.mjs
  24. 12 23
      services/web/test/unit/src/User/UserUpdater.test.mjs

+ 8 - 7
package-lock.json

@@ -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",

+ 0 - 68
services/web/app/src/Features/Newsletter/MailChimpClient.mjs

@@ -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

+ 0 - 315
services/web/app/src/Features/Newsletter/MailChimpProvider.mjs

@@ -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'
-    )
-  }
-}

+ 0 - 9
services/web/app/src/Features/Newsletter/NewsletterManager.mjs

@@ -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

+ 16 - 16
services/web/app/src/Features/User/UserController.mjs

@@ -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,
   })
 }
 

+ 0 - 3
services/web/app/src/Features/User/UserDeleter.mjs

@@ -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')

+ 35 - 23
services/web/app/src/Features/User/UserPagesController.mjs

@@ -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')

+ 7 - 2
services/web/app/src/Features/User/UserRegistrationHandler.mjs

@@ -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 },

+ 0 - 9
services/web/app/src/Features/User/UserUpdater.mjs

@@ -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
services/web/app/views/_customer_io.pug

@@ -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);
 		}}();

+ 9 - 41
services/web/app/views/user/email-preferences.pug

@@ -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

+ 4 - 0
services/web/frontend/extracted-translations.json

@@ -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": "",

+ 85 - 0
services/web/frontend/js/features/settings/components/email-preferences/email-preferences-form.tsx

@@ -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

+ 36 - 0
services/web/frontend/js/features/settings/components/email-preferences/root.tsx

@@ -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

+ 13 - 0
services/web/frontend/js/pages/user/email-preferences.tsx

@@ -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 - 0
services/web/frontend/js/utils/meta.ts

@@ -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
services/web/package.json

@@ -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",

+ 147 - 0
services/web/test/frontend/features/settings/components/email-preferences/email-preferences-form.test.tsx

@@ -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' })
+  })
+})

+ 0 - 215
services/web/test/unit/src/Newsletter/NewsletterManager.test.mjs

@@ -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
-    })
-  })
-})

+ 9 - 42
services/web/test/unit/src/User/UserController.test.mjs

@@ -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 }

+ 0 - 39
services/web/test/unit/src/User/UserDeleter.test.mjs

@@ -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')

+ 0 - 50
services/web/test/unit/src/User/UserPagesController.test.mjs

@@ -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

+ 21 - 10
services/web/test/unit/src/User/UserRegistrationHandler.test.mjs

@@ -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
+        )
       })
     })
   })

+ 12 - 23
services/web/test/unit/src/User/UserUpdater.test.mjs

@@ -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)
           }
         )
       })