plugin.spec.ts 40 KB

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