first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,823 @@
const SandboxedModule = require('sandboxed-module')
const cheerio = require('cheerio')
const path = require('path')
const { expect } = require('chai')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Email/EmailBuilder'
)
const EmailMessageHelper = require('../../../../app/src/Features/Email/EmailMessageHelper')
const ctaEmailBody = require('../../../../app/src/Features/Email/Bodies/cta-email')
const NoCTAEmailBody = require('../../../../app/src/Features/Email/Bodies/NoCTAEmailBody')
const BaseWithHeaderEmailLayout = require('../../../../app/src/Features/Email/Layouts/BaseWithHeaderEmailLayout')
describe('EmailBuilder', function () {
before(function () {
this.settings = {
appName: 'testApp',
siteUrl: 'https://www.overleaf.com',
}
this.EmailBuilder = SandboxedModule.require(MODULE_PATH, {
requires: {
'./EmailMessageHelper': EmailMessageHelper,
'./Bodies/cta-email': ctaEmailBody,
'./Bodies/NoCTAEmailBody': NoCTAEmailBody,
'./Layouts/BaseWithHeaderEmailLayout': BaseWithHeaderEmailLayout,
'@overleaf/settings': this.settings,
},
})
})
describe('projectInvite', function () {
beforeEach(function () {
this.opts = {
to: 'bob@bob.com',
first_name: 'bob',
owner: {
email: 'sally@hally.com',
},
inviteUrl: 'http://example.com/invite',
project: {
url: 'http://www.project.com',
name: 'standard project',
},
}
})
describe('when sending a normal email', function () {
beforeEach(function () {
this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
})
it('should have html and text properties', function () {
expect(this.email.html != null).to.equal(true)
expect(this.email.text != null).to.equal(true)
})
it('should not have undefined in it', function () {
this.email.html.indexOf('undefined').should.equal(-1)
this.email.subject.indexOf('undefined').should.equal(-1)
})
})
describe('when someone is up to no good', function () {
it('should not contain the project name at all if unsafe', function () {
this.opts.project.name = "<img src='http://evilsite.com/evil.php'>"
this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
expect(this.email.html).to.not.contain('evilsite.com')
expect(this.email.subject).to.not.contain('evilsite.com')
// but email should appear
expect(this.email.html).to.contain(this.opts.owner.email)
expect(this.email.subject).to.contain(this.opts.owner.email)
})
it('should not contain the inviter email at all if unsafe', function () {
this.opts.owner.email =
'verylongemailaddressthatwillfailthecheck@longdomain.domain'
this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
expect(this.email.html).to.not.contain(this.opts.owner.email)
expect(this.email.subject).to.not.contain(this.opts.owner.email)
// but title should appear
expect(this.email.html).to.contain(this.opts.project.name)
expect(this.email.subject).to.contain(this.opts.project.name)
})
it('should handle both email and title being unsafe', function () {
this.opts.project.name = "<img src='http://evilsite.com/evil.php'>"
this.opts.owner.email =
'verylongemailaddressthatwillfailthecheck@longdomain.domain'
this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
expect(this.email.html).to.not.contain('evilsite.com')
expect(this.email.subject).to.not.contain('evilsite.com')
expect(this.email.html).to.not.contain(this.opts.owner.email)
expect(this.email.subject).to.not.contain(this.opts.owner.email)
expect(this.email.html).to.contain(
'Please view the project to find out more'
)
})
})
})
describe('SpamSafe', function () {
beforeEach(function () {
this.opts = {
to: 'bob@joe.com',
first_name: 'bob',
newOwner: {
email: 'sally@hally.com',
},
inviteUrl: 'http://example.com/invite',
project: {
url: 'http://www.project.com',
name: 'come buy my product at http://notascam.com',
},
}
this.email = this.EmailBuilder.buildEmail(
'ownershipTransferConfirmationPreviousOwner',
this.opts
)
})
it('should replace spammy project name', function () {
this.email.html.indexOf('your project').should.not.equal(-1)
})
})
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 () {
const { title, ...missing } = content
expect(() => {
this.EmailBuilder.ctaTemplate(missing)
}).to.throw(Error)
})
it('should throw an error when missing message', function () {
const { message, ...missing } = content
expect(() => {
this.EmailBuilder.ctaTemplate(missing)
}).to.throw(Error)
})
it('should throw an error when missing ctaText', function () {
const { ctaText, ...missing } = content
expect(() => {
this.EmailBuilder.ctaTemplate(missing)
}).to.throw(Error)
})
it('should throw an error when missing ctaURL', function () {
const { ctaURL, ...missing } = content
expect(() => {
this.EmailBuilder.ctaTemplate(missing)
}).to.throw(Error)
})
})
})
describe('templates', function () {
describe('CTA', function () {
describe('canceledSubscription', function () {
beforeEach(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
}
this.email = this.EmailBuilder.buildEmail(
'canceledSubscription',
this.opts
)
this.expectedUrl =
'https://docs.google.com/forms/d/e/1FAIpQLSfa7z_s-cucRRXm70N4jEcSbFsZeb0yuKThHGQL8ySEaQzF0Q/viewform?usp=sf_link'
})
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('a:contains("Leave Feedback")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.expectedUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
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('confirmEmail', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.userId = 'abc123'
this.opts = {
to: this.emailAddress,
confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=aToken123`,
sendingUser_id: this.userId,
}
this.email = this.EmailBuilder.buildEmail('confirmEmail', this.opts)
})
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('a:contains("Confirm Email")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
expect(fallbackLink).to.contain(this.opts.confirmEmailUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.opts.confirmEmailUrl)
})
})
})
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
)
this.expectedUrl = `${
this.settings.siteUrl
}/project/${this.opts.project._id.toString()}`
})
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('.force-overleaf-style').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('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 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('.force-overleaf-style').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('reconfirmEmail', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.userId = 'abc123'
this.opts = {
to: this.emailAddress,
confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=aToken123`,
sendingUser_id: this.userId,
}
this.email = this.EmailBuilder.buildEmail('reconfirmEmail', this.opts)
})
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('a:contains("Reconfirm Email")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
expect(fallbackLink).to.contain(this.opts.confirmEmailUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.opts.confirmEmailUrl)
})
})
})
describe('verifyEmailToJoinTeam', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
acceptInviteUrl: `${this.settings.siteUrl}/subscription/invites/aToken123/`,
inviter: {
email: 'deanna@overleaf.com',
first_name: 'Deanna',
last_name: 'Troi',
},
}
this.email = this.EmailBuilder.buildEmail(
'verifyEmailToJoinTeam',
this.opts
)
})
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('a:contains("Join now")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.opts.acceptInviteUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
expect(fallbackLink).to.contain(this.opts.acceptInviteUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.opts.acceptInviteUrl)
})
})
})
describe('reactivatedSubscription', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
}
this.email = this.EmailBuilder.buildEmail(
'reactivatedSubscription',
this.opts
)
this.expectedUrl = `${this.settings.siteUrl}/user/subscription`
})
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('a:contains("View Subscription Dashboard")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.expectedUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
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('testEmail', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
}
this.email = this.EmailBuilder.buildEmail('testEmail', this.opts)
})
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(
`a:contains("Open ${this.settings.appName}")`
)
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.settings.siteUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html()
expect(fallbackLink).to.contain(this.settings.siteUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(
`Open ${this.settings.appName}: ${this.settings.siteUrl}`
)
})
})
})
describe('registered', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
setNewPasswordUrl: `${this.settings.siteUrl}/user/activate?token=aToken123&user_id=aUserId123`,
}
this.email = this.EmailBuilder.buildEmail('registered', this.opts)
})
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('a:contains("Set password")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(
this.opts.setNewPasswordUrl
)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html().replace(/&amp;/, '&')
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('projectInvite', function () {
before(function () {
this.emailAddress = 'example@overleaf.com'
this.owner = {
email: 'owner@example.com',
name: 'Bailey',
}
this.projectName = 'Top Secret'
this.opts = {
inviteUrl: `${this.settings.siteUrl}/project/projectId123/invite/token/aToken123`,
owner: {
email: this.owner.email,
},
project: {
name: this.projectName,
},
to: this.emailAddress,
}
this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
})
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('a:contains("View project")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.opts.inviteUrl)
const fallback = dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
const fallbackLink = fallback.html().replace(/&amp;/g, '&')
expect(fallbackLink).to.contain(this.opts.inviteUrl)
})
})
describe('plain text email', function () {
it('should contain the CTA link', function () {
expect(this.email.text).to.contain(this.opts.inviteUrl)
})
})
})
describe('welcome', function () {
beforeEach(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=token123`,
}
this.email = this.EmailBuilder.buildEmail('welcome', this.opts)
this.dom = cheerio.load(this.email.html)
})
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 buttonLink = this.dom('a:contains("Confirm Email")')
expect(buttonLink.length).to.equal(1)
expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl)
const fallback = this.dom('.force-overleaf-style').last()
expect(fallback.length).to.equal(1)
expect(fallback.html()).to.contain(this.opts.confirmEmailUrl)
})
it('should include help links', function () {
const helpGuidesLink = this.dom('a:contains("Help Guides")')
const templatesLink = this.dom('a:contains("Templates")')
const logInLink = this.dom('a:contains("log in")')
expect(helpGuidesLink.length).to.equal(1)
expect(templatesLink.length).to.equal(1)
expect(logInLink.length).to.equal(1)
})
})
describe('plain text email', function () {
it('should contain the CTA URL', function () {
expect(this.email.text).to.contain(this.opts.confirmEmailUrl)
})
it('should include help URL', function () {
expect(this.email.text).to.contain('/learn')
expect(this.email.text).to.contain('/login')
expect(this.email.text).to.contain('/templates')
})
it('should contain HTML links', function () {
expect(this.email.text).to.not.contain('<a')
})
})
})
describe('groupSSODisabled', function () {
it('should build the email for non managed and linked users', function () {
const setNewPasswordUrl = `${this.settings.siteUrl}/user/password/reset`
const emailAddress = 'example@overleaf.com'
const opts = {
to: emailAddress,
setNewPasswordUrl,
userIsManaged: false,
}
const email = this.EmailBuilder.buildEmail('groupSSODisabled', opts)
expect(email.subject).to.equal(
'A change to your Overleaf login options'
)
const dom = cheerio.load(email.html)
expect(email.html).to.exist
expect(email.html).to.contain(
'Your group administrator has disabled single sign-on for your group.'
)
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(
`${this.settings.siteUrl}/login`
)
expect(links[1].attribs.href).to.equal(setNewPasswordUrl)
expect(email.html).to.contain(
"If you don't have a password, you can set one now."
)
expect(email.text).to.exist
const expectedPlainText = [
'Hi,',
'',
'Your group administrator has disabled single sign-on for your group.',
'',
'',
'',
'What does this mean for you?',
'',
'You can still log in to Overleaf using one of our other login options or with your email address and password.',
'',
"If you don't have a password, you can set one now.",
'',
`Set your new password: ${setNewPasswordUrl}`,
'',
'',
'',
'Regards,',
`The ${this.settings.appName} Team - ${this.settings.siteUrl}`,
]
expect(email.text.split(/\r?\n/)).to.deep.equal(expectedPlainText)
})
it('should build the email for managed and linked users', function () {
const emailAddress = 'example@overleaf.com'
const setNewPasswordUrl = `${this.settings.siteUrl}/user/password/reset`
const opts = {
to: emailAddress,
setNewPasswordUrl,
userIsManaged: true,
}
const email = this.EmailBuilder.buildEmail('groupSSODisabled', opts)
expect(email.subject).to.equal(
'Action required: Set your Overleaf password'
)
const dom = cheerio.load(email.html)
expect(email.html).to.exist
expect(email.html).to.contain(
'Your group administrator has disabled single sign-on for your group.'
)
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(
`${this.settings.siteUrl}/user/password/reset`
)
expect(email.text).to.exist
const expectedPlainText = [
'Hi,',
'',
'Your group administrator has disabled single sign-on for your group.',
'',
'',
'',
'What does this mean for you?',
'',
'You now need an email address and password to sign in to your Overleaf account.',
'',
`Set your new password: ${setNewPasswordUrl}`,
'',
'',
'',
'Regards,',
`The ${this.settings.appName} Team - ${this.settings.siteUrl}`,
]
expect(email.text.split(/\r?\n/)).to.deep.equal(expectedPlainText)
})
})
})
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)
})
})
})
describe('welcomeWithoutCTA', function () {
beforeEach(function () {
this.emailAddress = 'example@overleaf.com'
this.opts = {
to: this.emailAddress,
}
this.email = this.EmailBuilder.buildEmail(
'welcomeWithoutCTA',
this.opts
)
this.dom = cheerio.load(this.email.html)
})
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 help links', function () {
const helpGuidesLink = this.dom('a:contains("Help Guides")')
const templatesLink = this.dom('a:contains("Templates")')
const logInLink = this.dom('a:contains("log in")')
expect(helpGuidesLink.length).to.equal(1)
expect(templatesLink.length).to.equal(1)
expect(logInLink.length).to.equal(1)
})
})
describe('plain text email', function () {
it('should include help URL', function () {
expect(this.email.text).to.contain('/learn')
expect(this.email.text).to.contain('/login')
expect(this.email.text).to.contain('/templates')
})
it('should contain HTML links', function () {
expect(this.email.text).to.not.contain('<a')
})
})
})
})
})
})

View File

@@ -0,0 +1,122 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const { expect } = require('chai')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Email/EmailHandler'
)
describe('EmailHandler', function () {
beforeEach(function () {
this.html = '<html>hello</html>'
this.Settings = { email: {} }
this.EmailBuilder = {
buildEmail: sinon.stub().returns({ html: this.html }),
}
this.EmailSender = {
promises: {
sendEmail: sinon.stub().resolves(),
},
}
this.Queues = {
createScheduledJob: sinon.stub().resolves(),
}
this.EmailHandler = SandboxedModule.require(MODULE_PATH, {
requires: {
'./EmailBuilder': this.EmailBuilder,
'./EmailSender': this.EmailSender,
'@overleaf/settings': this.Settings,
'../../infrastructure/Queues': this.Queues,
},
})
})
describe('send email', function () {
it('should use the correct options', async function () {
const opts = { to: 'bob@bob.com' }
await this.EmailHandler.promises.sendEmail('welcome', opts)
expect(this.EmailSender.promises.sendEmail).to.have.been.calledWithMatch({
html: this.html,
})
})
it('should return the error', async function () {
this.EmailSender.promises.sendEmail.rejects(new Error('boom'))
const opts = {
to: 'bob@bob.com',
subject: 'hello bob',
}
await expect(this.EmailHandler.promises.sendEmail('welcome', opts)).to.be
.rejected
})
it('should not send an email if lifecycle is not enabled', async function () {
this.Settings.email.lifecycle = false
this.EmailBuilder.buildEmail.returns({ type: 'lifecycle' })
await this.EmailHandler.promises.sendEmail('welcome', {})
expect(this.EmailSender.promises.sendEmail).not.to.have.been.called
})
it('should send an email if lifecycle is not enabled but the type is notification', async function () {
this.Settings.email.lifecycle = false
this.EmailBuilder.buildEmail.returns({ type: 'notification' })
const opts = { to: 'bob@bob.com' }
await this.EmailHandler.promises.sendEmail('welcome', opts)
expect(this.EmailSender.promises.sendEmail).to.have.been.called
})
it('should send lifecycle email if it is enabled', async function () {
this.Settings.email.lifecycle = true
this.EmailBuilder.buildEmail.returns({ type: 'lifecycle' })
const opts = { to: 'bob@bob.com' }
await this.EmailHandler.promises.sendEmail('welcome', opts)
expect(this.EmailSender.promises.sendEmail).to.have.been.called
})
describe('with plain-text email content', function () {
beforeEach(function () {
this.text = 'hello there'
})
it('should pass along the text field', async function () {
this.EmailBuilder.buildEmail.returns({
html: this.html,
text: this.text,
})
const opts = { to: 'bob@bob.com' }
await this.EmailHandler.promises.sendEmail('welcome', opts)
expect(
this.EmailSender.promises.sendEmail
).to.have.been.calledWithMatch({
html: this.html,
text: this.text,
})
})
})
})
describe('send deferred email', function () {
beforeEach(function () {
this.opts = {
to: 'bob@bob.com',
first_name: 'hello bob',
}
this.emailType = 'canceledSubscription'
this.ONE_HOUR_IN_MS = 1000 * 60 * 60
this.EmailHandler.sendDeferredEmail(
this.emailType,
this.opts,
this.ONE_HOUR_IN_MS
)
})
it('should add a email job to the queue', function () {
expect(this.Queues.createScheduledJob).to.have.been.calledWith(
'deferred-emails',
{ data: { emailType: this.emailType, opts: this.opts } },
this.ONE_HOUR_IN_MS
)
})
})
})

View File

@@ -0,0 +1,35 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const { expect } = require('chai')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Email/EmailMessageHelper'
)
describe('EmailMessageHelper', function () {
beforeEach(function () {
this.EmailMessageHelper = SandboxedModule.require(MODULE_PATH, {})
})
describe('cleanHTML', function () {
beforeEach(function () {
this.text = 'a message'
this.span = `<span style="text-align:center">${this.text}</span>`
this.fullMessage = `${this.span}<div></div>`
})
it('should remove HTML for plainText version', function () {
const processed = this.EmailMessageHelper.cleanHTML(
this.fullMessage,
true
)
expect(processed).to.equal(this.text)
})
it('should keep HTML for HTML version but remove tags not allowed', function () {
const processed = this.EmailMessageHelper.cleanHTML(
this.fullMessage,
false
)
expect(processed).to.equal(this.span)
})
})
})

View File

@@ -0,0 +1,131 @@
const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const { expect } = require('chai')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Email/EmailSender.js'
)
describe('EmailSender', function () {
beforeEach(function () {
this.rateLimiter = {
consume: sinon.stub().resolves(),
}
this.RateLimiter = {
RateLimiter: sinon.stub().returns(this.rateLimiter),
}
this.Settings = {
email: {
transport: 'ses',
parameters: {
AWSAccessKeyID: 'key',
AWSSecretKey: 'secret',
},
fromAddress: 'bob@bob.com',
replyToAddress: 'sally@gmail.com',
},
}
this.sesClient = { sendMail: sinon.stub().resolves() }
this.ses = { createTransport: () => this.sesClient }
this.EmailSender = SandboxedModule.require(MODULE_PATH, {
requires: {
nodemailer: this.ses,
'nodemailer-ses-transport': sinon.stub(),
'@overleaf/settings': this.Settings,
'../../infrastructure/RateLimiter': this.RateLimiter,
'@overleaf/metrics': {
inc() {},
},
},
})
this.opts = {
to: 'bob@bob.com',
subject: 'new email',
html: '<hello></hello>',
}
})
describe('sendEmail', function () {
it('should set the properties on the email to send', async function () {
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
html: this.opts.html,
to: this.opts.to,
subject: this.opts.subject,
})
})
it('should return a non-specific error', async function () {
this.sesClient.sendMail.rejects(new Error('boom'))
await expect(this.EmailSender.promises.sendEmail({})).to.be.rejectedWith(
'error sending message'
)
})
it('should use the from address from settings', async function () {
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
from: this.Settings.email.fromAddress,
})
})
it('should use the reply to address from settings', async function () {
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
replyTo: this.Settings.email.replyToAddress,
})
})
it('should use the reply to address in options as an override', async function () {
this.opts.replyTo = 'someone@else.com'
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
replyTo: this.opts.replyTo,
})
})
it('should not send an email when the rate limiter says no', async function () {
this.opts.sendingUser_id = '12321312321'
this.rateLimiter.consume.rejects({ remainingPoints: 0 })
await expect(this.EmailSender.promises.sendEmail(this.opts)).to.be
.rejected
expect(this.sesClient.sendMail).not.to.have.been.called
})
it('should send the email when the rate limtier says continue', async function () {
this.opts.sendingUser_id = '12321312321'
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.called
})
it('should not check the rate limiter when there is no sendingUser_id', async function () {
this.EmailSender.sendEmail(this.opts, () => {
expect(this.sesClient.sendMail).to.have.been.called
expect(this.rateLimiter.consume).not.to.have.been.called
})
})
describe('with plain-text email content', function () {
beforeEach(function () {
this.opts.text = 'hello there'
})
it('should set the text property on the email to send', async function () {
await this.EmailSender.promises.sendEmail(this.opts)
expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
html: this.opts.html,
text: this.opts.text,
to: this.opts.to,
subject: this.opts.subject,
})
})
})
})
})

View File

@@ -0,0 +1,76 @@
const path = require('path')
const modulePath = path.join(
__dirname,
'../../../../app/src/Features/Email/SpamSafe'
)
const SpamSafe = require(modulePath)
const { expect } = require('chai')
describe('SpamSafe', function () {
it('should reject spammy names', function () {
expect(SpamSafe.isSafeUserName('Charline Wałęsa')).to.equal(true)
expect(
SpamSafe.isSafeUserName(
"hey come buy this product im selling it's really good for you and it'll make your latex 10x guaranteed"
)
).to.equal(false)
expect(SpamSafe.isSafeUserName('隆太郎 宮本')).to.equal(true)
expect(SpamSafe.isSafeUserName('Visit haxx0red.com')).to.equal(false)
expect(
SpamSafe.isSafeUserName(
'加美汝VXhihi661金沙2001005com the first deposit will be _100%_'
)
).to.equal(false)
expect(
SpamSafe.isSafeProjectName(
'Neural Networks: good for your health and will solve all your problems'
)
).to.equal(false)
expect(
SpamSafe.isSafeProjectName(
'An analysis of the questions of the universe!'
)
).to.equal(true)
expect(SpamSafe.isSafeProjectName("A'p'o's't'r'o'p'h'e gallore")).to.equal(
true
)
expect(
SpamSafe.isSafeProjectName(
'come buy this => http://www.dopeproduct.com/search/?q=user123'
)
).to.equal(false)
expect(
SpamSafe.isSafeEmail('realistic-email+1@domain.sub-hyphen.com')
).to.equal(true)
expect(SpamSafe.isSafeEmail('notquiteRight@evil$.com')).to.equal(false)
expect(SpamSafe.safeUserName('Tammy Weinstįen', 'A User')).to.equal(
'Tammy Weinstįen'
)
expect(SpamSafe.safeUserName('haxx0red.com', 'A User')).to.equal('A User')
expect(SpamSafe.safeUserName('What$ Upp', 'A User')).to.equal('A User')
expect(SpamSafe.safeProjectName('Math-ematics!', 'A Project')).to.equal(
'Math-ematics!'
)
expect(
SpamSafe.safeProjectName(
`A Very long title for a very long book that will never be read${'a'.repeat(
250
)}`,
'A Project'
)
).to.equal('A Project')
expect(
SpamSafe.safeEmail('safe-ëmail@domain.com', 'A collaborator')
).to.equal('safe-ëmail@domain.com')
expect(
SpamSafe.safeEmail('Բարեւ@another.domain', 'A collaborator')
).to.equal('Բարեւ@another.domain')
expect(
SpamSafe.safeEmail(`me+${'a'.repeat(40)}@googoole.con`, 'A collaborator')
).to.equal('A collaborator')
expect(
SpamSafe.safeEmail('sendME$$$@iAmAprince.com', 'A collaborator')
).to.equal('A collaborator')
})
})