mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #3190 from overleaf/jel-new-cta-email-body
New CTA email body GitOrigin-RevId: 6712980ed8b5dbbddfcf17c4263b13d62aa67ac0
This commit is contained in:
parent
95352894a5
commit
0642922490
3 changed files with 361 additions and 58 deletions
96
services/web/app/src/Features/Email/Bodies/cta-email.js
Normal file
96
services/web/app/src/Features/Email/Bodies/cta-email.js
Normal 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;"> </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;"> </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;"> </p>
|
||||
|
||||
<% (secondaryMessage).forEach(function(paragraph) { %>
|
||||
<p class="avoid-auto-linking">
|
||||
<%= paragraph %>
|
||||
</p>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
|
||||
<p style="margin: 0; padding: 0;"> </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>
|
||||
<% } %>
|
||||
\
|
||||
`)
|
|
@ -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
|
||||
|
|
|
@ -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(/&/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 = `<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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue