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 %>
+
+ <% }) %>
+
+
+
+
+
+ <% 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)
+ })
})
})
})