plugin.spec.ts 38 KB

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