ソースを参照

[web] update email base templates (#33791)

* update email template design
* gate email template on split test

GitOrigin-RevId: 2e0a1d9abf11a0c3f16e103511191d43d542b8a4
Kristina 4 ヶ月 前
親
コミット
78dd0a8681

+ 28 - 0
services/web/app/src/Features/Email/Bodies/UpdatedNoCTAEmailBody.mjs

@@ -0,0 +1,28 @@
+import _ from 'lodash'
+import { fontFamily, colors } from '../emailStyles.mjs'
+
+export default _.template(`\
+<% if (title) { %>
+<h2 style="margin: 0 0 16px 0; font-family: ${fontFamily}; font-size: 30px; font-weight: 600; color: ${colors.textDark}; line-height: 40px;">
+  <%= title %>
+</h2>
+<% } %>
+
+<% if (greeting) { %>
+<p style="margin: 0 0 16px 0;">
+  <%= greeting %>
+</p>
+<% } %>
+
+<% (message).forEach(function(paragraph) { %>
+<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
+  <%= paragraph %>
+</p>
+<% }) %>
+
+<% if (highlightedText) { %>
+<div style="text-align: center; color: ${colors.textDark}; font-size: 20px; margin: 16px 0; padding: 16px 8px; border-radius: 8px; background-color: ${colors.background};">
+  <b><%= highlightedText %></b>
+</div>
+<% } %>\
+`)

+ 65 - 0
services/web/app/src/Features/Email/Bodies/updated-cta-email.mjs

@@ -0,0 +1,65 @@
+import _ from 'lodash'
+import { fontFamily, colors } from '../emailStyles.mjs'
+
+export default _.template(`\
+<% if (title) { %>
+<h2 style="margin: 0 0 16px 0; font-family: ${fontFamily}; font-size: 30px; font-weight: 600; color: ${colors.textDark}; line-height: 40px;">
+  <%= title %>
+</h2>
+<% } %>
+
+<% if (greeting) { %>
+<p style="margin: 0 0 16px 0;">
+  <%= greeting %>
+</p>
+<% } %>
+
+<% (message).forEach(function(paragraph) { %>
+<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
+  <%= paragraph %>
+</p>
+<% }) %>
+
+<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="margin: 32px 0;">
+  <tr>
+    <td bgcolor="${colors.ctaGreen}" style="background-color: ${colors.ctaGreen}; border-radius: 9999px; padding: 6px 16px; text-align: center;">
+      <a href="<%= ctaURL %>" style="color: ${colors.ctaText}; display: block; font-family: ${fontFamily}; font-size: 16px; font-weight: 600; line-height: 24px; text-align: center; text-decoration: none; -webkit-hyphens: none; -ms-hyphens: none; hyphens: none;">
+        <%= ctaText %>
+      </a>
+    </td>
+  </tr>
+</table>
+
+<% if (secondaryMessage && secondaryMessage.length > 0) { %>
+  <% (secondaryMessage).forEach(function(paragraph) { %>
+  <p class="force-overleaf-style" style="margin: 0 0 16px 0;">
+    <%= paragraph %>
+  </p>
+  <% }) %>
+<% } %>
+
+<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 20px; color: ${colors.textMuted};">
+  If the button above does not appear, please copy and paste this link into your browser's address bar:
+</p>
+<p style="margin: 0; font-size: 14px; line-height: 20px; word-break: break-all;">
+  <a href="<%= ctaURL %>" style="color: ${colors.linkGreen}; text-decoration: none;"><%= ctaURL %></a>
+</p>
+
+<% if (gmailGoToAction) { %>
+<script type="application/ld+json">
+  <%=
+    StringHelper.stringifyJsonForScript({
+      "@context": "http://schema.org",
+      "@type": "EmailMessage",
+      "potentialAction": {
+        "@type": "ViewAction",
+        "target": gmailGoToAction.target,
+        "url": gmailGoToAction.target,
+        "name": gmailGoToAction.name
+      },
+      "description": gmailGoToAction.description
+    })
+  %>
+</script>
+<% } %>\
+`)

+ 24 - 8
services/web/app/src/Features/Email/EmailBuilder.mjs

@@ -4,9 +4,12 @@ import moment from 'moment'
 import EmailMessageHelper from './EmailMessageHelper.mjs'
 import StringHelper from '../Helpers/StringHelper.mjs'
 import BaseEmailLayout from './Layouts/BaseEmailLayout.mjs'
+import UpdatedBaseEmailLayout from './Layouts/UpdatedBaseEmailLayout.mjs'
 import SpamSafe from './SpamSafe.mjs'
 import ctaEmailBody from './Bodies/cta-email.mjs'
+import updatedCtaEmailBody from './Bodies/updated-cta-email.mjs'
 import NoCTAEmailBody from './Bodies/NoCTAEmailBody.mjs'
+import UpdatedNoCTAEmailBody from './Bodies/UpdatedNoCTAEmailBody.mjs'
 
 function _emailBodyPlainText(content, opts, ctaEmail) {
   let emailBody = `${content.greeting(opts, true)}`
@@ -75,7 +78,12 @@ function ctaTemplate(content) {
     subject(opts) {
       return content.subject(opts)
     },
-    layout: BaseEmailLayout,
+    layout(opts) {
+      const layoutFn = opts.useNewEmailDesign
+        ? UpdatedBaseEmailLayout
+        : BaseEmailLayout
+      return layoutFn(opts)
+    },
     footerMessage(opts) {
       return content.footerMessage(opts)
     },
@@ -83,7 +91,8 @@ function ctaTemplate(content) {
       return _emailBodyPlainText(content, opts, true)
     },
     compiledTemplate(opts) {
-      return ctaEmailBody({
+      const bodyFn = opts.useNewEmailDesign ? updatedCtaEmailBody : ctaEmailBody
+      return bodyFn({
         title: content.title(opts),
         greeting: content.greeting(opts),
         message: content.message(opts),
@@ -108,7 +117,12 @@ function NoCTAEmailTemplate(content) {
     subject(opts) {
       return content.subject(opts)
     },
-    layout: BaseEmailLayout,
+    layout(opts) {
+      const layoutFn = opts.useNewEmailDesign
+        ? UpdatedBaseEmailLayout
+        : BaseEmailLayout
+      return layoutFn(opts)
+    },
     plainTextTemplate(opts) {
       return `\
 ${content.greeting(opts)}
@@ -120,7 +134,10 @@ The ${settings.appName} Team - ${settings.siteUrl}\
       `
     },
     compiledTemplate(opts) {
-      return NoCTAEmailBody({
+      const bodyFn = opts.useNewEmailDesign
+        ? UpdatedNoCTAEmailBody
+        : NoCTAEmailBody
+      return bodyFn({
         title:
           typeof content.title === 'function' ? content.title(opts) : undefined,
         greeting: content.greeting(opts),
@@ -137,7 +154,6 @@ The ${settings.appName} Team - ${settings.siteUrl}\
 
 function buildEmail(templateName, opts) {
   const template = templates[templateName]
-  opts.siteUrl = settings.siteUrl
   opts.body = template.compiledTemplate(opts)
   opts.footerMessage = template.footerMessage
     ? template.footerMessage(opts)
@@ -189,7 +205,7 @@ templates.canceledSubscription = ctaTemplate({
     return ['Thank you in advance!']
   },
   ctaText() {
-    return 'Leave Feedback'
+    return 'Leave feedback'
   },
   ctaURL(opts) {
     return 'https://docs.google.com/forms/d/e/1FAIpQLSfa7z_s-cucRRXm70N4jEcSbFsZeb0yuKThHGQL8ySEaQzF0Q/viewform?usp=sf_link'
@@ -394,7 +410,7 @@ templates.reconfirmEmail = ctaTemplate({
     ]
   },
   ctaText() {
-    return 'Reconfirm Email'
+    return 'Reconfirm email'
   },
   ctaURL(opts) {
     return opts.confirmEmailUrl
@@ -894,7 +910,7 @@ templates.SAMLDataCleared = ctaTemplate({
     ]
   },
   ctaText(opts) {
-    return 'Update my Emails and affiliations'
+    return 'Update my emails and affiliations'
   },
   ctaURL(opts) {
     return `${settings.siteUrl}/user/settings`

+ 24 - 1
services/web/app/src/Features/Email/EmailHandler.mjs

@@ -4,14 +4,37 @@ import logger from '@overleaf/logger'
 import EmailBuilder from './EmailBuilder.mjs'
 import EmailSender from './EmailSender.mjs'
 import Queues from '../../infrastructure/Queues.mjs'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
+import UserGetter from '../User/UserGetter.mjs'
 
 const EMAIL_SETTINGS = Settings.email || {}
 
 /**
  * @param {string} emailType
- * @param {opts} any
+ * @param {object} opts
  */
 async function sendEmail(emailType, opts) {
+  if (opts.to) {
+    try {
+      const user = await UserGetter.promises.getUserByAnyEmail(opts.to, {
+        _id: 1,
+      })
+      if (user) {
+        const { variant } =
+          await SplitTestHandler.promises.getAssignmentForUser(
+            user._id.toString(),
+            'new-email-design'
+          )
+        opts.useNewEmailDesign = variant === 'enabled'
+      }
+    } catch (err) {
+      logger.warn(
+        { err },
+        'failed to get new-email-design split test assignment'
+      )
+    }
+  }
+
   const email = EmailBuilder.buildEmail(emailType, opts)
   if (email.type === 'lifecycle' && !EMAIL_SETTINGS.lifecycle) {
     return

+ 169 - 0
services/web/app/src/Features/Email/Layouts/UpdatedBaseEmailLayout.mjs

@@ -0,0 +1,169 @@
+import _ from 'lodash'
+import settings from '@overleaf/settings'
+import { fontFamily, colors } from '../emailStyles.mjs'
+
+const _isSaas = settings.env === 'saas'
+const _imageBase = settings.cdn?.web?.host || settings.siteUrl || null
+
+let _logoHtml = `<span style="font-family: ${fontFamily}; font-size: 30px; font-weight: bold; color: ${colors.logoGreen};">${settings.appName}</span>`
+let _taglineHtml = ''
+if (_isSaas && _imageBase) {
+  _logoHtml = `
+    <a href="${settings.siteUrl}" style="text-decoration: none; display: inline-block; border: 0;">
+      <img
+        src="${_imageBase}/img/ol-brand/email-logo@2x.png"
+        alt="${settings.appName}"
+        width="139"
+        height="40"
+        style="display: block; border: 0; width: 139px; height: 40px;"
+      >
+    </a>
+  `
+
+  _taglineHtml = `
+    <!--[if mso]>
+    <img
+      src="${_imageBase}/img/ol-brand/email-footer-tagline@2x.png"
+      alt="The home of scientific and technical writing"
+      width="452"
+      height="25"
+      style="display: block; border: 0;"
+    />
+    <![endif]-->
+    <!--[if !mso]><!-->
+    <img
+      src="${_imageBase}/img/ol-brand/email-footer-tagline@2x.png"
+      alt="The home of scientific and technical writing"
+      style="display: block; border: 0; width: 100%; max-width: 452px; height: auto;"
+    >
+    <!--<![endif]-->
+  `
+}
+
+const _customFooter = settings.email?.template?.customFooter || ''
+
+export default _.template(`\
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
+<head>
+  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <meta name="x-apple-disable-message-reformatting">
+  <!--[if mso]>
+  <style type="text/css">
+    body, table, td, p, a, li { font-family: Arial, Helvetica, sans-serif !important; }
+  </style>
+  <![endif]-->
+  <style>
+    body { margin: 0; padding: 0; background-color: ${colors.background}; }
+    img { border: 0; display: block; outline: none; text-decoration: none; }
+    .email-layout-table { border-collapse: collapse !important; }
+    a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
+
+    .force-overleaf-style a,
+    .force-overleaf-style a[href] {
+      color: ${colors.linkGreen} !important;
+      text-decoration: underline !important;
+      -moz-hyphens: none;
+      -ms-hyphens: none;
+      -webkit-hyphens: none;
+      hyphens: none;
+    }
+    .force-overleaf-style a:visited,
+    .force-overleaf-style a[href]:visited { color: ${colors.linkGreen}; }
+    .force-overleaf-style a:hover,
+    .force-overleaf-style a[href]:hover { color: ${colors.linkHover}; }
+
+    @media only screen and (min-width: 621px) {
+      .email-card-inner { padding: 56px !important; }
+    }
+    @media only screen and (max-width: 620px) {
+      .email-container { width: 100% !important; max-width: 100% !important; }
+      .email-outer-cell { padding: 0 10px !important; }
+      .email-header-cell { padding: 16px 0 !important; }
+    }
+  </style>
+</head>
+<body bgcolor="${colors.background}" style="margin: 0; padding: 0; background-color: ${colors.background}; font-family: ${fontFamily};">
+
+  <!-- Prevent Gmail iOS font size manipulation -->
+  <div style="display: none; white-space: nowrap; font: 15px courier; line-height: 0;">
+    &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
+    &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
+  </div>
+
+  <table class="email-layout-table" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" bgcolor="${colors.background}" style="background-color: ${colors.background}; min-width: 100%;">
+    <tr>
+      <td class="email-outer-cell" align="center" valign="top" style="padding: 40px 10px;">
+
+        <!--[if mso]>
+        <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="600" align="center"><tr><td>
+        <![endif]-->
+        <table class="email-container email-layout-table" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;">
+
+          <!-- Logo header -->
+          <tr>
+            <td class="email-header-cell" style="padding: 0 0 24px 0;">
+              ${_logoHtml}
+            </td>
+          </tr>
+
+          <!-- White content card -->
+          <tr>
+            <td bgcolor="#ffffff" style="background-color: #ffffff; border-radius: 8px; overflow: hidden;">
+              <table class="email-layout-table" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
+                <tr>
+                  <td
+                    class="email-card-inner"
+                    style="padding: 32px 24px; font-family: ${fontFamily}; font-size: 16px; line-height: 1.5; color: ${colors.textDark};"
+                  >
+                    <%= body %>
+                  </td>
+                </tr>
+              </table>
+            </td>
+          </tr>
+
+          <!-- Footer spacer -->
+          <tr>
+            <td style="height: 24px; font-size: 24px; line-height: 24px; mso-line-height-rule: exactly;">&nbsp;</td>
+          </tr>
+
+          <!-- Footer tagline -->
+          <tr>
+            <td align="center" style="padding: 0 0 48px 0;">
+              ${_taglineHtml}
+            </td>
+          </tr>
+
+          <!-- Footer site info -->
+          ${
+            _customFooter
+              ? `<tr>
+            <td align="center" style="padding: 0 0 32px 0; font-family: ${fontFamily}; font-size: 13px; color: ${colors.textMuted}; line-height: 1.5;">
+              ${_customFooter}
+            </td>
+          </tr>`
+              : ''
+          }
+
+          <% if (footerMessage) { %>
+          <tr>
+            <td align="center" class="force-overleaf-style" style="padding: 0 0 32px 0; font-family: ${fontFamily}; font-size: 14px; color: ${colors.textMuted}; line-height: 20px;">
+              <%= footerMessage %>
+            </td>
+          </tr>
+          <% } %>
+
+        </table>
+        <!--[if mso]>
+        </td></tr></table>
+        <![endif]-->
+
+      </td>
+    </tr>
+  </table>
+
+</body>
+</html>\
+`)

+ 12 - 0
services/web/app/src/Features/Email/emailStyles.mjs

@@ -0,0 +1,12 @@
+export const fontFamily = 'Arial, Helvetica, sans-serif'
+
+export const colors = {
+  textDark: '#1b222c',
+  textMuted: '#495365',
+  background: '#f4f5f6',
+  ctaGreen: '#098842',
+  ctaText: '#ffffff',
+  linkGreen: '#1e6b41',
+  linkHover: '#155a30',
+  logoGreen: '#04652f',
+}

BIN
services/web/public/img/ol-brand/email-footer-tagline@2x.png


BIN
services/web/public/img/ol-brand/email-logo@2x.png


+ 39 - 63
services/web/test/unit/src/Email/EmailBuilder.test.mjs

@@ -275,13 +275,11 @@ describe('EmailBuilder', function () {
         describe('HTML email', function () {
           it('should include a CTA button and a fallback CTA link', function (ctx) {
             const dom = cheerio.load(ctx.email.html)
-            const buttonLink = dom('a:contains("Leave Feedback")')
+            const buttonLink = dom('a:contains("Leave feedback")')
             expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback.length).to.equal(1)
-            const fallbackLink = fallback.html()
-            expect(fallbackLink).to.contain(ctx.expectedUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.expectedUrl)
           })
         })
 
@@ -317,10 +315,8 @@ describe('EmailBuilder', function () {
             const buttonLink = dom('a:contains("Leave feedback")')
             expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback.length).to.equal(1)
-            const fallbackLink = fallback.html()
-            expect(fallbackLink).to.contain(ctx.expectedUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.expectedUrl)
           })
         })
 
@@ -354,10 +350,8 @@ describe('EmailBuilder', function () {
             const buttonLink = dom('a:contains("Confirm email")')
             expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback.length).to.equal(1)
-            const fallbackLink = fallback.html()
-            expect(fallbackLink).to.contain(ctx.opts.confirmEmailUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.opts.confirmEmailUrl)
           })
         })
 
@@ -396,13 +390,11 @@ describe('EmailBuilder', function () {
         describe('HTML email', function () {
           it('should include a CTA button and a fallback CTA link', function (ctx) {
             const dom = cheerio.load(ctx.email.html)
-            const buttonLink = dom('td a')
-            expect(buttonLink).to.exist
+            const buttonLink = dom('a:contains("View project")')
+            expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback).to.exist
-            const fallbackLink = fallback.html().replace(/&amp;/g, '&')
-            expect(fallbackLink).to.contain(ctx.expectedUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.expectedUrl)
           })
         })
 
@@ -438,13 +430,11 @@ describe('EmailBuilder', function () {
         describe('HTML email', function () {
           it('should include a CTA button and a fallback CTA link', function (ctx) {
             const dom = cheerio.load(ctx.email.html)
-            const buttonLink = dom('td a')
-            expect(buttonLink).to.exist
+            const buttonLink = dom('a:contains("Reset password")')
+            expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.opts.setNewPasswordUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback).to.exist
-            const fallbackLink = fallback.html().replace(/&amp;/g, '&')
-            expect(fallbackLink).to.contain(ctx.opts.setNewPasswordUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.opts.setNewPasswordUrl)
           })
         })
 
@@ -475,13 +465,11 @@ describe('EmailBuilder', function () {
         describe('HTML email', function () {
           it('should include a CTA button and a fallback CTA link', function (ctx) {
             const dom = cheerio.load(ctx.email.html)
-            const buttonLink = dom('a:contains("Reconfirm Email")')
+            const buttonLink = dom('a:contains("Reconfirm email")')
             expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback.length).to.equal(1)
-            const fallbackLink = fallback.html()
-            expect(fallbackLink).to.contain(ctx.opts.confirmEmailUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.opts.confirmEmailUrl)
           })
         })
 
@@ -521,10 +509,8 @@ describe('EmailBuilder', function () {
             const buttonLink = dom('a:contains("Join now")')
             expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.opts.acceptInviteUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback.length).to.equal(1)
-            const fallbackLink = fallback.html()
-            expect(fallbackLink).to.contain(ctx.opts.acceptInviteUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.opts.acceptInviteUrl)
           })
         })
 
@@ -559,10 +545,8 @@ describe('EmailBuilder', function () {
             const buttonLink = dom('a:contains("View Subscription Dashboard")')
             expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback.length).to.equal(1)
-            const fallbackLink = fallback.html()
-            expect(fallbackLink).to.contain(ctx.expectedUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.expectedUrl)
           })
         })
 
@@ -593,10 +577,8 @@ describe('EmailBuilder', function () {
             const buttonLink = dom(`a:contains("Open ${ctx.settings.appName}")`)
             expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.settings.siteUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback.length).to.equal(1)
-            const fallbackLink = fallback.html()
-            expect(fallbackLink).to.contain(ctx.settings.siteUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.settings.siteUrl)
           })
         })
 
@@ -630,10 +612,8 @@ describe('EmailBuilder', function () {
             const buttonLink = dom('a:contains("Set password")')
             expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.opts.setNewPasswordUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback.length).to.equal(1)
-            const fallbackLink = fallback.html().replace(/&amp;/, '&')
-            expect(fallbackLink).to.contain(ctx.opts.setNewPasswordUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.opts.setNewPasswordUrl)
           })
         })
 
@@ -676,10 +656,8 @@ describe('EmailBuilder', function () {
             const buttonLink = dom('a:contains("View project")')
             expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.opts.inviteUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback.length).to.equal(1)
-            const fallbackLink = fallback.html().replace(/&amp;/g, '&')
-            expect(fallbackLink).to.contain(ctx.opts.inviteUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.opts.inviteUrl)
           })
         })
 
@@ -711,9 +689,8 @@ describe('EmailBuilder', function () {
             const buttonLink = ctx.dom('a:contains("Confirm email")')
             expect(buttonLink.length).to.equal(1)
             expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl)
-            const fallback = ctx.dom('.force-overleaf-style').last()
-            expect(fallback.length).to.equal(1)
-            expect(fallback.html()).to.contain(ctx.opts.confirmEmailUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.opts.confirmEmailUrl)
           })
           it('should include help links', function (ctx) {
             const helpGuidesLink = ctx.dom('a:contains("Help Guides")')
@@ -761,11 +738,12 @@ describe('EmailBuilder', function () {
           expect(email.html).to.contain(
             'You can still log in to Overleaf using one of our other'
           )
-          const links = dom('a')
-          expect(links[0].attribs.href).to.equal(
+          const loginLink = dom('a:contains("login options")')
+          expect(loginLink.attr('href')).to.equal(
             `${ctx.settings.siteUrl}/login`
           )
-          expect(links[1].attribs.href).to.equal(setNewPasswordUrl)
+          const passwordLink = dom('a:contains("Set your new password")')
+          expect(passwordLink.attr('href')).to.equal(setNewPasswordUrl)
           expect(email.html).to.contain(
             "If you don't have a password, you can set one now."
           )
@@ -813,8 +791,8 @@ describe('EmailBuilder', function () {
           expect(email.html).to.contain(
             'You now need an email address and password to sign in to your Overleaf account.'
           )
-          const links = dom('a')
-          expect(links[0].attribs.href).to.equal(
+          const ctaLink = dom('a:contains("Set your new password")')
+          expect(ctaLink.attr('href')).to.equal(
             `${ctx.settings.siteUrl}/user/password/reset`
           )
 
@@ -1067,13 +1045,11 @@ describe('EmailBuilder', function () {
             expect(ctx.email.html).to.contain(
               'Remove inactive users to free up licenses'
             )
-            const buttonLink = dom('td a')
+            const buttonLink = dom('a:contains("Add licenses")')
             expect(buttonLink).to.exist
             expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl)
-            const fallback = dom('.force-overleaf-style').last()
-            expect(fallback).to.exist
-            const fallbackLink = fallback.html().replace(/&amp;/g, '&')
-            expect(fallbackLink).to.contain(ctx.expectedUrl)
+            expect(ctx.email.html).to.contain('copy and paste this link')
+            expect(ctx.email.html).to.contain(ctx.expectedUrl)
           })
         })
 

+ 21 - 0
services/web/test/unit/src/Email/EmailHandler.test.mjs

@@ -39,6 +39,27 @@ describe('EmailHandler', function () {
       default: ctx.Queues,
     }))
 
+    ctx.SplitTestHandler = {
+      promises: {
+        getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
+      },
+    }
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: ctx.SplitTestHandler,
+      })
+    )
+
+    ctx.UserGetter = {
+      promises: {
+        getUserByAnyEmail: sinon.stub().resolves(null),
+      },
+    }
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
     ctx.EmailHandler = (await import(MODULE_PATH)).default
   })
 

+ 75 - 0
services/web/test/unit/src/Email/UpdatedBaseEmailLayout.test.mjs

@@ -0,0 +1,75 @@
+import { vi, expect } from 'vitest'
+import { load } from 'cheerio'
+import path from 'node:path'
+
+const LAYOUT_PATH = path.join(
+  import.meta.dirname,
+  '../../../../app/src/Features/Email/Layouts/UpdatedBaseEmailLayout'
+)
+
+async function buildLayoutWithEnv(env) {
+  vi.resetModules()
+  vi.doMock('@overleaf/settings', () => ({
+    default: {
+      appName: 'TestApp',
+      siteUrl: 'https://www.example.com',
+      env,
+    },
+  }))
+  const { default: layout } = await import(LAYOUT_PATH)
+  return layout({ body: 'Test body', footerMessage: '' })
+}
+
+describe('UpdatedBaseEmailLayout branding', function () {
+  afterEach(function () {
+    vi.restoreAllMocks()
+    vi.resetModules()
+  })
+
+  describe('on SaaS', function () {
+    it('should show the logo image', async function () {
+      const html = await buildLayoutWithEnv('saas')
+      const dom = load(html)
+      const logoImg = dom('img[alt="TestApp"]')
+      expect(logoImg.length).to.equal(1)
+      expect(logoImg.attr('src')).to.contain('email-logo@2x.png')
+    })
+
+    it('should show the tagline image', async function () {
+      const html = await buildLayoutWithEnv('saas')
+      expect(html).to.contain('email-footer-tagline@2x.png')
+    })
+  })
+
+  describe('on server-ce', function () {
+    it('should show the app name as text instead of logo', async function () {
+      const html = await buildLayoutWithEnv('server-ce')
+      const dom = load(html)
+      const logoImg = dom('img[alt="TestApp"]')
+      expect(logoImg.length).to.equal(0)
+      expect(html).to.contain('TestApp')
+      expect(html).to.not.contain('email-logo@2x.png')
+    })
+
+    it('should not show the tagline image', async function () {
+      const html = await buildLayoutWithEnv('server-ce')
+      expect(html).to.not.contain('email-footer-tagline@2x.png')
+    })
+  })
+
+  describe('on server-pro', function () {
+    it('should show the app name as text instead of logo', async function () {
+      const html = await buildLayoutWithEnv('server-pro')
+      const dom = load(html)
+      const logoImg = dom('img[alt="TestApp"]')
+      expect(logoImg.length).to.equal(0)
+      expect(html).to.contain('TestApp')
+      expect(html).to.not.contain('email-logo@2x.png')
+    })
+
+    it('should not show the tagline image', async function () {
+      const html = await buildLayoutWithEnv('server-pro')
+      expect(html).to.not.contain('email-footer-tagline@2x.png')
+    })
+  })
+})