Merge pull request #3190 from overleaf/jel-new-cta-email-body

New CTA email body

GitOrigin-RevId: 6712980ed8b5dbbddfcf17c4263b13d62aa67ac0
This commit is contained in:
Shane Kilkelly 2020-09-16 09:53:50 +01:00 committed by Copybot
parent 95352894a5
commit 0642922490
3 changed files with 361 additions and 58 deletions

View file

@ -0,0 +1,96 @@
const _ = require('underscore')
module.exports = _.template(`\
<table class="row" style="border-collapse: collapse; border-spacing: 0; display: table; padding: 0; position: relative; text-align: left; vertical-align: top; width: 100%;">
<tbody>
<tr style="padding: 0; vertical-align: top;">
<th class="small-12 columns" style="line-height: 1.3; margin: 0 auto; padding: 0; padding-bottom: 16px; padding-left: 16px; padding-right: 16px; text-align: left;">
<table style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3;">
<tr style="padding: 0; text-align: left; vertical-align: top;">
<th style="margin: 0; padding: 0; text-align: left;">
<% if (title) { %>
<h3 class="avoid-auto-linking" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
<%= title %>
</h3>
<% } %>
</th>
<tr>
<td>
<p style="height: 20px; margin: 0; padding: 0;">&#xA0;</p>
<% if (greeting) { %>
<p style="margin: 0 0 10px 0; padding: 0;">
<%= greeting %>
</p>
<% } %>
<% (message).forEach(function(paragraph) { %>
<p class="avoid-auto-linking" style="margin: 0 0 10px 0; padding: 0;">
<%= paragraph %>
</p>
<% }) %>
<p style="margin: 0; padding: 0;">&#xA0;</p>
<table style="border-collapse: collapse; border-spacing: 0; float: none; margin: 0 auto; padding: 0; text-align: center; vertical-align: top; width: auto;">
<tr style="padding: 0; text-align: left; vertical-align: top;">
<td style="-moz-hyphens: auto; -webkit-hyphens: auto; border-collapse: collapse !important; border-radius: 9999px; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">
<table style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;">
<tr style="padding: 0; text-align: left; vertical-align: top;">
<td style="-moz-hyphens: auto; -webkit-hyphens: auto; background: #4F9C45; border: none; border-collapse: collapse !important; border-radius: 9999px; color: #fefefe; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">
<a href="<%= ctaURL %>" style="border: 0 solid #4F9C45; border-radius: 9999px; color: #fefefe; display: inline-block; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: bold; line-height: 1.3; margin: 0; padding: 8px 16px 8px 16px; text-align: left; text-decoration: none;">
<%= ctaText %>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<% if (secondaryMessage && secondaryMessage.length > 0) { %>
<p style="margin: 0; padding: 0;">&#xA0;</p>
<% (secondaryMessage).forEach(function(paragraph) { %>
<p class="avoid-auto-linking">
<%= paragraph %>
</p>
<% }) %>
<% } %>
<p style="margin: 0; padding: 0;">&#xA0;</p>
<p class="avoid-auto-linking" style="font-size: 12px;">
If the button above does not appear, please copy and paste this link into your browser's address bar:
</p>
<p class="avoid-auto-linking" style="font-size: 12px;">
<%= ctaURL %>
</p>
</td>
</tr>
</tr>
</table>
</th>
</tr>
</tbody>
</table>
<% 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>
<% } %>
\
`)

View file

@ -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
: `<b>${projectName}</b>`
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

View file

@ -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 = `<br /><span style="text-align:center" class="a-class"><b><i>${
this.message
}</i></b></span>`
this.messageNotAllowedHTML = `<div></div>${this.messageHTML}`
this.actionDescribed = 'an action described'
this.actionDescribedHTML = `<br /><span style="text-align:center" class="a-class"><b><i>${
this.actionDescribed
}</i></b>`
this.actionDescribedNotAllowedHTML = `<div></div>${
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(/&amp;/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(/&amp;/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 = `<br /><span style="text-align:center" class="a-class"><b><i>${
this.message
}</i></b></span>`
this.messageNotAllowedHTML = `<div></div>${this.messageHTML}`
this.actionDescribed = 'an action described'
this.actionDescribedHTML = `<br /><span style="text-align:center" class="a-class"><b><i>${
this.actionDescribed
}</i></b>`
this.actionDescribedNotAllowedHTML = `<div></div>${
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)
})
})
})
})