diff --git a/services/web/app/src/Features/Email/Bodies/UpdatedNoCTAEmailBody.mjs b/services/web/app/src/Features/Email/Bodies/UpdatedNoCTAEmailBody.mjs new file mode 100644 index 0000000000..6a314c2926 --- /dev/null +++ b/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) { %> +

+ <%= title %> +

+<% } %> + +<% if (greeting) { %> +

+ <%= greeting %> +

+<% } %> + +<% (message).forEach(function(paragraph) { %> +

+ <%= paragraph %> +

+<% }) %> + +<% if (highlightedText) { %> +
+ <%= highlightedText %> +
+<% } %>\ +`) diff --git a/services/web/app/src/Features/Email/Bodies/updated-cta-email.mjs b/services/web/app/src/Features/Email/Bodies/updated-cta-email.mjs new file mode 100644 index 0000000000..8987b62251 --- /dev/null +++ b/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) { %> +

+ <%= title %> +

+<% } %> + +<% if (greeting) { %> +

+ <%= greeting %> +

+<% } %> + +<% (message).forEach(function(paragraph) { %> +

+ <%= paragraph %> +

+<% }) %> + + + + + +
+ + <%= ctaText %> + +
+ +<% if (secondaryMessage && secondaryMessage.length > 0) { %> + <% (secondaryMessage).forEach(function(paragraph) { %> +

+ <%= paragraph %> +

+ <% }) %> +<% } %> + +

+ If the button above does not appear, please copy and paste this link into your browser's address bar: +

+

+ <%= ctaURL %> +

+ +<% if (gmailGoToAction) { %> + +<% } %>\ +`) diff --git a/services/web/app/src/Features/Email/EmailBuilder.mjs b/services/web/app/src/Features/Email/EmailBuilder.mjs index 2635ebf52b..21a1314b08 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.mjs +++ b/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` diff --git a/services/web/app/src/Features/Email/EmailHandler.mjs b/services/web/app/src/Features/Email/EmailHandler.mjs index 85a94d5439..0c0b769688 100644 --- a/services/web/app/src/Features/Email/EmailHandler.mjs +++ b/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 diff --git a/services/web/app/src/Features/Email/Layouts/UpdatedBaseEmailLayout.mjs b/services/web/app/src/Features/Email/Layouts/UpdatedBaseEmailLayout.mjs new file mode 100644 index 0000000000..174bd6d8d1 --- /dev/null +++ b/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 = `${settings.appName}` +let _taglineHtml = '' +if (_isSaas && _imageBase) { + _logoHtml = ` + + ${settings.appName} + + ` + + _taglineHtml = ` + + + The home of scientific and technical writing + + ` +} + +const _customFooter = settings.email?.template?.customFooter || '' + +export default _.template(`\ + + + + + + + + + + + + +
+                               +                               +
+ + + + + + + + +\ +`) diff --git a/services/web/app/src/Features/Email/emailStyles.mjs b/services/web/app/src/Features/Email/emailStyles.mjs new file mode 100644 index 0000000000..5ad855e6c2 --- /dev/null +++ b/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', +} diff --git a/services/web/public/img/ol-brand/email-footer-tagline@2x.png b/services/web/public/img/ol-brand/email-footer-tagline@2x.png new file mode 100644 index 0000000000..de4d7a2b1e Binary files /dev/null and b/services/web/public/img/ol-brand/email-footer-tagline@2x.png differ diff --git a/services/web/public/img/ol-brand/email-logo@2x.png b/services/web/public/img/ol-brand/email-logo@2x.png new file mode 100644 index 0000000000..900c7a113b Binary files /dev/null and b/services/web/public/img/ol-brand/email-logo@2x.png differ diff --git a/services/web/test/unit/src/Email/EmailBuilder.test.mjs b/services/web/test/unit/src/Email/EmailBuilder.test.mjs index c12104f120..8b94fcab5d 100644 --- a/services/web/test/unit/src/Email/EmailBuilder.test.mjs +++ b/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(/&/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(/&/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(/&/, '&') - 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(/&/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(/&/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) }) }) diff --git a/services/web/test/unit/src/Email/EmailHandler.test.mjs b/services/web/test/unit/src/Email/EmailHandler.test.mjs index 3d189efb3f..211ff146df 100644 --- a/services/web/test/unit/src/Email/EmailHandler.test.mjs +++ b/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 }) diff --git a/services/web/test/unit/src/Email/UpdatedBaseEmailLayout.test.mjs b/services/web/test/unit/src/Email/UpdatedBaseEmailLayout.test.mjs new file mode 100644 index 0000000000..cf2df55888 --- /dev/null +++ b/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') + }) + }) +})