EmailBuilder.test.mjs 41 KB

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