plugin.spec.ts 42 KB

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