EmailBuilderTests.js 18 KB

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