EmailBuilderTests.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. const SandboxedModule = require('sandboxed-module')
  2. const cheerio = require('cheerio')
  3. const path = require('path')
  4. const { expect } = require('chai')
  5. const MODULE_PATH = path.join(
  6. __dirname,
  7. '../../../../app/src/Features/Email/EmailBuilder'
  8. )
  9. const EmailMessageHelper = require('../../../../app/src/Features/Email/EmailMessageHelper')
  10. const ctaEmailBody = require('../../../../app/src/Features/Email/Bodies/cta-email')
  11. const NoCTAEmailBody = require('../../../../app/src/Features/Email/Bodies/NoCTAEmailBody')
  12. const BaseWithHeaderEmailLayout = require('../../../../app/src/Features/Email/Layouts/BaseWithHeaderEmailLayout')
  13. describe('EmailBuilder', function () {
  14. before(function () {
  15. this.settings = {
  16. appName: 'testApp',
  17. siteUrl: 'https://www.overleaf.com',
  18. }
  19. this.EmailBuilder = SandboxedModule.require(MODULE_PATH, {
  20. requires: {
  21. './EmailMessageHelper': EmailMessageHelper,
  22. './Bodies/cta-email': ctaEmailBody,
  23. './Bodies/NoCTAEmailBody': NoCTAEmailBody,
  24. './Layouts/BaseWithHeaderEmailLayout': BaseWithHeaderEmailLayout,
  25. '@overleaf/settings': this.settings,
  26. },
  27. })
  28. })
  29. describe('projectInvite', function () {
  30. beforeEach(function () {
  31. this.opts = {
  32. to: 'bob@bob.com',
  33. first_name: 'bob',
  34. owner: {
  35. email: 'sally@hally.com',
  36. },
  37. inviteUrl: 'http://example.com/invite',
  38. project: {
  39. url: 'http://www.project.com',
  40. name: 'standard project',
  41. },
  42. }
  43. })
  44. describe('when sending a normal email', function () {
  45. beforeEach(function () {
  46. this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
  47. })
  48. it('should have html and text properties', function () {
  49. expect(this.email.html != null).to.equal(true)
  50. expect(this.email.text != null).to.equal(true)
  51. })
  52. it('should not have undefined in it', function () {
  53. this.email.html.indexOf('undefined').should.equal(-1)
  54. this.email.subject.indexOf('undefined').should.equal(-1)
  55. })
  56. })
  57. describe('when someone is up to no good', function () {
  58. it('should not contain the project name at all if unsafe', function () {
  59. this.opts.project.name = "<img src='http://evilsite.com/evil.php'>"
  60. this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
  61. expect(this.email.html).to.not.contain('evilsite.com')
  62. expect(this.email.subject).to.not.contain('evilsite.com')
  63. // but email should appear
  64. expect(this.email.html).to.contain(this.opts.owner.email)
  65. expect(this.email.subject).to.contain(this.opts.owner.email)
  66. })
  67. it('should not contain the inviter email at all if unsafe', function () {
  68. this.opts.owner.email =
  69. 'verylongemailaddressthatwillfailthecheck@longdomain.domain'
  70. this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
  71. expect(this.email.html).to.not.contain(this.opts.owner.email)
  72. expect(this.email.subject).to.not.contain(this.opts.owner.email)
  73. // but title should appear
  74. expect(this.email.html).to.contain(this.opts.project.name)
  75. expect(this.email.subject).to.contain(this.opts.project.name)
  76. })
  77. it('should handle both email and title being unsafe', function () {
  78. this.opts.project.name = "<img src='http://evilsite.com/evil.php'>"
  79. this.opts.owner.email =
  80. 'verylongemailaddressthatwillfailthecheck@longdomain.domain'
  81. this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
  82. expect(this.email.html).to.not.contain('evilsite.com')
  83. expect(this.email.subject).to.not.contain('evilsite.com')
  84. expect(this.email.html).to.not.contain(this.opts.owner.email)
  85. expect(this.email.subject).to.not.contain(this.opts.owner.email)
  86. expect(this.email.html).to.contain(
  87. 'Please view the project to find out more'
  88. )
  89. })
  90. })
  91. })
  92. describe('SpamSafe', function () {
  93. beforeEach(function () {
  94. this.opts = {
  95. to: 'bob@joe.com',
  96. first_name: 'bob',
  97. newOwner: {
  98. email: 'sally@hally.com',
  99. },
  100. inviteUrl: 'http://example.com/invite',
  101. project: {
  102. url: 'http://www.project.com',
  103. name: 'come buy my product at http://notascam.com',
  104. },
  105. }
  106. this.email = this.EmailBuilder.buildEmail(
  107. 'ownershipTransferConfirmationPreviousOwner',
  108. this.opts
  109. )
  110. })
  111. it('should replace spammy project name', function () {
  112. this.email.html.indexOf('your project').should.not.equal(-1)
  113. })
  114. })
  115. describe('ctaTemplate', function () {
  116. describe('missing required content', function () {
  117. const content = {
  118. title: () => {},
  119. greeting: () => {},
  120. message: () => {},
  121. secondaryMessage: () => {},
  122. ctaText: () => {},
  123. ctaURL: () => {},
  124. gmailGoToAction: () => {},
  125. }
  126. it('should throw an error when missing title', function () {
  127. const { title, ...missing } = content
  128. expect(() => {
  129. this.EmailBuilder.ctaTemplate(missing)
  130. }).to.throw(Error)
  131. })
  132. it('should throw an error when missing message', function () {
  133. const { message, ...missing } = content
  134. expect(() => {
  135. this.EmailBuilder.ctaTemplate(missing)
  136. }).to.throw(Error)
  137. })
  138. it('should throw an error when missing ctaText', function () {
  139. const { ctaText, ...missing } = content
  140. expect(() => {
  141. this.EmailBuilder.ctaTemplate(missing)
  142. }).to.throw(Error)
  143. })
  144. it('should throw an error when missing ctaURL', function () {
  145. const { ctaURL, ...missing } = content
  146. expect(() => {
  147. this.EmailBuilder.ctaTemplate(missing)
  148. }).to.throw(Error)
  149. })
  150. })
  151. })
  152. describe('templates', function () {
  153. describe('CTA', function () {
  154. describe('canceledSubscription', function () {
  155. beforeEach(function () {
  156. this.emailAddress = 'example@overleaf.com'
  157. this.opts = {
  158. to: this.emailAddress,
  159. }
  160. this.email = this.EmailBuilder.buildEmail(
  161. 'canceledSubscription',
  162. this.opts
  163. )
  164. this.expectedUrl =
  165. 'https://docs.google.com/forms/d/e/1FAIpQLSfa7z_s-cucRRXm70N4jEcSbFsZeb0yuKThHGQL8ySEaQzF0Q/viewform?usp=sf_link'
  166. })
  167. it('should build the email', function () {
  168. expect(this.email.html).to.exist
  169. expect(this.email.text).to.exist
  170. })
  171. describe('HTML email', function () {
  172. it('should include a CTA button and a fallback CTA link', function () {
  173. const dom = cheerio.load(this.email.html)
  174. const buttonLink = dom('a:contains("Leave Feedback")')
  175. expect(buttonLink.length).to.equal(1)
  176. expect(buttonLink.attr('href')).to.equal(this.expectedUrl)
  177. const fallback = dom('.force-overleaf-style').last()
  178. expect(fallback.length).to.equal(1)
  179. const fallbackLink = fallback.html()
  180. expect(fallbackLink).to.contain(this.expectedUrl)
  181. })
  182. })
  183. describe('plain text email', function () {
  184. it('should contain the CTA link', function () {
  185. expect(this.email.text).to.contain(this.expectedUrl)
  186. })
  187. })
  188. })
  189. describe('confirmEmail', function () {
  190. before(function () {
  191. this.emailAddress = 'example@overleaf.com'
  192. this.userId = 'abc123'
  193. this.opts = {
  194. to: this.emailAddress,
  195. confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=aToken123`,
  196. sendingUser_id: this.userId,
  197. }
  198. this.email = this.EmailBuilder.buildEmail('confirmEmail', this.opts)
  199. })
  200. it('should build the email', function () {
  201. expect(this.email.html).to.exist
  202. expect(this.email.text).to.exist
  203. })
  204. describe('HTML email', function () {
  205. it('should include a CTA button and a fallback CTA link', function () {
  206. const dom = cheerio.load(this.email.html)
  207. const buttonLink = dom('a:contains("Confirm Email")')
  208. expect(buttonLink.length).to.equal(1)
  209. expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl)
  210. const fallback = dom('.force-overleaf-style').last()
  211. expect(fallback.length).to.equal(1)
  212. const fallbackLink = fallback.html()
  213. expect(fallbackLink).to.contain(this.opts.confirmEmailUrl)
  214. })
  215. })
  216. describe('plain text email', function () {
  217. it('should contain the CTA link', function () {
  218. expect(this.email.text).to.contain(this.opts.confirmEmailUrl)
  219. })
  220. })
  221. })
  222. describe('ownershipTransferConfirmationNewOwner', function () {
  223. before(function () {
  224. this.emailAddress = 'example@overleaf.com'
  225. this.opts = {
  226. to: this.emailAddress,
  227. previousOwner: {},
  228. project: {
  229. _id: 'abc123',
  230. name: 'example project',
  231. },
  232. }
  233. this.email = this.EmailBuilder.buildEmail(
  234. 'ownershipTransferConfirmationNewOwner',
  235. this.opts
  236. )
  237. this.expectedUrl = `${
  238. this.settings.siteUrl
  239. }/project/${this.opts.project._id.toString()}`
  240. })
  241. it('should build the email', function () {
  242. expect(this.email.html).to.exist
  243. expect(this.email.text).to.exist
  244. })
  245. describe('HTML email', function () {
  246. it('should include a CTA button and a fallback CTA link', function () {
  247. const dom = cheerio.load(this.email.html)
  248. const buttonLink = dom('td a')
  249. expect(buttonLink).to.exist
  250. expect(buttonLink.attr('href')).to.equal(this.expectedUrl)
  251. const fallback = dom('.force-overleaf-style').last()
  252. expect(fallback).to.exist
  253. const fallbackLink = fallback.html().replace(/&amp;/g, '&')
  254. expect(fallbackLink).to.contain(this.expectedUrl)
  255. })
  256. })
  257. describe('plain text email', function () {
  258. it('should contain the CTA link', function () {
  259. expect(this.email.text).to.contain(this.expectedUrl)
  260. })
  261. })
  262. })
  263. describe('passwordResetRequested', function () {
  264. before(function () {
  265. this.emailAddress = 'example@overleaf.com'
  266. this.opts = {
  267. to: this.emailAddress,
  268. setNewPasswordUrl: `${
  269. this.settings.siteUrl
  270. }/user/password/set?passwordResetToken=aToken&email=${encodeURIComponent(
  271. this.emailAddress
  272. )}`,
  273. }
  274. this.email = this.EmailBuilder.buildEmail(
  275. 'passwordResetRequested',
  276. this.opts
  277. )
  278. })
  279. it('should build the email', function () {
  280. expect(this.email.html).to.exist
  281. expect(this.email.text).to.exist
  282. })
  283. describe('HTML email', function () {
  284. it('should include a CTA button and a fallback CTA link', function () {
  285. const dom = cheerio.load(this.email.html)
  286. const buttonLink = dom('td a')
  287. expect(buttonLink).to.exist
  288. expect(buttonLink.attr('href')).to.equal(
  289. this.opts.setNewPasswordUrl
  290. )
  291. const fallback = dom('.force-overleaf-style').last()
  292. expect(fallback).to.exist
  293. const fallbackLink = fallback.html().replace(/&amp;/g, '&')
  294. expect(fallbackLink).to.contain(this.opts.setNewPasswordUrl)
  295. })
  296. })
  297. describe('plain text email', function () {
  298. it('should contain the CTA link', function () {
  299. expect(this.email.text).to.contain(this.opts.setNewPasswordUrl)
  300. })
  301. })
  302. })
  303. describe('reconfirmEmail', function () {
  304. before(function () {
  305. this.emailAddress = 'example@overleaf.com'
  306. this.userId = 'abc123'
  307. this.opts = {
  308. to: this.emailAddress,
  309. confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=aToken123`,
  310. sendingUser_id: this.userId,
  311. }
  312. this.email = this.EmailBuilder.buildEmail('reconfirmEmail', this.opts)
  313. })
  314. it('should build the email', function () {
  315. expect(this.email.html).to.exist
  316. expect(this.email.text).to.exist
  317. })
  318. describe('HTML email', function () {
  319. it('should include a CTA button and a fallback CTA link', function () {
  320. const dom = cheerio.load(this.email.html)
  321. const buttonLink = dom('a:contains("Reconfirm Email")')
  322. expect(buttonLink.length).to.equal(1)
  323. expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl)
  324. const fallback = dom('.force-overleaf-style').last()
  325. expect(fallback.length).to.equal(1)
  326. const fallbackLink = fallback.html()
  327. expect(fallbackLink).to.contain(this.opts.confirmEmailUrl)
  328. })
  329. })
  330. describe('plain text email', function () {
  331. it('should contain the CTA link', function () {
  332. expect(this.email.text).to.contain(this.opts.confirmEmailUrl)
  333. })
  334. })
  335. })
  336. describe('verifyEmailToJoinTeam', function () {
  337. before(function () {
  338. this.emailAddress = 'example@overleaf.com'
  339. this.opts = {
  340. to: this.emailAddress,
  341. acceptInviteUrl: `${this.settings.siteUrl}/subscription/invites/aToken123/`,
  342. inviter: {
  343. email: 'deanna@overleaf.com',
  344. first_name: 'Deanna',
  345. last_name: 'Troi',
  346. },
  347. }
  348. this.email = this.EmailBuilder.buildEmail(
  349. 'verifyEmailToJoinTeam',
  350. this.opts
  351. )
  352. })
  353. it('should build the email', function () {
  354. expect(this.email.html).to.exist
  355. expect(this.email.text).to.exist
  356. })
  357. describe('HTML email', function () {
  358. it('should include a CTA button and a fallback CTA link', function () {
  359. const dom = cheerio.load(this.email.html)
  360. const buttonLink = dom('a:contains("Join now")')
  361. expect(buttonLink.length).to.equal(1)
  362. expect(buttonLink.attr('href')).to.equal(this.opts.acceptInviteUrl)
  363. const fallback = dom('.force-overleaf-style').last()
  364. expect(fallback.length).to.equal(1)
  365. const fallbackLink = fallback.html()
  366. expect(fallbackLink).to.contain(this.opts.acceptInviteUrl)
  367. })
  368. })
  369. describe('plain text email', function () {
  370. it('should contain the CTA link', function () {
  371. expect(this.email.text).to.contain(this.opts.acceptInviteUrl)
  372. })
  373. })
  374. })
  375. describe('reactivatedSubscription', function () {
  376. before(function () {
  377. this.emailAddress = 'example@overleaf.com'
  378. this.opts = {
  379. to: this.emailAddress,
  380. }
  381. this.email = this.EmailBuilder.buildEmail(
  382. 'reactivatedSubscription',
  383. this.opts
  384. )
  385. this.expectedUrl = `${this.settings.siteUrl}/user/subscription`
  386. })
  387. it('should build the email', function () {
  388. expect(this.email.html).to.exist
  389. expect(this.email.text).to.exist
  390. })
  391. describe('HTML email', function () {
  392. it('should include a CTA button and a fallback CTA link', function () {
  393. const dom = cheerio.load(this.email.html)
  394. const buttonLink = dom('a:contains("View Subscription Dashboard")')
  395. expect(buttonLink.length).to.equal(1)
  396. expect(buttonLink.attr('href')).to.equal(this.expectedUrl)
  397. const fallback = dom('.force-overleaf-style').last()
  398. expect(fallback.length).to.equal(1)
  399. const fallbackLink = fallback.html()
  400. expect(fallbackLink).to.contain(this.expectedUrl)
  401. })
  402. })
  403. describe('plain text email', function () {
  404. it('should contain the CTA link', function () {
  405. expect(this.email.text).to.contain(this.expectedUrl)
  406. })
  407. })
  408. })
  409. describe('testEmail', function () {
  410. before(function () {
  411. this.emailAddress = 'example@overleaf.com'
  412. this.opts = {
  413. to: this.emailAddress,
  414. }
  415. this.email = this.EmailBuilder.buildEmail('testEmail', this.opts)
  416. })
  417. it('should build the email', function () {
  418. expect(this.email.html).to.exist
  419. expect(this.email.text).to.exist
  420. })
  421. describe('HTML email', function () {
  422. it('should include a CTA button and a fallback CTA link', function () {
  423. const dom = cheerio.load(this.email.html)
  424. const buttonLink = dom(
  425. `a:contains("Open ${this.settings.appName}")`
  426. )
  427. expect(buttonLink.length).to.equal(1)
  428. expect(buttonLink.attr('href')).to.equal(this.settings.siteUrl)
  429. const fallback = dom('.force-overleaf-style').last()
  430. expect(fallback.length).to.equal(1)
  431. const fallbackLink = fallback.html()
  432. expect(fallbackLink).to.contain(this.settings.siteUrl)
  433. })
  434. })
  435. describe('plain text email', function () {
  436. it('should contain the CTA link', function () {
  437. expect(this.email.text).to.contain(
  438. `Open ${this.settings.appName}: ${this.settings.siteUrl}`
  439. )
  440. })
  441. })
  442. })
  443. describe('registered', function () {
  444. before(function () {
  445. this.emailAddress = 'example@overleaf.com'
  446. this.opts = {
  447. to: this.emailAddress,
  448. setNewPasswordUrl: `${this.settings.siteUrl}/user/activate?token=aToken123&user_id=aUserId123`,
  449. }
  450. this.email = this.EmailBuilder.buildEmail('registered', this.opts)
  451. })
  452. it('should build the email', function () {
  453. expect(this.email.html).to.exist
  454. expect(this.email.text).to.exist
  455. })
  456. describe('HTML email', function () {
  457. it('should include a CTA button and a fallback CTA link', function () {
  458. const dom = cheerio.load(this.email.html)
  459. const buttonLink = dom('a:contains("Set password")')
  460. expect(buttonLink.length).to.equal(1)
  461. expect(buttonLink.attr('href')).to.equal(
  462. this.opts.setNewPasswordUrl
  463. )
  464. const fallback = dom('.force-overleaf-style').last()
  465. expect(fallback.length).to.equal(1)
  466. const fallbackLink = fallback.html().replace(/&amp;/, '&')
  467. expect(fallbackLink).to.contain(this.opts.setNewPasswordUrl)
  468. })
  469. })
  470. describe('plain text email', function () {
  471. it('should contain the CTA link', function () {
  472. expect(this.email.text).to.contain(this.opts.setNewPasswordUrl)
  473. })
  474. })
  475. })
  476. describe('projectInvite', function () {
  477. before(function () {
  478. this.emailAddress = 'example@overleaf.com'
  479. this.owner = {
  480. email: 'owner@example.com',
  481. name: 'Bailey',
  482. }
  483. this.projectName = 'Top Secret'
  484. this.opts = {
  485. inviteUrl:
  486. `${this.settings.siteUrl}/project/projectId123/invite/token/aToken123?` +
  487. [
  488. `project_name=${encodeURIComponent(this.projectName)}`,
  489. `user_first_name=${encodeURIComponent(this.owner.name)}`,
  490. ].join('&'),
  491. owner: {
  492. email: this.owner.email,
  493. },
  494. project: {
  495. name: this.projectName,
  496. },
  497. to: this.emailAddress,
  498. }
  499. this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts)
  500. })
  501. it('should build the email', function () {
  502. expect(this.email.html).to.exist
  503. expect(this.email.text).to.exist
  504. })
  505. describe('HTML email', function () {
  506. it('should include a CTA button and a fallback CTA link', function () {
  507. const dom = cheerio.load(this.email.html)
  508. const buttonLink = dom('a:contains("View project")')
  509. expect(buttonLink.length).to.equal(1)
  510. expect(buttonLink.attr('href')).to.equal(this.opts.inviteUrl)
  511. const fallback = dom('.force-overleaf-style').last()
  512. expect(fallback.length).to.equal(1)
  513. const fallbackLink = fallback.html().replace(/&amp;/g, '&')
  514. expect(fallbackLink).to.contain(this.opts.inviteUrl)
  515. })
  516. })
  517. describe('plain text email', function () {
  518. it('should contain the CTA link', function () {
  519. expect(this.email.text).to.contain(this.opts.inviteUrl)
  520. })
  521. })
  522. })
  523. })
  524. describe('no CTA', function () {
  525. describe('securityAlert', function () {
  526. before(function () {
  527. this.message = 'more details about the action'
  528. this.messageHTML = `<br /><span style="text-align:center" class="a-class"><b><i>${this.message}</i></b></span>`
  529. this.messageNotAllowedHTML = `<div></div>${this.messageHTML}`
  530. this.actionDescribed = 'an action described'
  531. this.actionDescribedHTML = `<br /><span style="text-align:center" class="a-class"><b><i>${this.actionDescribed}</i></b>`
  532. this.actionDescribedNotAllowedHTML = `<div></div>${this.actionDescribedHTML}`
  533. this.opts = {
  534. to: this.email,
  535. actionDescribed: this.actionDescribedNotAllowedHTML,
  536. action: 'an action',
  537. message: [this.messageNotAllowedHTML],
  538. }
  539. this.email = this.EmailBuilder.buildEmail('securityAlert', this.opts)
  540. })
  541. it('should build the email', function () {
  542. expect(this.email.html != null).to.equal(true)
  543. expect(this.email.text != null).to.equal(true)
  544. })
  545. describe('HTML email', function () {
  546. it('should clean HTML in opts.actionDescribed', function () {
  547. expect(this.email.html).to.not.contain(
  548. this.actionDescribedNotAllowedHTML
  549. )
  550. expect(this.email.html).to.contain(this.actionDescribedHTML)
  551. })
  552. it('should clean HTML in opts.message', function () {
  553. expect(this.email.html).to.not.contain(this.messageNotAllowedHTML)
  554. expect(this.email.html).to.contain(this.messageHTML)
  555. })
  556. })
  557. describe('plain text email', function () {
  558. it('should remove all HTML in opts.actionDescribed', function () {
  559. expect(this.email.text).to.not.contain(this.actionDescribedHTML)
  560. expect(this.email.text).to.contain(this.actionDescribed)
  561. })
  562. it('should remove all HTML in opts.message', function () {
  563. expect(this.email.text).to.not.contain(this.messageHTML)
  564. expect(this.email.text).to.contain(this.message)
  565. })
  566. })
  567. })
  568. })
  569. })
  570. })