EmailBuilder.test.mjs 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108
  1. import { vi, expect } from 'vitest'
  2. import cheerio from 'cheerio'
  3. import path from 'node:path'
  4. import EmailMessageHelper from '../../../../app/src/Features/Email/EmailMessageHelper.mjs'
  5. import ctaEmailBody from '../../../../app/src/Features/Email/Bodies/cta-email.mjs'
  6. import NoCTAEmailBody from '../../../../app/src/Features/Email/Bodies/NoCTAEmailBody.mjs'
  7. import BaseEmailLayout from '../../../../app/src/Features/Email/Layouts/BaseEmailLayout.mjs'
  8. const MODULE_PATH = path.join(
  9. import.meta.dirname,
  10. '../../../../app/src/Features/Email/EmailBuilder'
  11. )
  12. describe('EmailBuilder', function () {
  13. beforeEach(async function (ctx) {
  14. ctx.settings = {
  15. appName: 'testApp',
  16. siteUrl: 'https://www.overleaf.com',
  17. }
  18. vi.doMock('../../../../app/src/Features/Email/EmailMessageHelper', () => ({
  19. default: EmailMessageHelper,
  20. }))
  21. vi.doMock('../../../../app/src/Features/Email/Bodies/cta-email', () => ({
  22. default: ctaEmailBody,
  23. }))
  24. vi.doMock(
  25. '../../../../app/src/Features/Email/Bodies/NoCTAEmailBody',
  26. () => ({
  27. default: NoCTAEmailBody,
  28. })
  29. )
  30. vi.doMock(
  31. '../../../../app/src/Features/Email/Layouts/BaseEmailLayout',
  32. () => ({
  33. default: BaseEmailLayout,
  34. })
  35. )
  36. vi.doMock('@overleaf/settings', () => ({
  37. default: ctx.settings,
  38. }))
  39. ctx.EmailBuilder = (await import(MODULE_PATH)).default
  40. })
  41. describe('projectInvite', function () {
  42. beforeEach(function (ctx) {
  43. ctx.opts = {
  44. to: 'bob@bob.com',
  45. first_name: 'bob',
  46. owner: {
  47. email: 'sally@hally.com',
  48. },
  49. inviteUrl: 'http://example.com/invite',
  50. project: {
  51. url: 'http://www.project.com',
  52. name: 'standard project',
  53. },
  54. }
  55. })
  56. describe('when sending a normal email', function () {
  57. beforeEach(function (ctx) {
  58. ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts)
  59. })
  60. it('should have html and text properties', function (ctx) {
  61. expect(ctx.email.html != null).to.equal(true)
  62. expect(ctx.email.text != null).to.equal(true)
  63. })
  64. it('should not have undefined in it', function (ctx) {
  65. ctx.email.html.indexOf('undefined').should.equal(-1)
  66. ctx.email.subject.indexOf('undefined').should.equal(-1)
  67. })
  68. })
  69. describe('when dealing with escaping', function () {
  70. it("should not show possessive 's as '", function (ctx) {
  71. ctx.opts.project.name = "Aktöbe's project"
  72. ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts)
  73. expect(ctx.email.subject).to.not.contain(''')
  74. expect(ctx.email.subject).to.contain(ctx.opts.project.name)
  75. })
  76. it('should not show an ampersand as &', function (ctx) {
  77. ctx.opts.project.name = 'Aktöbe & Almaty project'
  78. ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts)
  79. expect(ctx.email.subject).to.not.contain('&')
  80. expect(ctx.email.subject).to.contain(ctx.opts.project.name)
  81. })
  82. it('should prevent dangerous characters as project names', function (ctx) {
  83. const characters = ['""', '<>', '//']
  84. for (const pair of characters) {
  85. ctx.opts.project.name = `${pair} project`
  86. ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts)
  87. expect(ctx.email.subject).to.not.contain(pair)
  88. }
  89. })
  90. })
  91. describe('when someone is up to no good', function () {
  92. it('should not contain the project name at all if unsafe', function (ctx) {
  93. ctx.opts.project.name = "<img src='http://evilsite.com/evil.php'>"
  94. ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts)
  95. expect(ctx.email.html).to.not.contain('evilsite.com')
  96. expect(ctx.email.subject).to.not.contain('evilsite.com')
  97. // but email should appear
  98. expect(ctx.email.html).to.contain(ctx.opts.owner.email)
  99. expect(ctx.email.subject).to.contain(ctx.opts.owner.email)
  100. })
  101. it('should not contain the inviter email at all if unsafe', function (ctx) {
  102. ctx.opts.owner.email =
  103. 'verylongemailaddressthatwillfailthecheck@longdomain.domain'
  104. ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts)
  105. expect(ctx.email.html).to.not.contain(ctx.opts.owner.email)
  106. expect(ctx.email.subject).to.not.contain(ctx.opts.owner.email)
  107. // but title should appear
  108. expect(ctx.email.html).to.contain(ctx.opts.project.name)
  109. expect(ctx.email.subject).to.contain(ctx.opts.project.name)
  110. })
  111. it('should handle both email and title being unsafe', function (ctx) {
  112. ctx.opts.project.name = "<img src='http://evilsite.com/evil.php'>"
  113. ctx.opts.owner.email =
  114. 'verylongemailaddressthatwillfailthecheck@longdomain.domain'
  115. ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts)
  116. expect(ctx.email.html).to.not.contain('evilsite.com')
  117. expect(ctx.email.subject).to.not.contain('evilsite.com')
  118. expect(ctx.email.html).to.not.contain(ctx.opts.owner.email)
  119. expect(ctx.email.subject).to.not.contain(ctx.opts.owner.email)
  120. expect(ctx.email.html).to.contain(
  121. 'Please view the project to find out more'
  122. )
  123. })
  124. })
  125. })
  126. describe('SpamSafe', function () {
  127. beforeEach(function (ctx) {
  128. ctx.opts = {
  129. to: 'bob@joe.com',
  130. first_name: 'bob',
  131. newOwner: {
  132. email: 'sally@hally.com',
  133. },
  134. inviteUrl: 'http://example.com/invite',
  135. project: {
  136. url: 'http://www.project.com',
  137. name: 'come buy my product at http://notascam.com',
  138. },
  139. }
  140. ctx.email = ctx.EmailBuilder.buildEmail(
  141. 'ownershipTransferConfirmationPreviousOwner',
  142. ctx.opts
  143. )
  144. })
  145. it('should replace spammy project name', function (ctx) {
  146. ctx.email.html.indexOf('your project').should.not.equal(-1)
  147. })
  148. })
  149. describe('gitTokenExpiringSoon', function () {
  150. beforeEach(function (ctx) {
  151. ctx.opts = {
  152. to: 'user@example.com',
  153. }
  154. ctx.email = ctx.EmailBuilder.buildEmail('gitTokenExpiringSoon', ctx.opts)
  155. })
  156. it('should render html, text, and subject without undefined', function (ctx) {
  157. expect(ctx.email.html).to.not.be.undefined
  158. expect(ctx.email.text).to.not.be.undefined
  159. expect(ctx.email.subject).to.not.be.undefined
  160. ctx.email.html.indexOf('undefined').should.equal(-1)
  161. ctx.email.text.indexOf('undefined').should.equal(-1)
  162. ctx.email.subject.indexOf('undefined').should.equal(-1)
  163. })
  164. it('should link the CTA to user settings', function (ctx) {
  165. ctx.email.text.should.contain(`${ctx.settings.siteUrl}/user/settings`)
  166. })
  167. })
  168. describe('gitTokenExpired', function () {
  169. beforeEach(function (ctx) {
  170. ctx.opts = {
  171. to: 'user@example.com',
  172. }
  173. ctx.email = ctx.EmailBuilder.buildEmail('gitTokenExpired', ctx.opts)
  174. })
  175. it('should render html, text, and subject without undefined', function (ctx) {
  176. expect(ctx.email.html).to.not.be.undefined
  177. expect(ctx.email.text).to.not.be.undefined
  178. expect(ctx.email.subject).to.not.be.undefined
  179. ctx.email.html.indexOf('undefined').should.equal(-1)
  180. ctx.email.text.indexOf('undefined').should.equal(-1)
  181. ctx.email.subject.indexOf('undefined').should.equal(-1)
  182. })
  183. it('should link the CTA to user settings', function (ctx) {
  184. ctx.email.text.should.contain(`${ctx.settings.siteUrl}/user/settings`)
  185. })
  186. })
  187. describe('ctaTemplate', function () {
  188. describe('missing required content', function () {
  189. const content = {
  190. title: () => {},
  191. greeting: () => {},
  192. message: () => {},
  193. secondaryMessage: () => {},
  194. ctaText: () => {},
  195. ctaURL: () => {},
  196. gmailGoToAction: () => {},
  197. }
  198. it('should throw an error when missing title', function (ctx) {
  199. const { title, ...missing } = content
  200. expect(() => {
  201. ctx.EmailBuilder.ctaTemplate(missing)
  202. }).to.throw(Error)
  203. })
  204. it('should throw an error when missing message', function (ctx) {
  205. const { message, ...missing } = content
  206. expect(() => {
  207. ctx.EmailBuilder.ctaTemplate(missing)
  208. }).to.throw(Error)
  209. })
  210. it('should throw an error when missing ctaText', function (ctx) {
  211. const { ctaText, ...missing } = content
  212. expect(() => {
  213. ctx.EmailBuilder.ctaTemplate(missing)
  214. }).to.throw(Error)
  215. })
  216. it('should throw an error when missing ctaURL', function (ctx) {
  217. const { ctaURL, ...missing } = content
  218. expect(() => {
  219. ctx.EmailBuilder.ctaTemplate(missing)
  220. }).to.throw(Error)
  221. })
  222. })
  223. describe('footerMessage', function () {
  224. it('should default footerMessage to undefined when not provided', function (ctx) {
  225. const template = ctx.EmailBuilder.ctaTemplate({
  226. subject: () => 'Subject',
  227. message: () => ['Message'],
  228. ctaText: () => 'Click',
  229. ctaURL: () => 'https://example.com',
  230. })
  231. expect(template.footerMessage({})).to.be.undefined
  232. })
  233. it('should use the provided footerMessage callback', function (ctx) {
  234. const template = ctx.EmailBuilder.ctaTemplate({
  235. subject: () => 'Subject',
  236. message: () => ['Message'],
  237. ctaText: () => 'Click',
  238. ctaURL: () => 'https://example.com',
  239. footerMessage: () => 'Custom footer text',
  240. })
  241. expect(template.footerMessage({})).to.equal('Custom footer text')
  242. })
  243. it('should include footerMessage in plain text output when provided', function (ctx) {
  244. ctx.EmailBuilder.templates.testFooterTemplate =
  245. ctx.EmailBuilder.ctaTemplate({
  246. subject: () => 'Test Subject',
  247. message: () => ['Body message'],
  248. ctaText: () => 'Go',
  249. ctaURL: () => 'https://example.com',
  250. footerMessage: (opts, isPlainText) =>
  251. isPlainText ? 'Plain footer' : '<b>HTML footer</b>',
  252. })
  253. const email = ctx.EmailBuilder.buildEmail('testFooterTemplate', {
  254. to: 'test@example.com',
  255. })
  256. expect(email.text).to.contain('Plain footer')
  257. delete ctx.EmailBuilder.templates.testFooterTemplate
  258. })
  259. })
  260. })
  261. describe('templates', function () {
  262. describe('CTA', function () {
  263. describe('canceledSubscription', function () {
  264. beforeEach(function (ctx) {
  265. ctx.emailAddress = 'example@overleaf.com'
  266. ctx.opts = {
  267. to: ctx.emailAddress,
  268. }
  269. ctx.email = ctx.EmailBuilder.buildEmail(
  270. 'canceledSubscription',
  271. ctx.opts
  272. )
  273. ctx.expectedUrl =
  274. 'https://docs.google.com/forms/d/e/1FAIpQLSfa7z_s-cucRRXm70N4jEcSbFsZeb0yuKThHGQL8ySEaQzF0Q/viewform?usp=sf_link'
  275. })
  276. it('should build the email', function (ctx) {
  277. expect(ctx.email.html).to.exist
  278. expect(ctx.email.text).to.exist
  279. })
  280. describe('HTML email', function () {
  281. it('should include a CTA button and a fallback CTA link', function (ctx) {
  282. const dom = cheerio.load(ctx.email.html)
  283. const buttonLink = dom('a:contains("Leave feedback")')
  284. expect(buttonLink.length).to.equal(1)
  285. expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl)
  286. expect(ctx.email.html).to.contain('copy and paste this link')
  287. expect(ctx.email.html).to.contain(ctx.expectedUrl)
  288. })
  289. })
  290. describe('plain text email', function () {
  291. it('should contain the CTA link', function (ctx) {
  292. expect(ctx.email.text).to.contain(ctx.expectedUrl)
  293. })
  294. })
  295. })
  296. describe('canceledSubscriptionOrAddOn', function () {
  297. beforeEach(function (ctx) {
  298. ctx.emailAddress = 'example@overleaf.com'
  299. ctx.opts = {
  300. to: ctx.emailAddress,
  301. }
  302. ctx.email = ctx.EmailBuilder.buildEmail(
  303. 'canceledSubscriptionOrAddOn',
  304. ctx.opts
  305. )
  306. ctx.expectedUrl =
  307. 'https://digitalscience.qualtrics.com/jfe/form/SV_2n2aSlWgvoxXdGK'
  308. })
  309. it('should build the email', function (ctx) {
  310. expect(ctx.email.html).to.exist
  311. expect(ctx.email.text).to.exist
  312. })
  313. describe('HTML email', function () {
  314. it('should include a CTA button and a fallback CTA link', function (ctx) {
  315. const dom = cheerio.load(ctx.email.html)
  316. const buttonLink = dom('a:contains("Leave feedback")')
  317. expect(buttonLink.length).to.equal(1)
  318. expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl)
  319. expect(ctx.email.html).to.contain('copy and paste this link')
  320. expect(ctx.email.html).to.contain(ctx.expectedUrl)
  321. })
  322. })
  323. describe('plain text email', function () {
  324. it('should contain the CTA link', function (ctx) {
  325. expect(ctx.email.text).to.contain(ctx.expectedUrl)
  326. })
  327. })
  328. })
  329. describe('confirmEmail', function () {
  330. beforeEach(function (ctx) {
  331. ctx.emailAddress = 'example@overleaf.com'
  332. ctx.userId = 'abc123'
  333. ctx.opts = {
  334. to: ctx.emailAddress,
  335. confirmEmailUrl: `${ctx.settings.siteUrl}/user/emails/confirm?token=aToken123`,
  336. sendingUser_id: ctx.userId,
  337. }
  338. ctx.email = ctx.EmailBuilder.buildEmail('confirmEmail', ctx.opts)
  339. })
  340. it('should build the email', function (ctx) {
  341. expect(ctx.email.html).to.exist
  342. expect(ctx.email.text).to.exist
  343. })
  344. describe('HTML email', function () {
  345. it('should include a CTA button and a fallback CTA link', function (ctx) {
  346. const dom = cheerio.load(ctx.email.html)
  347. const buttonLink = dom('a:contains("Confirm email")')
  348. expect(buttonLink.length).to.equal(1)
  349. expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl)
  350. expect(ctx.email.html).to.contain('copy and paste this link')
  351. expect(ctx.email.html).to.contain(ctx.opts.confirmEmailUrl)
  352. })
  353. })
  354. describe('plain text email', function () {
  355. it('should contain the CTA link', function (ctx) {
  356. expect(ctx.email.text).to.contain(ctx.opts.confirmEmailUrl)
  357. })
  358. })
  359. })
  360. describe('ownershipTransferConfirmationNewOwner', function () {
  361. beforeEach(function (ctx) {
  362. ctx.emailAddress = 'example@overleaf.com'
  363. ctx.opts = {
  364. to: ctx.emailAddress,
  365. previousOwner: {},
  366. project: {
  367. _id: 'abc123',
  368. name: 'example project',
  369. },
  370. }
  371. ctx.email = ctx.EmailBuilder.buildEmail(
  372. 'ownershipTransferConfirmationNewOwner',
  373. ctx.opts
  374. )
  375. ctx.expectedUrl = `${
  376. ctx.settings.siteUrl
  377. }/project/${ctx.opts.project._id.toString()}`
  378. })
  379. it('should build the email', function (ctx) {
  380. expect(ctx.email.html).to.exist
  381. expect(ctx.email.text).to.exist
  382. })
  383. describe('HTML email', function () {
  384. it('should include a CTA button and a fallback CTA link', function (ctx) {
  385. const dom = cheerio.load(ctx.email.html)
  386. const buttonLink = dom('a:contains("View project")')
  387. expect(buttonLink.length).to.equal(1)
  388. expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl)
  389. expect(ctx.email.html).to.contain('copy and paste this link')
  390. expect(ctx.email.html).to.contain(ctx.expectedUrl)
  391. })
  392. })
  393. describe('plain text email', function () {
  394. it('should contain the CTA link', function (ctx) {
  395. expect(ctx.email.text).to.contain(ctx.expectedUrl)
  396. })
  397. })
  398. })
  399. describe('passwordResetRequested', function () {
  400. beforeEach(function (ctx) {
  401. ctx.emailAddress = 'example@overleaf.com'
  402. ctx.opts = {
  403. to: ctx.emailAddress,
  404. setNewPasswordUrl: `${
  405. ctx.settings.siteUrl
  406. }/user/password/set?passwordResetToken=aToken&email=${encodeURIComponent(
  407. ctx.emailAddress
  408. )}`,
  409. }
  410. ctx.email = ctx.EmailBuilder.buildEmail(
  411. 'passwordResetRequested',
  412. ctx.opts
  413. )
  414. })
  415. it('should build the email', function (ctx) {
  416. expect(ctx.email.html).to.exist
  417. expect(ctx.email.text).to.exist
  418. })
  419. describe('HTML email', function () {
  420. it('should include a CTA button and a fallback CTA link', function (ctx) {
  421. const dom = cheerio.load(ctx.email.html)
  422. const buttonLink = dom('a:contains("Reset password")')
  423. expect(buttonLink.length).to.equal(1)
  424. expect(buttonLink.attr('href')).to.equal(ctx.opts.setNewPasswordUrl)
  425. expect(ctx.email.html).to.contain('copy and paste this link')
  426. expect(ctx.email.html).to.contain(ctx.opts.setNewPasswordUrl)
  427. })
  428. })
  429. describe('plain text email', function () {
  430. it('should contain the CTA link', function (ctx) {
  431. expect(ctx.email.text).to.contain(ctx.opts.setNewPasswordUrl)
  432. })
  433. })
  434. })
  435. describe('reconfirmEmail', function () {
  436. beforeEach(function (ctx) {
  437. ctx.emailAddress = 'example@overleaf.com'
  438. ctx.userId = 'abc123'
  439. ctx.opts = {
  440. to: ctx.emailAddress,
  441. confirmEmailUrl: `${ctx.settings.siteUrl}/user/emails/confirm?token=aToken123`,
  442. sendingUser_id: ctx.userId,
  443. }
  444. ctx.email = ctx.EmailBuilder.buildEmail('reconfirmEmail', ctx.opts)
  445. })
  446. it('should build the email', function (ctx) {
  447. expect(ctx.email.html).to.exist
  448. expect(ctx.email.text).to.exist
  449. })
  450. describe('HTML email', function () {
  451. it('should include a CTA button and a fallback CTA link', function (ctx) {
  452. const dom = cheerio.load(ctx.email.html)
  453. const buttonLink = dom('a:contains("Reconfirm email")')
  454. expect(buttonLink.length).to.equal(1)
  455. expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl)
  456. expect(ctx.email.html).to.contain('copy and paste this link')
  457. expect(ctx.email.html).to.contain(ctx.opts.confirmEmailUrl)
  458. })
  459. })
  460. describe('plain text email', function () {
  461. it('should contain the CTA link', function (ctx) {
  462. expect(ctx.email.text).to.contain(ctx.opts.confirmEmailUrl)
  463. })
  464. })
  465. })
  466. describe('verifyEmailToJoinTeam', function () {
  467. beforeEach(function (ctx) {
  468. ctx.emailAddress = 'example@overleaf.com'
  469. ctx.opts = {
  470. to: ctx.emailAddress,
  471. acceptInviteUrl: `${ctx.settings.siteUrl}/subscription/invites/aToken123/`,
  472. inviter: {
  473. email: 'deanna@overleaf.com',
  474. first_name: 'Deanna',
  475. last_name: 'Troi',
  476. },
  477. }
  478. ctx.email = ctx.EmailBuilder.buildEmail(
  479. 'verifyEmailToJoinTeam',
  480. ctx.opts
  481. )
  482. })
  483. it('should build the email', function (ctx) {
  484. expect(ctx.email.html).to.exist
  485. expect(ctx.email.text).to.exist
  486. })
  487. describe('HTML email', function () {
  488. it('should include a CTA button and a fallback CTA link', function (ctx) {
  489. const dom = cheerio.load(ctx.email.html)
  490. const buttonLink = dom('a:contains("Join now")')
  491. expect(buttonLink.length).to.equal(1)
  492. expect(buttonLink.attr('href')).to.equal(ctx.opts.acceptInviteUrl)
  493. expect(ctx.email.html).to.contain('copy and paste this link')
  494. expect(ctx.email.html).to.contain(ctx.opts.acceptInviteUrl)
  495. })
  496. })
  497. describe('plain text email', function () {
  498. it('should contain the CTA link', function (ctx) {
  499. expect(ctx.email.text).to.contain(ctx.opts.acceptInviteUrl)
  500. })
  501. })
  502. })
  503. describe('reactivatedSubscription', function () {
  504. beforeEach(function (ctx) {
  505. ctx.emailAddress = 'example@overleaf.com'
  506. ctx.opts = {
  507. to: ctx.emailAddress,
  508. }
  509. ctx.email = ctx.EmailBuilder.buildEmail(
  510. 'reactivatedSubscription',
  511. ctx.opts
  512. )
  513. ctx.expectedUrl = `${ctx.settings.siteUrl}/user/subscription`
  514. })
  515. it('should build the email', function (ctx) {
  516. expect(ctx.email.html).to.exist
  517. expect(ctx.email.text).to.exist
  518. })
  519. describe('HTML email', function () {
  520. it('should include a CTA button and a fallback CTA link', function (ctx) {
  521. const dom = cheerio.load(ctx.email.html)
  522. const buttonLink = dom('a:contains("View Subscription Dashboard")')
  523. expect(buttonLink.length).to.equal(1)
  524. expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl)
  525. expect(ctx.email.html).to.contain('copy and paste this link')
  526. expect(ctx.email.html).to.contain(ctx.expectedUrl)
  527. })
  528. })
  529. describe('plain text email', function () {
  530. it('should contain the CTA link', function (ctx) {
  531. expect(ctx.email.text).to.contain(ctx.expectedUrl)
  532. })
  533. })
  534. })
  535. describe('testEmail', function () {
  536. beforeEach(function (ctx) {
  537. ctx.emailAddress = 'example@overleaf.com'
  538. ctx.opts = {
  539. to: ctx.emailAddress,
  540. }
  541. ctx.email = ctx.EmailBuilder.buildEmail('testEmail', ctx.opts)
  542. })
  543. it('should build the email', function (ctx) {
  544. expect(ctx.email.html).to.exist
  545. expect(ctx.email.text).to.exist
  546. })
  547. describe('HTML email', function () {
  548. it('should include a CTA button and a fallback CTA link', function (ctx) {
  549. const dom = cheerio.load(ctx.email.html)
  550. const buttonLink = dom(`a:contains("Open ${ctx.settings.appName}")`)
  551. expect(buttonLink.length).to.equal(1)
  552. expect(buttonLink.attr('href')).to.equal(ctx.settings.siteUrl)
  553. expect(ctx.email.html).to.contain('copy and paste this link')
  554. expect(ctx.email.html).to.contain(ctx.settings.siteUrl)
  555. })
  556. })
  557. describe('plain text email', function () {
  558. it('should contain the CTA link', function (ctx) {
  559. expect(ctx.email.text).to.contain(
  560. `Open ${ctx.settings.appName}: ${ctx.settings.siteUrl}`
  561. )
  562. })
  563. })
  564. })
  565. describe('registered', function () {
  566. beforeEach(function (ctx) {
  567. ctx.emailAddress = 'example@overleaf.com'
  568. ctx.opts = {
  569. to: ctx.emailAddress,
  570. setNewPasswordUrl: `${ctx.settings.siteUrl}/user/activate?token=aToken123&user_id=aUserId123`,
  571. }
  572. ctx.email = ctx.EmailBuilder.buildEmail('registered', ctx.opts)
  573. })
  574. it('should build the email', function (ctx) {
  575. expect(ctx.email.html).to.exist
  576. expect(ctx.email.text).to.exist
  577. })
  578. describe('HTML email', function () {
  579. it('should include a CTA button and a fallback CTA link', function (ctx) {
  580. const dom = cheerio.load(ctx.email.html)
  581. const buttonLink = dom('a:contains("Set password")')
  582. expect(buttonLink.length).to.equal(1)
  583. expect(buttonLink.attr('href')).to.equal(ctx.opts.setNewPasswordUrl)
  584. expect(ctx.email.html).to.contain('copy and paste this link')
  585. expect(ctx.email.html).to.contain(ctx.opts.setNewPasswordUrl)
  586. })
  587. })
  588. describe('plain text email', function () {
  589. it('should contain the CTA link', function (ctx) {
  590. expect(ctx.email.text).to.contain(ctx.opts.setNewPasswordUrl)
  591. })
  592. })
  593. })
  594. describe('projectInvite', function () {
  595. beforeEach(function (ctx) {
  596. ctx.emailAddress = 'example@overleaf.com'
  597. ctx.owner = {
  598. email: 'owner@example.com',
  599. name: 'Bailey',
  600. }
  601. ctx.projectName = 'Top Secret'
  602. ctx.opts = {
  603. inviteUrl: `${ctx.settings.siteUrl}/project/projectId123/invite/token/aToken123`,
  604. owner: {
  605. email: ctx.owner.email,
  606. },
  607. project: {
  608. name: ctx.projectName,
  609. },
  610. to: ctx.emailAddress,
  611. }
  612. ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts)
  613. })
  614. it('should build the email', function (ctx) {
  615. expect(ctx.email.html).to.exist
  616. expect(ctx.email.text).to.exist
  617. })
  618. describe('HTML email', function () {
  619. it('should include a CTA button and a fallback CTA link', function (ctx) {
  620. const dom = cheerio.load(ctx.email.html)
  621. const buttonLink = dom('a:contains("View project")')
  622. expect(buttonLink.length).to.equal(1)
  623. expect(buttonLink.attr('href')).to.equal(ctx.opts.inviteUrl)
  624. expect(ctx.email.html).to.contain('copy and paste this link')
  625. expect(ctx.email.html).to.contain(ctx.opts.inviteUrl)
  626. })
  627. })
  628. describe('plain text email', function () {
  629. it('should contain the CTA link', function (ctx) {
  630. expect(ctx.email.text).to.contain(ctx.opts.inviteUrl)
  631. })
  632. })
  633. })
  634. describe('welcome', function () {
  635. beforeEach(function (ctx) {
  636. ctx.emailAddress = 'example@overleaf.com'
  637. ctx.opts = {
  638. to: ctx.emailAddress,
  639. confirmEmailUrl: `${ctx.settings.siteUrl}/user/emails/confirm?token=token123`,
  640. }
  641. ctx.email = ctx.EmailBuilder.buildEmail('welcome', ctx.opts)
  642. ctx.dom = cheerio.load(ctx.email.html)
  643. })
  644. it('should build the email', function (ctx) {
  645. expect(ctx.email.html).to.exist
  646. expect(ctx.email.text).to.exist
  647. })
  648. describe('HTML email', function () {
  649. it('should include a CTA button and a fallback CTA link', function (ctx) {
  650. const buttonLink = ctx.dom('a:contains("Confirm email")')
  651. expect(buttonLink.length).to.equal(1)
  652. expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl)
  653. expect(ctx.email.html).to.contain('copy and paste this link')
  654. expect(ctx.email.html).to.contain(ctx.opts.confirmEmailUrl)
  655. })
  656. it('should include help links', function (ctx) {
  657. const helpGuidesLink = ctx.dom('a:contains("Help Guides")')
  658. const templatesLink = ctx.dom('a:contains("Templates")')
  659. const logInLink = ctx.dom('a:contains("log in")')
  660. expect(helpGuidesLink.length).to.equal(1)
  661. expect(templatesLink.length).to.equal(1)
  662. expect(logInLink.length).to.equal(1)
  663. })
  664. })
  665. describe('plain text email', function () {
  666. it('should contain the CTA URL', function (ctx) {
  667. expect(ctx.email.text).to.contain(ctx.opts.confirmEmailUrl)
  668. })
  669. it('should include help URL', function (ctx) {
  670. expect(ctx.email.text).to.contain('/learn')
  671. expect(ctx.email.text).to.contain('/login')
  672. expect(ctx.email.text).to.contain('/templates')
  673. })
  674. it('should contain HTML links', function (ctx) {
  675. expect(ctx.email.text).to.not.contain('<a')
  676. })
  677. })
  678. })
  679. describe('groupSSODisabled', function () {
  680. it('should build the email for non managed and linked users', function (ctx) {
  681. const setNewPasswordUrl = `${ctx.settings.siteUrl}/user/password/reset`
  682. const emailAddress = 'example@overleaf.com'
  683. const opts = {
  684. to: emailAddress,
  685. setNewPasswordUrl,
  686. userIsManaged: false,
  687. }
  688. const email = ctx.EmailBuilder.buildEmail('groupSSODisabled', opts)
  689. expect(email.subject).to.equal(
  690. 'A change to your Overleaf login options'
  691. )
  692. const dom = cheerio.load(email.html)
  693. expect(email.html).to.exist
  694. expect(email.html).to.contain(
  695. 'Your group administrator has disabled single sign-on for your group.'
  696. )
  697. expect(email.html).to.contain(
  698. 'You can still log in to Overleaf using one of our other'
  699. )
  700. const loginLink = dom('a:contains("login options")')
  701. expect(loginLink.attr('href')).to.equal(
  702. `${ctx.settings.siteUrl}/login`
  703. )
  704. const passwordLink = dom('a:contains("Set your new password")')
  705. expect(passwordLink.attr('href')).to.equal(setNewPasswordUrl)
  706. expect(email.html).to.contain(
  707. "If you don't have a password, you can set one now."
  708. )
  709. expect(email.text).to.exist
  710. const expectedPlainText = [
  711. 'Hi,',
  712. '',
  713. 'Your group administrator has disabled single sign-on for your group.',
  714. '',
  715. '',
  716. '',
  717. 'What does this mean for you?',
  718. '',
  719. 'You can still log in to Overleaf using one of our other login options or with your email address and password.',
  720. '',
  721. "If you don't have a password, you can set one now.",
  722. '',
  723. `Set your new password: ${setNewPasswordUrl}`,
  724. '',
  725. '',
  726. '',
  727. 'Regards,',
  728. `The ${ctx.settings.appName} Team - ${ctx.settings.siteUrl}`,
  729. ]
  730. expect(email.text.split(/\r?\n/)).to.deep.equal(expectedPlainText)
  731. })
  732. it('should build the email for managed and linked users', function (ctx) {
  733. const emailAddress = 'example@overleaf.com'
  734. const setNewPasswordUrl = `${ctx.settings.siteUrl}/user/password/reset`
  735. const opts = {
  736. to: emailAddress,
  737. setNewPasswordUrl,
  738. userIsManaged: true,
  739. }
  740. const email = ctx.EmailBuilder.buildEmail('groupSSODisabled', opts)
  741. expect(email.subject).to.equal(
  742. 'Action required: Set your Overleaf password'
  743. )
  744. const dom = cheerio.load(email.html)
  745. expect(email.html).to.exist
  746. expect(email.html).to.contain(
  747. 'Your group administrator has disabled single sign-on for your group.'
  748. )
  749. expect(email.html).to.contain(
  750. 'You now need an email address and password to sign in to your Overleaf account.'
  751. )
  752. const ctaLink = dom('a:contains("Set your new password")')
  753. expect(ctaLink.attr('href')).to.equal(
  754. `${ctx.settings.siteUrl}/user/password/reset`
  755. )
  756. expect(email.text).to.exist
  757. const expectedPlainText = [
  758. 'Hi,',
  759. '',
  760. 'Your group administrator has disabled single sign-on for your group.',
  761. '',
  762. '',
  763. '',
  764. 'What does this mean for you?',
  765. '',
  766. 'You now need an email address and password to sign in to your Overleaf account.',
  767. '',
  768. `Set your new password: ${setNewPasswordUrl}`,
  769. '',
  770. '',
  771. '',
  772. 'Regards,',
  773. `The ${ctx.settings.appName} Team - ${ctx.settings.siteUrl}`,
  774. ]
  775. expect(email.text.split(/\r?\n/)).to.deep.equal(expectedPlainText)
  776. })
  777. })
  778. })
  779. describe('no CTA', function () {
  780. describe('securityAlert', function () {
  781. beforeEach(function (ctx) {
  782. ctx.message = 'more details about the action'
  783. ctx.messageHTML = `<br /><span style="text-align:center" class="a-class"><b><i>${ctx.message}</i></b></span>`
  784. ctx.messageNotAllowedHTML = `<div></div>${ctx.messageHTML}`
  785. ctx.actionDescribed = 'an action described'
  786. ctx.actionDescribedHTML = `<br /><span style="text-align:center" class="a-class"><b><i>${ctx.actionDescribed}</i></b>`
  787. ctx.actionDescribedNotAllowedHTML = `<div></div>${ctx.actionDescribedHTML}`
  788. ctx.opts = {
  789. to: ctx.email,
  790. actionDescribed: ctx.actionDescribedNotAllowedHTML,
  791. action: 'an action',
  792. message: [ctx.messageNotAllowedHTML],
  793. }
  794. ctx.email = ctx.EmailBuilder.buildEmail('securityAlert', ctx.opts)
  795. })
  796. it('should build the email', function (ctx) {
  797. expect(ctx.email.html != null).to.equal(true)
  798. expect(ctx.email.text != null).to.equal(true)
  799. })
  800. describe('HTML email', function () {
  801. it('should clean HTML in opts.actionDescribed', function (ctx) {
  802. expect(ctx.email.html).to.not.contain(
  803. ctx.actionDescribedNotAllowedHTML
  804. )
  805. expect(ctx.email.html).to.contain(ctx.actionDescribedHTML)
  806. })
  807. it('should clean HTML in opts.message', function (ctx) {
  808. expect(ctx.email.html).to.not.contain(ctx.messageNotAllowedHTML)
  809. expect(ctx.email.html).to.contain(ctx.messageHTML)
  810. })
  811. })
  812. describe('plain text email', function () {
  813. it('should remove all HTML in opts.actionDescribed', function (ctx) {
  814. expect(ctx.email.text).to.not.contain(ctx.actionDescribedHTML)
  815. expect(ctx.email.text).to.contain(ctx.actionDescribed)
  816. })
  817. it('should remove all HTML in opts.message', function (ctx) {
  818. expect(ctx.email.text).to.not.contain(ctx.messageHTML)
  819. expect(ctx.email.text).to.contain(ctx.message)
  820. })
  821. })
  822. })
  823. describe('welcomeWithoutCTA', function () {
  824. beforeEach(function (ctx) {
  825. ctx.emailAddress = 'example@overleaf.com'
  826. ctx.opts = {
  827. to: ctx.emailAddress,
  828. }
  829. ctx.email = ctx.EmailBuilder.buildEmail('welcomeWithoutCTA', ctx.opts)
  830. ctx.dom = cheerio.load(ctx.email.html)
  831. })
  832. it('should build the email', function (ctx) {
  833. expect(ctx.email.html).to.exist
  834. expect(ctx.email.text).to.exist
  835. })
  836. describe('HTML email', function () {
  837. it('should include help links', function (ctx) {
  838. const helpGuidesLink = ctx.dom('a:contains("Help Guides")')
  839. const templatesLink = ctx.dom('a:contains("Templates")')
  840. const logInLink = ctx.dom('a:contains("log in")')
  841. expect(helpGuidesLink.length).to.equal(1)
  842. expect(templatesLink.length).to.equal(1)
  843. expect(logInLink.length).to.equal(1)
  844. })
  845. })
  846. describe('plain text email', function () {
  847. it('should include help URL', function (ctx) {
  848. expect(ctx.email.text).to.contain('/learn')
  849. expect(ctx.email.text).to.contain('/login')
  850. expect(ctx.email.text).to.contain('/templates')
  851. })
  852. it('should contain HTML links', function (ctx) {
  853. expect(ctx.email.text).to.not.contain('<a')
  854. })
  855. })
  856. })
  857. describe('removeGroupMember', function () {
  858. beforeEach(function (ctx) {
  859. ctx.passwordResetUrl = `${ctx.settings.siteUrl}/user/password/reset`
  860. ctx.emailAddress = 'example@overleaf.com'
  861. ctx.opts = {
  862. to: ctx.emailAddress,
  863. adminName: 'abcdef',
  864. }
  865. ctx.email = ctx.EmailBuilder.buildEmail('removeGroupMember', ctx.opts)
  866. ctx.dom = cheerio.load(ctx.email.html)
  867. })
  868. it('should build the email', function (ctx) {
  869. expect(ctx.email.html).to.exist
  870. expect(ctx.email.text).to.exist
  871. })
  872. describe('HTML email', function () {
  873. it('should include links', function (ctx) {
  874. const resetPasswordLink = ctx.dom('a:contains("set a password")')
  875. expect(resetPasswordLink.length).to.equal(1)
  876. expect(resetPasswordLink.attr('href')).to.equal(
  877. ctx.passwordResetUrl
  878. )
  879. })
  880. })
  881. describe('plain text email', function () {
  882. it('should include URLs', function (ctx) {
  883. expect(ctx.email.text).to.contain(ctx.passwordResetUrl)
  884. })
  885. })
  886. })
  887. describe('taxExemptCertificateRequired', function () {
  888. beforeEach(function (ctx) {
  889. ctx.emailAddress = 'customer@example.com'
  890. ctx.opts = {
  891. to: ctx.emailAddress,
  892. ein: '12-3456789',
  893. stripeCustomerId: 'cus_123456789',
  894. }
  895. ctx.email = ctx.EmailBuilder.buildEmail(
  896. 'taxExemptCertificateRequired',
  897. ctx.opts
  898. )
  899. ctx.dom = cheerio.load(ctx.email.html)
  900. })
  901. it('should build the email', function (ctx) {
  902. expect(ctx.email.html).to.exist
  903. expect(ctx.email.text).to.exist
  904. })
  905. describe('HTML email', function () {
  906. it('should include the EIN', function (ctx) {
  907. expect(ctx.email.html).to.contain(ctx.opts.ein)
  908. })
  909. it('should include the Stripe customer ID', function (ctx) {
  910. expect(ctx.email.html).to.contain(ctx.opts.stripeCustomerId)
  911. })
  912. it('should include tax exemption verification text', function (ctx) {
  913. expect(ctx.email.html).to.contain('tax exempt')
  914. expect(ctx.email.html).to.contain('verification')
  915. })
  916. })
  917. describe('plain text email', function () {
  918. it('should include the EIN', function (ctx) {
  919. expect(ctx.email.text).to.contain(ctx.opts.ein)
  920. })
  921. it('should include the Stripe customer ID', function (ctx) {
  922. expect(ctx.email.text).to.contain(ctx.opts.stripeCustomerId)
  923. })
  924. it('should include tax exemption verification text', function (ctx) {
  925. expect(ctx.email.text).to.contain('tax exempt')
  926. expect(ctx.email.text).to.contain('verification')
  927. })
  928. })
  929. })
  930. describe('groupMemberLimitWarning', function () {
  931. beforeEach(function (ctx) {
  932. ctx.emailAddress = 'example@overleaf.com'
  933. ctx.opts = {
  934. to: ctx.emailAddress,
  935. groupName: 'Example Group',
  936. firstName: 'Joe',
  937. currentMembers: 9,
  938. membersLimit: 10,
  939. remainingSeats: 1,
  940. }
  941. ctx.email = ctx.EmailBuilder.buildEmail(
  942. 'groupMemberLimitWarning',
  943. ctx.opts
  944. )
  945. ctx.expectedUrl = `${ctx.settings.siteUrl}/user/subscription/group/add-users`
  946. })
  947. it('should build the email', function (ctx) {
  948. expect(ctx.email.html).to.exist
  949. expect(ctx.email.text).to.exist
  950. })
  951. describe('HTML email', function () {
  952. it('should include a CTA button and a fallback CTA link', function (ctx) {
  953. const dom = cheerio.load(ctx.email.html)
  954. const plainText = dom.text()
  955. expect(ctx.email.subject).to.equal(
  956. 'Action needed: Your Overleaf group is nearly out of licenses'
  957. )
  958. expect(ctx.email.html).to.exist
  959. expect(ctx.email.html).to.contain(
  960. `Your Overleaf group <b>${ctx.opts.groupName}</b> is close to its license limit.`
  961. )
  962. expect(plainText).to.contain(
  963. `${ctx.opts.currentMembers} of ${ctx.opts.membersLimit} ` +
  964. `licenses are in use (${ctx.opts.remainingSeats} remaining).`
  965. )
  966. expect(ctx.email.html).to.contain(
  967. 'Because domain capture is enabled, users from your domain ' +
  968. 'can join automatically via SSO.'
  969. )
  970. expect(ctx.email.html).to.contain(
  971. 'Once all licenses are used, new users won’t be able to join.'
  972. )
  973. expect(ctx.email.html).to.contain('What you can do now:')
  974. expect(ctx.email.html).to.contain('Add more licenses, or')
  975. expect(ctx.email.html).to.contain(
  976. 'Remove inactive users to free up licenses'
  977. )
  978. const buttonLink = dom('a:contains("Add licenses")')
  979. expect(buttonLink).to.exist
  980. expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl)
  981. expect(ctx.email.html).to.contain('copy and paste this link')
  982. expect(ctx.email.html).to.contain(ctx.expectedUrl)
  983. })
  984. })
  985. describe('plain text email', function () {
  986. it('should contain the CTA link', function (ctx) {
  987. expect(ctx.email.text).to.contain(ctx.expectedUrl)
  988. })
  989. })
  990. })
  991. })
  992. })
  993. })