plugin.spec.ts 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075
  1. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  2. import { Test, TestingModule } from '@nestjs/testing';
  3. import { TypeOrmModule } from '@nestjs/typeorm';
  4. import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
  5. import {
  6. EventBus,
  7. Injector,
  8. JobQueueService,
  9. LanguageCode,
  10. Logger,
  11. Order,
  12. OrderStateTransitionEvent,
  13. PluginCommonModule,
  14. RequestContext,
  15. VendureEvent,
  16. } from '@vendure/core';
  17. import { ensureConfigLoaded } from '@vendure/core/dist/config/config-helpers';
  18. import { TestingLogger } from '@vendure/testing';
  19. import { createReadStream, readFileSync } from 'fs';
  20. import path from 'path';
  21. import { Readable } from 'stream';
  22. import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
  23. import { EmailProcessor } from './email-processor';
  24. import { EmailEventListener } from './event-listener';
  25. import { orderConfirmationHandler } from './handler/default-email-handlers';
  26. import { EmailEventHandler } from './handler/event-handler';
  27. import { EmailPlugin } from './plugin';
  28. import { EmailSender } from './sender/email-sender';
  29. import { FileBasedTemplateLoader } from './template-loader/file-based-template-loader';
  30. import { TemplateLoader } from './template-loader/template-loader';
  31. import {
  32. EmailDetails,
  33. Partial as EmailPartial,
  34. EmailPluginOptions,
  35. EmailTransportOptions,
  36. LoadTemplateInput,
  37. } from './types';
  38. describe('EmailPlugin', () => {
  39. let eventBus: EventBus;
  40. let onSend: Mock;
  41. let module: TestingModule;
  42. const testingLogger = new TestingLogger((...args) => vi.fn(...args));
  43. async function initPluginWithHandlers(
  44. handlers: Array<EmailEventHandler<string, any>>,
  45. options?: Partial<EmailPluginOptions>,
  46. ) {
  47. await ensureConfigLoaded();
  48. onSend = vi.fn();
  49. module = await Test.createTestingModule({
  50. imports: [
  51. TypeOrmModule.forRoot({
  52. type: 'sqljs',
  53. retryAttempts: 0,
  54. }),
  55. PluginCommonModule,
  56. EmailPlugin.init({
  57. templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../test-templates')),
  58. transport: {
  59. type: 'testing',
  60. onSend,
  61. },
  62. handlers,
  63. ...options,
  64. }),
  65. ],
  66. providers: [MockService],
  67. }).compile();
  68. Logger.useLogger(testingLogger);
  69. module.useLogger(new Logger());
  70. const plugin = module.get(EmailPlugin);
  71. eventBus = module.get(EventBus);
  72. await plugin.onApplicationBootstrap();
  73. return module;
  74. }
  75. afterEach(async () => {
  76. if (module) {
  77. await module.close();
  78. }
  79. });
  80. it('setting from, recipient, subject', async () => {
  81. const ctx = RequestContext.deserialize({
  82. _channel: { code: DEFAULT_CHANNEL_CODE },
  83. _languageCode: LanguageCode.en,
  84. } as any);
  85. const handler = new EmailEventListener('test')
  86. .on(MockEvent)
  87. .setFrom('"test from" <noreply@test.com>')
  88. .setRecipient(() => 'test@test.com')
  89. .setSubject('Hello')
  90. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  91. await initPluginWithHandlers([handler]);
  92. await eventBus.publish(new MockEvent(ctx, true));
  93. await pause();
  94. expect(onSend.mock.calls[0][0].subject).toBe('Hello');
  95. expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
  96. expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
  97. });
  98. describe('event filtering', () => {
  99. const ctx = RequestContext.deserialize({
  100. _channel: { code: DEFAULT_CHANNEL_CODE },
  101. _languageCode: LanguageCode.en,
  102. } as any);
  103. it('single filter', async () => {
  104. const handler = new EmailEventListener('test')
  105. .on(MockEvent)
  106. .filter(event => event.shouldSend === true)
  107. .setRecipient(() => 'test@test.com')
  108. .setFrom('"test from" <noreply@test.com>')
  109. .setSubject('test subject');
  110. await initPluginWithHandlers([handler]);
  111. await eventBus.publish(new MockEvent(ctx, false));
  112. await pause();
  113. expect(onSend).not.toHaveBeenCalled();
  114. await eventBus.publish(new MockEvent(ctx, true));
  115. await pause();
  116. expect(onSend).toHaveBeenCalledTimes(1);
  117. });
  118. it('multiple filters', async () => {
  119. const handler = new EmailEventListener('test')
  120. .on(MockEvent)
  121. .filter(event => event.shouldSend === true)
  122. .filter(event => !!event.ctx.activeUserId)
  123. .setFrom('"test from" <noreply@test.com>')
  124. .setRecipient(() => 'test@test.com')
  125. .setSubject('test subject');
  126. await initPluginWithHandlers([handler]);
  127. await eventBus.publish(new MockEvent(ctx, true));
  128. await pause();
  129. expect(onSend).not.toHaveBeenCalled();
  130. const ctxWithUser = RequestContext.deserialize({ ...ctx, _session: { user: { id: 42 } } } as any);
  131. await eventBus.publish(new MockEvent(ctxWithUser, true));
  132. await pause();
  133. expect(onSend).toHaveBeenCalledTimes(1);
  134. });
  135. it('with .loadData() after .filter()', async () => {
  136. const handler = new EmailEventListener('test')
  137. .on(MockEvent)
  138. .filter(event => event.shouldSend === true)
  139. .loadData(context => Promise.resolve('loaded data'))
  140. .setRecipient(() => 'test@test.com')
  141. .setFrom('"test from" <noreply@test.com>')
  142. .setSubject('test subject');
  143. await initPluginWithHandlers([handler]);
  144. await eventBus.publish(new MockEvent(ctx, false));
  145. await pause();
  146. expect(onSend).not.toHaveBeenCalled();
  147. await eventBus.publish(new MockEvent(ctx, true));
  148. await pause();
  149. expect(onSend).toHaveBeenCalledTimes(1);
  150. });
  151. });
  152. describe('templateVars', () => {
  153. const ctx = RequestContext.deserialize({
  154. _channel: { code: DEFAULT_CHANNEL_CODE },
  155. _languageCode: LanguageCode.en,
  156. } as any);
  157. it('interpolates subject', async () => {
  158. const handler = new EmailEventListener('test')
  159. .on(MockEvent)
  160. .setFrom('"test from" <noreply@test.com>')
  161. .setRecipient(() => 'test@test.com')
  162. .setSubject('Hello {{ subjectVar }}')
  163. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  164. await initPluginWithHandlers([handler]);
  165. await eventBus.publish(new MockEvent(ctx, true));
  166. await pause();
  167. expect(onSend.mock.calls[0][0].subject).toBe('Hello foo');
  168. });
  169. it('interpolates body', async () => {
  170. const handler = new EmailEventListener('test')
  171. .on(MockEvent)
  172. .setFrom('"test from" <noreply@test.com>')
  173. .setRecipient(() => 'test@test.com')
  174. .setSubject('Hello')
  175. .setTemplateVars(event => ({ testVar: 'this is the test var' }));
  176. await initPluginWithHandlers([handler]);
  177. await eventBus.publish(new MockEvent(ctx, true));
  178. await pause();
  179. expect(onSend.mock.calls[0][0].body).toContain('this is the test var');
  180. });
  181. /**
  182. * Intended to test the ability for Handlebars to interpolate
  183. * getters on the Order entity prototype.
  184. * See https://github.com/vendure-ecommerce/vendure/issues/259
  185. */
  186. it('interpolates body with property from entity', async () => {
  187. const handler = new EmailEventListener('test')
  188. .on(MockEvent)
  189. .setFrom('"test from" <noreply@test.com>')
  190. .setRecipient(() => 'test@test.com')
  191. .setSubject('Hello')
  192. .setTemplateVars(event => ({ order: new Order({ subTotal: 123 }) }));
  193. await initPluginWithHandlers([handler]);
  194. await eventBus.publish(new MockEvent(ctx, true));
  195. await pause();
  196. expect(onSend.mock.calls[0][0].body).toContain('Total: 123');
  197. });
  198. it('interpolates globalTemplateVars', async () => {
  199. const handler = new EmailEventListener('test')
  200. .on(MockEvent)
  201. .setFrom('"test from" <noreply@test.com>')
  202. .setRecipient(() => 'test@test.com')
  203. .setSubject('Hello {{ globalVar }}');
  204. await initPluginWithHandlers([handler], {
  205. globalTemplateVars: { globalVar: 'baz' },
  206. });
  207. await eventBus.publish(new MockEvent(ctx, true));
  208. await pause();
  209. expect(onSend.mock.calls[0][0].subject).toBe('Hello baz');
  210. });
  211. it('loads globalTemplateVars async', async () => {
  212. const handler = new EmailEventListener('test')
  213. .on(MockEvent)
  214. .setFrom('"test from" <noreply@test.com>')
  215. .setRecipient(() => 'test@test.com')
  216. .setSubject('Job {{ name }}, {{ primaryColor }}');
  217. await initPluginWithHandlers([handler], {
  218. globalTemplateVars: async (_ctxLocal: RequestContext, injector: Injector) => {
  219. const jobQueueService = injector.get(JobQueueService);
  220. const jobQueue = await jobQueueService.createQueue({
  221. name: 'hello-service',
  222. // eslint-disable-next-line
  223. process: async job => {
  224. return 'hello';
  225. },
  226. });
  227. const name = jobQueue.name;
  228. return {
  229. name,
  230. primaryColor: 'blue',
  231. };
  232. },
  233. });
  234. await eventBus.publish(new MockEvent(ctx, true));
  235. await pause();
  236. expect(onSend.mock.calls[0][0].subject).toBe(`Job hello-service, blue`);
  237. });
  238. it('interpolates from', async () => {
  239. const handler = new EmailEventListener('test')
  240. .on(MockEvent)
  241. .setFrom('"test from {{ globalVar }}" <noreply@test.com>')
  242. .setRecipient(() => 'test@test.com')
  243. .setSubject('Hello');
  244. await initPluginWithHandlers([handler], {
  245. globalTemplateVars: { globalVar: 'baz' },
  246. });
  247. await eventBus.publish(new MockEvent(ctx, true));
  248. await pause();
  249. expect(onSend.mock.calls[0][0].from).toBe('"test from baz" <noreply@test.com>');
  250. });
  251. // Test fix for https://github.com/vendure-ecommerce/vendure/issues/363
  252. it('does not escape HTML chars when interpolating "from"', async () => {
  253. const handler = new EmailEventListener('test')
  254. .on(MockEvent)
  255. .setFrom('{{ globalFrom }}')
  256. .setRecipient(() => 'Test <test@test.com>')
  257. .setSubject('Hello');
  258. await initPluginWithHandlers([handler], {
  259. globalTemplateVars: { globalFrom: 'Test <test@test.com>' },
  260. });
  261. await eventBus.publish(new MockEvent(ctx, true));
  262. await pause();
  263. expect(onSend.mock.calls[0][0].from).toBe('Test <test@test.com>');
  264. });
  265. it('globalTemplateVars available in setTemplateVars method', async () => {
  266. const handler = new EmailEventListener('test')
  267. .on(MockEvent)
  268. .setFrom('"test from" <noreply@test.com>')
  269. .setRecipient(() => 'test@test.com')
  270. .setSubject('Hello {{ testVar }}')
  271. .setTemplateVars((event, globals) => ({ testVar: (globals.globalVar as string) + ' quux' }));
  272. await initPluginWithHandlers([handler], {
  273. globalTemplateVars: { globalVar: 'baz' },
  274. });
  275. await eventBus.publish(new MockEvent(ctx, true));
  276. await pause();
  277. expect(onSend.mock.calls[0][0].subject).toBe('Hello baz quux');
  278. });
  279. it('setTemplateVars overrides globals', async () => {
  280. const handler = new EmailEventListener('test')
  281. .on(MockEvent)
  282. .setFrom('"test from" <noreply@test.com>')
  283. .setRecipient(() => 'test@test.com')
  284. .setSubject('Hello {{ name }}')
  285. .setTemplateVars((event, globals) => ({ name: 'quux' }));
  286. await initPluginWithHandlers([handler], { globalTemplateVars: { name: 'baz' } });
  287. await eventBus.publish(new MockEvent(ctx, true));
  288. await pause();
  289. expect(onSend.mock.calls[0][0].subject).toBe('Hello quux');
  290. });
  291. });
  292. describe('handlebars helpers', () => {
  293. const ctx = RequestContext.deserialize({
  294. _channel: { code: DEFAULT_CHANNEL_CODE },
  295. _languageCode: LanguageCode.en,
  296. } as any);
  297. it('formateDate', async () => {
  298. const handler = new EmailEventListener('test-helpers')
  299. .on(MockEvent)
  300. .setFrom('"test from" <noreply@test.com>')
  301. .setRecipient(() => 'test@test.com')
  302. .setSubject('Hello')
  303. .setTemplateVars(event => ({ myDate: new Date('2020-01-01T10:00:00.000Z'), myPrice: 0 }));
  304. await initPluginWithHandlers([handler]);
  305. await eventBus.publish(new MockEvent(ctx, true));
  306. await pause();
  307. expect(onSend.mock.calls[0][0].body).toContain('Date: Wed Jan 01 2020 10:00:00');
  308. });
  309. it('formatMoney', async () => {
  310. const handler = new EmailEventListener('test-helpers')
  311. .on(MockEvent)
  312. .setFrom('"test from" <noreply@test.com>')
  313. .setRecipient(() => 'test@test.com')
  314. .setSubject('Hello')
  315. .setTemplateVars(event => ({ myDate: new Date(), myPrice: 123 }));
  316. await initPluginWithHandlers([handler]);
  317. await eventBus.publish(new MockEvent(ctx, true));
  318. await pause();
  319. expect(onSend.mock.calls[0][0].body).toContain('Price: 1.23');
  320. expect(onSend.mock.calls[0][0].body).toContain('Price: €1.23');
  321. expect(onSend.mock.calls[0][0].body).toContain('Price: £1.23');
  322. });
  323. });
  324. describe('multiple configs', () => {
  325. const ctx = RequestContext.deserialize({
  326. _channel: { code: DEFAULT_CHANNEL_CODE },
  327. _languageCode: LanguageCode.en,
  328. } as any);
  329. it('additional LanguageCode', async () => {
  330. const handler = new EmailEventListener('test')
  331. .on(MockEvent)
  332. .setFrom('"test from" <noreply@test.com>')
  333. .setSubject('Hello, {{ name }}!')
  334. .setRecipient(() => 'test@test.com')
  335. .setTemplateVars(() => ({ name: 'Test' }))
  336. .addTemplate({
  337. channelCode: 'default',
  338. languageCode: LanguageCode.de,
  339. templateFile: 'body.de.hbs',
  340. subject: 'Servus, {{ name }}!',
  341. });
  342. await initPluginWithHandlers([handler]);
  343. const ctxTa = RequestContext.deserialize({ ...ctx, _languageCode: LanguageCode.ta } as any);
  344. await eventBus.publish(new MockEvent(ctxTa, true));
  345. await pause();
  346. expect(onSend.mock.calls[0][0].subject).toBe('Hello, Test!');
  347. expect(onSend.mock.calls[0][0].body).toContain('Default body.');
  348. const ctxDe = RequestContext.deserialize({ ...ctx, _languageCode: LanguageCode.de } as any);
  349. await eventBus.publish(new MockEvent(ctxDe, true));
  350. await pause();
  351. expect(onSend.mock.calls[1][0].subject).toBe('Servus, Test!');
  352. expect(onSend.mock.calls[1][0].body).toContain('German body.');
  353. });
  354. it('set LanguageCode', async () => {
  355. const handler = new EmailEventListener('test')
  356. .on(MockEvent)
  357. .setFrom('"test from" <noreply@test.com>')
  358. .setSubject('Hello, {{ name }}!')
  359. .setRecipient(() => 'test@test.com')
  360. .setLanguageCode(() => LanguageCode.de)
  361. .setTemplateVars(() => ({ name: 'Test' }))
  362. .addTemplate({
  363. channelCode: 'default',
  364. languageCode: LanguageCode.de,
  365. templateFile: 'body.de.hbs',
  366. subject: 'Servus, {{ name }}!',
  367. });
  368. const ctxEn = RequestContext.deserialize({ ...ctx, _languageCode: LanguageCode.en } as any);
  369. await eventBus.publish(new MockEvent(ctxEn, true));
  370. await pause();
  371. expect(onSend.mock.calls[1][0].subject).toBe('Servus, Test!');
  372. expect(onSend.mock.calls[1][0].body).toContain('German body.');
  373. });
  374. });
  375. describe('loadData', () => {
  376. it('loads async data', async () => {
  377. const handler = new EmailEventListener('test')
  378. .on(MockEvent)
  379. .loadData(async ({ injector }) => {
  380. const service = injector.get(MockService);
  381. return service.someAsyncMethod();
  382. })
  383. .setFrom('"test from" <noreply@test.com>')
  384. .setSubject('Hello, {{ testData }}!')
  385. .setRecipient(() => 'test@test.com')
  386. .setTemplateVars(event => ({ testData: event.data }));
  387. await initPluginWithHandlers([handler]);
  388. await eventBus.publish(
  389. new MockEvent(
  390. RequestContext.deserialize({
  391. _channel: { code: DEFAULT_CHANNEL_CODE },
  392. _languageCode: LanguageCode.en,
  393. } as any),
  394. true,
  395. ),
  396. );
  397. await pause();
  398. expect(onSend.mock.calls[0][0].subject).toBe('Hello, loaded data!');
  399. });
  400. it('works when loadData is called after other setup', async () => {
  401. const handler = new EmailEventListener('test')
  402. .on(MockEvent)
  403. .setFrom('"test from" <noreply@test.com>')
  404. .setSubject('Hello, {{ testData }}!')
  405. .setRecipient(() => 'test@test.com')
  406. .loadData(async ({ injector }) => {
  407. const service = injector.get(MockService);
  408. return service.someAsyncMethod();
  409. })
  410. .setTemplateVars(event => ({ testData: event.data }));
  411. await initPluginWithHandlers([handler]);
  412. await eventBus.publish(
  413. new MockEvent(
  414. RequestContext.deserialize({
  415. _channel: { code: DEFAULT_CHANNEL_CODE },
  416. _languageCode: LanguageCode.en,
  417. } as any),
  418. true,
  419. ),
  420. );
  421. await pause();
  422. expect(onSend.mock.calls[0][0].subject).toBe('Hello, loaded data!');
  423. expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
  424. expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
  425. });
  426. it('only executes for filtered events', async () => {
  427. let callCount = 0;
  428. const handler = new EmailEventListener('test')
  429. .on(MockEvent)
  430. .filter(event => event.shouldSend === true)
  431. .loadData(async ({ injector }) => {
  432. callCount++;
  433. });
  434. await initPluginWithHandlers([handler]);
  435. await eventBus.publish(new MockEvent(RequestContext.empty(), false));
  436. await eventBus.publish(new MockEvent(RequestContext.empty(), true));
  437. await pause();
  438. expect(callCount).toBe(1);
  439. });
  440. });
  441. describe('attachments', () => {
  442. const ctx = RequestContext.deserialize({
  443. _channel: { code: DEFAULT_CHANNEL_CODE },
  444. _languageCode: LanguageCode.en,
  445. } as any);
  446. const TEST_IMAGE_PATH = path.join(__dirname, '../test-fixtures/test.jpg');
  447. it('attachments are empty by default', async () => {
  448. const handler = new EmailEventListener('test')
  449. .on(MockEvent)
  450. .setFrom('"test from" <noreply@test.com>')
  451. .setRecipient(() => 'test@test.com')
  452. .setSubject('Hello {{ subjectVar }}');
  453. await initPluginWithHandlers([handler]);
  454. await eventBus.publish(new MockEvent(ctx, true));
  455. await pause();
  456. expect(onSend.mock.calls[0][0].attachments).toEqual([]);
  457. });
  458. it('sync attachment', async () => {
  459. const handler = new EmailEventListener('test')
  460. .on(MockEvent)
  461. .setFrom('"test from" <noreply@test.com>')
  462. .setRecipient(() => 'test@test.com')
  463. .setSubject('Hello {{ subjectVar }}')
  464. .setAttachments(() => [
  465. {
  466. path: TEST_IMAGE_PATH,
  467. },
  468. ]);
  469. await initPluginWithHandlers([handler]);
  470. await eventBus.publish(new MockEvent(ctx, true));
  471. await pause();
  472. expect(onSend.mock.calls[0][0].attachments).toEqual([{ path: TEST_IMAGE_PATH }]);
  473. });
  474. it('async attachment', async () => {
  475. const handler = new EmailEventListener('test')
  476. .on(MockEvent)
  477. .setFrom('"test from" <noreply@test.com>')
  478. .setRecipient(() => 'test@test.com')
  479. .setSubject('Hello {{ subjectVar }}')
  480. .setAttachments(async () => [
  481. {
  482. path: TEST_IMAGE_PATH,
  483. },
  484. ]);
  485. await initPluginWithHandlers([handler]);
  486. await eventBus.publish(new MockEvent(ctx, true));
  487. await pause();
  488. expect(onSend.mock.calls[0][0].attachments).toEqual([{ path: TEST_IMAGE_PATH }]);
  489. });
  490. it('attachment content as a string buffer', async () => {
  491. const handler = new EmailEventListener('test')
  492. .on(MockEvent)
  493. .setFrom('"test from" <noreply@test.com>')
  494. .setRecipient(() => 'test@test.com')
  495. .setSubject('Hello {{ subjectVar }}')
  496. .setAttachments(() => [
  497. {
  498. content: Buffer.from('hello'),
  499. },
  500. ]);
  501. await initPluginWithHandlers([handler]);
  502. await eventBus.publish(new MockEvent(ctx, true));
  503. await pause();
  504. const attachment = onSend.mock.calls[0][0].attachments[0].content;
  505. expect(Buffer.compare(attachment, Buffer.from('hello'))).toBe(0); // 0 = buffers are equal
  506. });
  507. it('attachment content as an image buffer', async () => {
  508. const imageFileBuffer = readFileSync(TEST_IMAGE_PATH);
  509. const handler = new EmailEventListener('test')
  510. .on(MockEvent)
  511. .setFrom('"test from" <noreply@test.com>')
  512. .setRecipient(() => 'test@test.com')
  513. .setSubject('Hello {{ subjectVar }}')
  514. .setAttachments(() => [
  515. {
  516. content: imageFileBuffer,
  517. },
  518. ]);
  519. await initPluginWithHandlers([handler]);
  520. await eventBus.publish(new MockEvent(ctx, true));
  521. await pause();
  522. const attachment = onSend.mock.calls[0][0].attachments[0].content;
  523. expect(Buffer.compare(attachment, imageFileBuffer)).toBe(0); // 0 = buffers are equal
  524. });
  525. it('attachment content as a string', async () => {
  526. const handler = new EmailEventListener('test')
  527. .on(MockEvent)
  528. .setFrom('"test from" <noreply@test.com>')
  529. .setRecipient(() => 'test@test.com')
  530. .setSubject('Hello {{ subjectVar }}')
  531. .setAttachments(() => [
  532. {
  533. content: 'hello',
  534. },
  535. ]);
  536. await initPluginWithHandlers([handler]);
  537. await eventBus.publish(new MockEvent(ctx, true));
  538. await pause();
  539. const attachment = onSend.mock.calls[0][0].attachments[0].content;
  540. expect(attachment).toBe('hello');
  541. });
  542. it('attachment content as a string stream', async () => {
  543. const handler = new EmailEventListener('test')
  544. .on(MockEvent)
  545. .setFrom('"test from" <noreply@test.com>')
  546. .setRecipient(() => 'test@test.com')
  547. .setSubject('Hello {{ subjectVar }}')
  548. .setAttachments(() => [
  549. {
  550. content: Readable.from(['hello']),
  551. },
  552. ]);
  553. await initPluginWithHandlers([handler]);
  554. await eventBus.publish(new MockEvent(ctx, true));
  555. await pause();
  556. const attachment = onSend.mock.calls[0][0].attachments[0].content;
  557. expect(Buffer.compare(attachment, Buffer.from('hello'))).toBe(0); // 0 = buffers are equal
  558. });
  559. it('attachment content as an image stream', async () => {
  560. const imageFileBuffer = readFileSync(TEST_IMAGE_PATH);
  561. const imageFileStream = createReadStream(TEST_IMAGE_PATH);
  562. const handler = new EmailEventListener('test')
  563. .on(MockEvent)
  564. .setFrom('"test from" <noreply@test.com>')
  565. .setRecipient(() => 'test@test.com')
  566. .setSubject('Hello {{ subjectVar }}')
  567. .setAttachments(() => [
  568. {
  569. content: imageFileStream,
  570. },
  571. ]);
  572. await initPluginWithHandlers([handler]);
  573. await eventBus.publish(new MockEvent(ctx, true));
  574. await pause();
  575. const attachment = onSend.mock.calls[0][0].attachments[0].content;
  576. expect(Buffer.compare(attachment, imageFileBuffer)).toBe(0); // 0 = buffers are equal
  577. });
  578. it('raises a warning for large content attachments', async () => {
  579. testingLogger.warnSpy.mockClear();
  580. const largeBuffer = Buffer.from(Array.from({ length: 65535, 0: 0 }));
  581. const handler = new EmailEventListener('test')
  582. .on(MockEvent)
  583. .setFrom('"test from" <noreply@test.com>')
  584. .setRecipient(() => 'test@test.com')
  585. .setSubject('Hello {{ subjectVar }}')
  586. .setAttachments(() => [
  587. {
  588. content: largeBuffer,
  589. },
  590. ]);
  591. await initPluginWithHandlers([handler]);
  592. await eventBus.publish(new MockEvent(ctx, true));
  593. await pause();
  594. expect(testingLogger.warnSpy.mock.calls[0][0]).toContain(
  595. "Email has a large 'content' attachment (64k). Consider using the 'path' instead for improved performance.",
  596. );
  597. });
  598. });
  599. describe('orderConfirmationHandler', () => {
  600. beforeEach(async () => {
  601. await initPluginWithHandlers([orderConfirmationHandler], {
  602. templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../templates')),
  603. });
  604. });
  605. const ctx = RequestContext.deserialize({
  606. _channel: { code: DEFAULT_CHANNEL_CODE },
  607. _languageCode: LanguageCode.en,
  608. } as any);
  609. const order = {
  610. code: 'ABCDE',
  611. customer: {
  612. emailAddress: 'test@test.com',
  613. },
  614. lines: [],
  615. } as any;
  616. it('filters events with wrong order state', async () => {
  617. await eventBus.publish(
  618. new OrderStateTransitionEvent('AddingItems', 'ArrangingPayment', ctx, order),
  619. );
  620. await pause();
  621. expect(onSend).not.toHaveBeenCalled();
  622. await eventBus.publish(new OrderStateTransitionEvent('AddingItems', 'Cancelled', ctx, order));
  623. await pause();
  624. expect(onSend).not.toHaveBeenCalled();
  625. await eventBus.publish(
  626. new OrderStateTransitionEvent('AddingItems', 'PaymentAuthorized', ctx, order),
  627. );
  628. await pause();
  629. expect(onSend).not.toHaveBeenCalled();
  630. await eventBus.publish(
  631. new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order),
  632. );
  633. await pause();
  634. expect(onSend).toHaveBeenCalledTimes(1);
  635. });
  636. it('sets the Order Customer emailAddress as recipient', async () => {
  637. await eventBus.publish(
  638. new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order),
  639. );
  640. await pause();
  641. expect(onSend.mock.calls[0][0].recipient).toBe(order.customer!.emailAddress);
  642. });
  643. it('sets the subject', async () => {
  644. await eventBus.publish(
  645. new OrderStateTransitionEvent('ArrangingPayment', 'PaymentSettled', ctx, order),
  646. );
  647. await pause();
  648. expect(onSend.mock.calls[0][0].subject).toBe(`Order confirmation for #${order.code as string}`);
  649. });
  650. });
  651. describe('error handling', () => {
  652. it('Logs an error if the template file is not found', async () => {
  653. const ctx = RequestContext.deserialize({
  654. _channel: { code: DEFAULT_CHANNEL_CODE },
  655. _languageCode: LanguageCode.en,
  656. } as any);
  657. testingLogger.errorSpy.mockClear();
  658. const handler = new EmailEventListener('no-template')
  659. .on(MockEvent)
  660. .setFrom('"test from" <noreply@test.com>')
  661. .setRecipient(() => 'test@test.com')
  662. .setSubject('Hello {{ subjectVar }}');
  663. await initPluginWithHandlers([handler]);
  664. await eventBus.publish(new MockEvent(ctx, true));
  665. await pause();
  666. expect(testingLogger.errorSpy.mock.calls[0][0]).toContain('ENOENT: no such file or directory');
  667. });
  668. it('Logs a Handlebars error if the template is invalid', async () => {
  669. const ctx = RequestContext.deserialize({
  670. _channel: { code: DEFAULT_CHANNEL_CODE },
  671. _languageCode: LanguageCode.en,
  672. } as any);
  673. testingLogger.errorSpy.mockClear();
  674. const handler = new EmailEventListener('bad-template')
  675. .on(MockEvent)
  676. .setFrom('"test from" <noreply@test.com>')
  677. .setRecipient(() => 'test@test.com')
  678. .setSubject('Hello {{ subjectVar }}');
  679. await initPluginWithHandlers([handler]);
  680. await eventBus.publish(new MockEvent(ctx, true));
  681. await pause();
  682. expect(testingLogger.errorSpy.mock.calls[0][0]).toContain('Parse error on line 3:');
  683. });
  684. it('Logs an error if the loadData method throws', async () => {
  685. const ctx = RequestContext.deserialize({
  686. _channel: { code: DEFAULT_CHANNEL_CODE },
  687. _languageCode: LanguageCode.en,
  688. } as any);
  689. testingLogger.errorSpy.mockClear();
  690. const handler = new EmailEventListener('bad-template')
  691. .on(MockEvent)
  692. .setFrom('"test from" <noreply@test.com>')
  693. .setRecipient(() => 'test@test.com')
  694. .setSubject('Hello {{ subjectVar }}')
  695. .loadData(context => {
  696. throw new Error('something went horribly wrong!');
  697. });
  698. await initPluginWithHandlers([handler]);
  699. await eventBus.publish(new MockEvent(ctx, true));
  700. await pause();
  701. expect(testingLogger.errorSpy.mock.calls[0][0]).toContain('something went horribly wrong!');
  702. });
  703. });
  704. describe('custom sender', () => {
  705. it('should allow a custom sender to be utilized', async () => {
  706. const ctx = RequestContext.deserialize({
  707. _channel: { code: DEFAULT_CHANNEL_CODE },
  708. _languageCode: LanguageCode.en,
  709. } as any);
  710. const handler = new EmailEventListener('test')
  711. .on(MockEvent)
  712. .setFrom('"test from" <noreply@test.com>')
  713. .setRecipient(() => 'test@test.com')
  714. .setSubject('Hello')
  715. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  716. const fakeSender = new FakeCustomSender();
  717. const send = vi.fn();
  718. fakeSender.send = send;
  719. await initPluginWithHandlers([handler], {
  720. emailSender: fakeSender,
  721. });
  722. await eventBus.publish(new MockEvent(ctx, true));
  723. await pause();
  724. expect(send.mock.calls[0][0].subject).toBe('Hello');
  725. expect(send.mock.calls[0][0].recipient).toBe('test@test.com');
  726. expect(send.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
  727. expect(onSend).toHaveBeenCalledTimes(0);
  728. });
  729. });
  730. describe('optional address fields', () => {
  731. const ctx = RequestContext.deserialize({
  732. _channel: { code: DEFAULT_CHANNEL_CODE },
  733. _languageCode: LanguageCode.en,
  734. } as any);
  735. it('cc', async () => {
  736. const handler = new EmailEventListener('test')
  737. .on(MockEvent)
  738. .setFrom('"test from" <noreply@test.com>')
  739. .setRecipient(() => 'test@test.com')
  740. .setSubject('Hello {{ subjectVar }}')
  741. .setOptionalAddressFields(() => ({ cc: 'foo@bar.com' }));
  742. await initPluginWithHandlers([handler]);
  743. await eventBus.publish(new MockEvent(ctx, true));
  744. await pause();
  745. expect(onSend.mock.calls[0][0].cc).toBe('foo@bar.com');
  746. });
  747. it('bcc', async () => {
  748. const handler = new EmailEventListener('test')
  749. .on(MockEvent)
  750. .setFrom('"test from" <noreply@test.com>')
  751. .setRecipient(() => 'test@test.com')
  752. .setSubject('Hello {{ subjectVar }}')
  753. .setOptionalAddressFields(() => ({ bcc: 'foo@bar.com' }));
  754. await initPluginWithHandlers([handler]);
  755. await eventBus.publish(new MockEvent(ctx, true));
  756. await pause();
  757. expect(onSend.mock.calls[0][0].bcc).toBe('foo@bar.com');
  758. });
  759. it('replyTo', async () => {
  760. const handler = new EmailEventListener('test')
  761. .on(MockEvent)
  762. .setFrom('"test from" <noreply@test.com>')
  763. .setRecipient(() => 'test@test.com')
  764. .setSubject('Hello {{ subjectVar }}')
  765. .setOptionalAddressFields(() => ({ replyTo: 'foo@bar.com' }));
  766. await initPluginWithHandlers([handler]);
  767. await eventBus.publish(new MockEvent(ctx, true));
  768. await pause();
  769. expect(onSend.mock.calls[0][0].replyTo).toBe('foo@bar.com');
  770. });
  771. });
  772. describe('Dynamic transport settings', () => {
  773. let injectorArg: Injector | undefined;
  774. let ctxArg: RequestContext | undefined;
  775. it('Initializes with async transport settings', async () => {
  776. const handler = new EmailEventListener('test')
  777. .on(MockEvent)
  778. .setFrom('"test from" <noreply@test.com>')
  779. .setRecipient(() => 'test@test.com')
  780. .setSubject('Hello')
  781. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  782. module = await initPluginWithHandlers([handler], {
  783. transport: async (injector, _ctx) => {
  784. injectorArg = injector;
  785. ctxArg = _ctx;
  786. return {
  787. type: 'testing',
  788. onSend: () => {
  789. /* */
  790. },
  791. };
  792. },
  793. });
  794. const ctx = RequestContext.deserialize({
  795. _channel: { code: DEFAULT_CHANNEL_CODE },
  796. _languageCode: LanguageCode.en,
  797. } as any);
  798. await module!.get(EventBus).publish(new MockEvent(ctx, true));
  799. await pause();
  800. expect(module).toBeDefined();
  801. expect(typeof module.get(EmailPlugin).options.transport).toBe('function');
  802. });
  803. it('Passes injector and context to transport function', async () => {
  804. const ctx = RequestContext.deserialize({
  805. _channel: { code: DEFAULT_CHANNEL_CODE },
  806. _languageCode: LanguageCode.en,
  807. } as any);
  808. await module!.get(EventBus).publish(new MockEvent(ctx, true));
  809. await pause();
  810. expect(injectorArg?.constructor.name).toBe('Injector');
  811. expect(ctxArg?.constructor.name).toBe('RequestContext');
  812. });
  813. it('Resolves async transport settings', async () => {
  814. const transport = await module!.get(EmailProcessor).getTransportSettings();
  815. expect(transport.type).toBe('testing');
  816. });
  817. });
  818. describe('Dynamic subject handling', () => {
  819. it('With string', async () => {
  820. const ctx = RequestContext.deserialize({
  821. _channel: { code: DEFAULT_CHANNEL_CODE },
  822. _languageCode: LanguageCode.en,
  823. } as any);
  824. const handler = new EmailEventListener('test')
  825. .on(MockEvent)
  826. .setFrom('"test from" <noreply@test.com>')
  827. .setRecipient(() => 'test@test.com')
  828. .setSubject('Hello')
  829. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  830. await initPluginWithHandlers([handler]);
  831. await eventBus.publish(new MockEvent(ctx, true));
  832. await pause();
  833. expect(onSend.mock.calls[0][0].subject).toBe('Hello');
  834. expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
  835. expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
  836. });
  837. it('With callback function', async () => {
  838. const ctx = RequestContext.deserialize({
  839. _channel: { code: DEFAULT_CHANNEL_CODE },
  840. _languageCode: LanguageCode.en,
  841. } as any);
  842. const handler = new EmailEventListener('test')
  843. .on(MockEvent)
  844. .setFrom('"test from" <noreply@test.com>')
  845. .setRecipient(() => 'test@test.com')
  846. .setSubject(async (_e, _ctx, _i) => {
  847. const service = _i.get(MockService);
  848. const mockData = await service.someAsyncMethod();
  849. return `Hello from ${mockData} and {{ subjectVar }}`;
  850. })
  851. .setTemplateVars(event => ({ subjectVar: 'foo' }));
  852. await initPluginWithHandlers([handler]);
  853. await eventBus.publish(new MockEvent(ctx, true));
  854. await pause();
  855. expect(onSend.mock.calls[0][0].subject).toBe('Hello from loaded data and foo');
  856. expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
  857. expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
  858. });
  859. });
  860. // Only in case of custom template loader - part of the jsDoc - not used in the core code
  861. describe('CustomLanguageAwareTemplateLoader example', () => {
  862. it('loads language-specific template correctly', async () => {
  863. class CustomLanguageAwareTemplateLoader implements TemplateLoader {
  864. constructor(private templateDir: string) {}
  865. async loadTemplate(
  866. _injector: Injector,
  867. context: RequestContext,
  868. { type, templateName }: LoadTemplateInput,
  869. ) {
  870. const filePath = path.join(
  871. this.templateDir,
  872. type,
  873. `${templateName}.${context.languageCode}.hbs`,
  874. );
  875. return readFileSync(filePath, 'utf-8');
  876. }
  877. async loadPartials(): Promise<EmailPartial[]> {
  878. return [];
  879. }
  880. }
  881. const templatePath = path.join(__dirname, '../test-templates');
  882. const loader = new CustomLanguageAwareTemplateLoader(templatePath);
  883. const requestContext = RequestContext.deserialize({
  884. _channel: { code: DEFAULT_CHANNEL_CODE },
  885. _languageCode: LanguageCode.de,
  886. } as any);
  887. const result = await loader.loadTemplate({} as Injector, requestContext, {
  888. type: 'test',
  889. templateName: 'body',
  890. templateVars: {},
  891. });
  892. expect(result).toContain('German body');
  893. });
  894. });
  895. });
  896. class FakeCustomSender implements EmailSender {
  897. send: (email: EmailDetails<'unserialized'>, options: EmailTransportOptions) => void;
  898. }
  899. const pause = () => new Promise(resolve => setTimeout(resolve, 100));
  900. class MockEvent extends VendureEvent {
  901. constructor(
  902. public ctx: RequestContext,
  903. public shouldSend: boolean,
  904. ) {
  905. super();
  906. }
  907. }
  908. class MockService {
  909. someAsyncMethod() {
  910. return Promise.resolve('loaded data');
  911. }
  912. }