From 0642922490eaa5cbf85909ba599f91325a4d3d9e Mon Sep 17 00:00:00 2001 From: Shane Kilkelly Date: Wed, 16 Sep 2020 09:53:50 +0100 Subject: [PATCH] Merge pull request #3190 from overleaf/jel-new-cta-email-body New CTA email body GitOrigin-RevId: 6712980ed8b5dbbddfcf17c4263b13d62aa67ac0 --- .../src/Features/Email/Bodies/cta-email.js | 96 ++++++++ .../app/src/Features/Email/EmailBuilder.js | 102 ++++++-- .../test/unit/src/Email/EmailBuilderTests.js | 221 ++++++++++++++---- 3 files changed, 361 insertions(+), 58 deletions(-) create mode 100644 services/web/app/src/Features/Email/Bodies/cta-email.js diff --git a/services/web/app/src/Features/Email/Bodies/cta-email.js b/services/web/app/src/Features/Email/Bodies/cta-email.js new file mode 100644 index 0000000000..d9bbebbf69 --- /dev/null +++ b/services/web/app/src/Features/Email/Bodies/cta-email.js @@ -0,0 +1,96 @@ +const _ = require('underscore') + +module.exports = _.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.js b/services/web/app/src/Features/Email/EmailBuilder.js index 11bad894f8..9a25a6ccd3 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.js +++ b/services/web/app/src/Features/Email/EmailBuilder.js @@ -6,9 +6,83 @@ const EmailMessageHelper = require('./EmailMessageHelper') const StringHelper = require('../Helpers/StringHelper') const BaseWithHeaderEmailLayout = require(`./Layouts/BaseWithHeaderEmailLayout`) const SpamSafe = require('./SpamSafe') +const ctaEmailBody = require('./Bodies/cta-email') const SingleCTAEmailBody = require(`./Bodies/SingleCTAEmailBody`) const NoCTAEmailBody = require(`./Bodies/NoCTAEmailBody`) +function _emailBodyPlainText(content, opts, ctaEmail) { + let emailBody = `${content.greeting(opts, true)}` + emailBody += `\r\n\r\n` + emailBody += `${content.message(opts, true).join('\r\n\r\n')}` + + if (ctaEmail) { + emailBody += `\r\n\r\n` + emailBody += `${content.ctaText(opts, true)}: ${content.ctaURL(opts, true)}` + } + + if ( + content.secondaryMessage(opts, true) && + content.secondaryMessage(opts, true).length > 0 + ) { + emailBody += `\r\n\r\n` + emailBody += `${content.secondaryMessage(opts, true).join('\r\n\r\n')}` + } + + emailBody += `\r\n\r\n` + emailBody += `Regards,\r\nThe ${settings.appName} Team - ${settings.siteUrl}` + + return emailBody +} + +function ctaTemplate(content) { + if ( + !content.ctaURL || + !content.ctaText || + !content.message || + !content.subject + ) { + throw new Error('missing required CTA email content') + } + if (!content.title) { + content.title = () => {} + } + if (!content.greeting) { + content.greeting = () => 'Hi,' + } + if (!content.secondaryMessage) { + content.secondaryMessage = () => [] + } + if (!content.gmailGoToAction) { + content.gmailGoToAction = () => {} + } + return { + subject(opts) { + return content.subject(opts) + }, + layout: BaseWithHeaderEmailLayout, + plainTextTemplate(opts) { + return _emailBodyPlainText(content, opts, true) + }, + compiledTemplate(opts) { + return ctaEmailBody({ + title: content.title(opts), + greeting: content.greeting(opts), + message: content.message(opts), + secondaryMessage: content.secondaryMessage(opts), + ctaText: content.ctaText(opts), + ctaURL: content.ctaURL(opts), + gmailGoToAction: content.gmailGoToAction(opts), + StringHelper + }) + } + } +} + +// +// DEPRECATED +// +// Use ctaTemplate instead of CTAEmailTemplate +// function CTAEmailTemplate(content) { if (content.greeting == null) { content.greeting = () => 'Hi,' @@ -175,7 +249,7 @@ Your subscription was reactivated successfully.\ } }) -templates.passwordResetRequested = CTAEmailTemplate({ +templates.passwordResetRequested = ctaTemplate({ subject() { return `Password Reset - ${settings.appName}` }, @@ -183,14 +257,13 @@ templates.passwordResetRequested = CTAEmailTemplate({ return 'Password Reset' }, message() { - return `We got a request to reset your ${settings.appName} password.` + return [`We got a request to reset your ${settings.appName} password.`] }, secondaryMessage() { - return `\ -If you ignore this message, your password won't be changed. - -If you didn't request a password reset, let us know.\ -` + return [ + "If you ignore this message, your password won't be changed.", + "If you didn't request a password reset, let us know." + ] }, ctaText() { return 'Reset password' @@ -331,7 +404,7 @@ templates.ownershipTransferConfirmationPreviousOwner = NoCTAEmailTemplate({ } }) -templates.ownershipTransferConfirmationNewOwner = CTAEmailTemplate({ +templates.ownershipTransferConfirmationNewOwner = ctaTemplate({ subject(opts) { return `Project ownership transfer - ${settings.appName}` }, @@ -341,17 +414,19 @@ templates.ownershipTransferConfirmationNewOwner = CTAEmailTemplate({ ) return `${projectName} - Owner change` }, - message(opts) { + message(opts, isPlainText) { const nameAndEmail = _.escape( _formatUserNameAndEmail(opts.previousOwner, 'A collaborator') ) const projectName = _.escape( SpamSafe.safeProjectName(opts.project.name, 'a project') ) - return `\ -${nameAndEmail} has made you the owner of **${projectName}**. You can now -manage ${projectName} sharing settings. -` + const projectNameEmphasized = isPlainText + ? projectName + : `${projectName}` + return [ + `${nameAndEmail} has made you the owner of ${projectNameEmphasized}. You can now manage ${projectName} sharing settings.` + ] }, ctaText(opts) { return 'View project' @@ -479,6 +554,7 @@ function _formatUserNameAndEmail(user, placeholder) { module.exports = { templates, + ctaTemplate, CTAEmailTemplate, NoCTAEmailTemplate, buildEmail diff --git a/services/web/test/unit/src/Email/EmailBuilderTests.js b/services/web/test/unit/src/Email/EmailBuilderTests.js index e3c64c4d0a..5ca6206512 100644 --- a/services/web/test/unit/src/Email/EmailBuilderTests.js +++ b/services/web/test/unit/src/Email/EmailBuilderTests.js @@ -1,4 +1,5 @@ const SandboxedModule = require('sandboxed-module') +const cheerio = require('cheerio') const path = require('path') const { expect } = require('chai') const _ = require('underscore') @@ -101,58 +102,188 @@ describe('EmailBuilder', function() { }) }) + describe('ctaTemplate', function() { + describe('missing required content', function() { + const content = { + title: () => {}, + greeting: () => {}, + message: () => {}, + secondaryMessage: () => {}, + ctaText: () => {}, + ctaURL: () => {}, + gmailGoToAction: () => {} + } + it('should throw an error when missing title', function() { + let { title, ...missing } = content + expect(() => { + this.EmailBuilder.ctaTemplate(missing) + }).to.throw(Error) + }) + it('should throw an error when missing message', function() { + let { message, ...missing } = content + expect(() => { + this.EmailBuilder.ctaTemplate(missing) + }).to.throw(Error) + }) + it('should throw an error when missing ctaText', function() { + let { ctaText, ...missing } = content + expect(() => { + this.EmailBuilder.ctaTemplate(missing) + }).to.throw(Error) + }) + it('should throw an error when missing ctaURL', function() { + let { ctaURL, ...missing } = content + expect(() => { + this.EmailBuilder.ctaTemplate(missing) + }).to.throw(Error) + }) + }) + }) + describe('templates', function() { - describe('securityAlert', function() { - before(function() { - this.message = 'more details about the action' - this.messageHTML = `
${ - this.message - }` - this.messageNotAllowedHTML = `
${this.messageHTML}` - - this.actionDescribed = 'an action described' - this.actionDescribedHTML = `
${ - this.actionDescribed - }` - this.actionDescribedNotAllowedHTML = `
${ - this.actionDescribedHTML - }` - - this.opts = { - to: this.email, - actionDescribed: this.actionDescribedNotAllowedHTML, - action: 'an action', - message: [this.messageNotAllowedHTML] - } - this.email = this.EmailBuilder.buildEmail('securityAlert', this.opts) - }) - - it('should build the email', function() { - expect(this.email.html != null).to.equal(true) - expect(this.email.text != null).to.equal(true) - }) - - describe('HTML email', function() { - it('should clean HTML in opts.actionDescribed', function() { - expect(this.email.html).to.not.contain( - this.actionDescribedNotAllowedHTML + describe('CTA', function() { + describe('ownershipTransferConfirmationNewOwner', function() { + before(function() { + this.emailAddress = 'example@overleaf.com' + this.opts = { + to: this.emailAddress, + previousOwner: {}, + project: { + _id: 'abc123', + name: 'example project' + } + } + this.email = this.EmailBuilder.buildEmail( + 'ownershipTransferConfirmationNewOwner', + this.opts ) - expect(this.email.html).to.contain(this.actionDescribedHTML) + this.expectedUrl = `${ + this.settings.siteUrl + }/project/${this.opts.project._id.toString()}` }) - it('should clean HTML in opts.message', function() { - expect(this.email.html).to.not.contain(this.messageNotAllowedHTML) - expect(this.email.html).to.contain(this.messageHTML) + + it('should build the email', function() { + expect(this.email.html).to.exist + expect(this.email.text).to.exist + }) + + describe('HTML email', function() { + it('should include a CTA button and a fallback CTA link', function() { + const dom = cheerio.load(this.email.html) + const buttonLink = dom('td a') + expect(buttonLink).to.exist + expect(buttonLink.attr('href')).to.equal(this.expectedUrl) + const fallback = dom('.avoid-auto-linking').last() + expect(fallback).to.exist + const fallbackLink = fallback.html().replace(/&/g, '&') + expect(fallbackLink).to.contain(this.expectedUrl) + }) + }) + + describe('plain text email', function() { + it('should contain the CTA link', function() { + expect(this.email.text).to.contain(this.expectedUrl) + }) }) }) - describe('plain text email', function() { - it('should remove all HTML in opts.actionDescribed', function() { - expect(this.email.text).to.not.contain(this.actionDescribedHTML) - expect(this.email.text).to.contain(this.actionDescribed) + describe('passwordResetRequested', function() { + before(function() { + this.emailAddress = 'example@overleaf.com' + this.opts = { + to: this.emailAddress, + setNewPasswordUrl: `${ + this.settings.siteUrl + }/user/password/set?passwordResetToken=aToken&email=${encodeURIComponent( + this.emailAddress + )}` + } + this.email = this.EmailBuilder.buildEmail( + 'passwordResetRequested', + this.opts + ) }) - it('should remove all HTML in opts.message', function() { - expect(this.email.text).to.not.contain(this.messageHTML) - expect(this.email.text).to.contain(this.message) + + it('should build the email', function() { + expect(this.email.html).to.exist + expect(this.email.text).to.exist + }) + + describe('HTML email', function() { + it('should include a CTA button and a fallback CTA link', function() { + const dom = cheerio.load(this.email.html) + const buttonLink = dom('td a') + expect(buttonLink).to.exist + expect(buttonLink.attr('href')).to.equal( + this.opts.setNewPasswordUrl + ) + const fallback = dom('.avoid-auto-linking').last() + expect(fallback).to.exist + const fallbackLink = fallback.html().replace(/&/g, '&') + expect(fallbackLink).to.contain(this.opts.setNewPasswordUrl) + }) + }) + + describe('plain text email', function() { + it('should contain the CTA link', function() { + expect(this.email.text).to.contain(this.opts.setNewPasswordUrl) + }) + }) + }) + }) + describe('no CTA', function() { + describe('securityAlert', function() { + before(function() { + this.message = 'more details about the action' + this.messageHTML = `
${ + this.message + }` + this.messageNotAllowedHTML = `
${this.messageHTML}` + + this.actionDescribed = 'an action described' + this.actionDescribedHTML = `
${ + this.actionDescribed + }` + this.actionDescribedNotAllowedHTML = `
${ + this.actionDescribedHTML + }` + + this.opts = { + to: this.email, + actionDescribed: this.actionDescribedNotAllowedHTML, + action: 'an action', + message: [this.messageNotAllowedHTML] + } + this.email = this.EmailBuilder.buildEmail('securityAlert', this.opts) + }) + + it('should build the email', function() { + expect(this.email.html != null).to.equal(true) + expect(this.email.text != null).to.equal(true) + }) + + describe('HTML email', function() { + it('should clean HTML in opts.actionDescribed', function() { + expect(this.email.html).to.not.contain( + this.actionDescribedNotAllowedHTML + ) + expect(this.email.html).to.contain(this.actionDescribedHTML) + }) + it('should clean HTML in opts.message', function() { + expect(this.email.html).to.not.contain(this.messageNotAllowedHTML) + expect(this.email.html).to.contain(this.messageHTML) + }) + }) + + describe('plain text email', function() { + it('should remove all HTML in opts.actionDescribed', function() { + expect(this.email.text).to.not.contain(this.actionDescribedHTML) + expect(this.email.text).to.contain(this.actionDescribed) + }) + it('should remove all HTML in opts.message', function() { + expect(this.email.text).to.not.contain(this.messageHTML) + expect(this.email.text).to.contain(this.message) + }) }) }) })