event-handler.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. import { LanguageCode } from '@vendure/common/lib/generated-types';
  2. import { Type } from '@vendure/common/lib/shared-types';
  3. import { Injector, Logger } from '@vendure/core';
  4. import { serializeAttachments } from './attachment-utils';
  5. import { loggerCtx } from './constants';
  6. import { EmailEventListener } from './event-listener';
  7. import {
  8. EmailAttachment,
  9. EmailTemplateConfig,
  10. EventWithAsyncData,
  11. EventWithContext,
  12. IntermediateEmailDetails,
  13. LoadDataFn,
  14. SetAttachmentsFn,
  15. SetOptionalAddressFieldsFn,
  16. SetTemplateVarsFn,
  17. } from './types';
  18. /**
  19. * @description
  20. * The EmailEventHandler defines how the EmailPlugin will respond to a given event.
  21. *
  22. * A handler is created by creating a new {@link EmailEventListener} and calling the `.on()` method
  23. * to specify which event to respond to.
  24. *
  25. * @example
  26. * ```ts
  27. * const confirmationHandler = new EmailEventListener('order-confirmation')
  28. * .on(OrderStateTransitionEvent)
  29. * .filter(event => event.toState === 'PaymentSettled')
  30. * .setRecipient(event => event.order.customer.emailAddress)
  31. * .setSubject(`Order confirmation for #{{ order.code }}`)
  32. * .setTemplateVars(event => ({ order: event.order }));
  33. * ```
  34. *
  35. * This example creates a handler which listens for the `OrderStateTransitionEvent` and if the Order has
  36. * transitioned to the `'PaymentSettled'` state, it will generate and send an email.
  37. *
  38. * The string argument passed into the `EmailEventListener` constructor is used to identify the handler, and
  39. * also to locate the directory of the email template files. So in the example above, there should be a directory
  40. * `<app root>/static/email/templates/order-confirmation` which contains a Handlebars template named `body.hbs`.
  41. *
  42. * ## Handling other languages
  43. *
  44. * By default, the handler will respond to all events on all channels and use the same subject ("Order confirmation for #12345" above)
  45. * and body template. Where the server is intended to support multiple languages, the `.addTemplate()` method may be used
  46. * to define the subject and body template for specific language and channel combinations.
  47. *
  48. * The language is determined by looking at the `languageCode` property of the event's `ctx` ({@link RequestContext}) object.
  49. *
  50. * @example
  51. * ```ts
  52. * const extendedConfirmationHandler = confirmationHandler
  53. * .addTemplate({
  54. * channelCode: 'default',
  55. * languageCode: LanguageCode.de,
  56. * templateFile: 'body.de.hbs',
  57. * subject: 'Bestellbestätigung für #{{ order.code }}',
  58. * })
  59. * ```
  60. *
  61. * ## Defining a custom handler
  62. *
  63. * Let's say you have a plugin which defines a new event type, `QuoteRequestedEvent`. In your plugin you have defined a mutation
  64. * which is executed when the customer requests a quote in your storefront, and in your resolver, you use the {@link EventBus} to publish a
  65. * new `QuoteRequestedEvent`.
  66. *
  67. * You now want to email the customer with their quote. Here are the steps you would take to set this up:
  68. *
  69. * ### 1. Create a new handler
  70. *
  71. * ```ts
  72. * import { EmailEventListener } from `\@vendure/email-plugin`;
  73. * import { QuoteRequestedEvent } from `./events`;
  74. *
  75. * const quoteRequestedHandler = new EmailEventListener('quote-requested')
  76. * .on(QuoteRequestedEvent)
  77. * .setRecipient(event => event.customer.emailAddress)
  78. * .setSubject(`Here's the quote you requested`)
  79. * .setTemplateVars(event => ({ details: event.details }));
  80. * ```
  81. *
  82. * ### 2. Create the email template
  83. *
  84. * Next you need to make sure there is a template defined at `<app root>/static/email/templates/quote-requested/body.hbs`. The template
  85. * would look something like this:
  86. *
  87. * ```handlebars
  88. * {{> header title="Here's the quote you requested" }}
  89. *
  90. * <mj-section background-color="#fafafa">
  91. * <mj-column>
  92. * <mj-text color="#525252">
  93. * Thank you for your interest in our products! Here's the details
  94. * of the quote you recently requested:
  95. * </mj-text>
  96. *
  97. * <--! your custom email layout goes here -->
  98. * </mj-column>
  99. * </mj-section>
  100. *
  101. *
  102. * {{> footer }}
  103. * ```
  104. *
  105. * You can find pre-made templates on the [MJML website](https://mjml.io/templates/).
  106. *
  107. * ### 3. Register the handler
  108. *
  109. * Finally, you need to register the handler with the EmailPlugin:
  110. *
  111. * ```ts {hl_lines=[8]}
  112. * import { defaultEmailHandlers, EmailPlugin } from '\@vendure/email-plugin';
  113. * import { quoteRequestedHandler } from './plugins/quote-plugin';
  114. *
  115. * const config: VendureConfig = {
  116. * // Add an instance of the plugin to the plugins array
  117. * plugins: [
  118. * EmailPlugin.init({
  119. * handlers: [...defaultEmailHandlers, quoteRequestedHandler],
  120. * templatePath: path.join(__dirname, 'vendure/email/templates'),
  121. * // ... etc
  122. * }),
  123. * ],
  124. * };
  125. * ```
  126. *
  127. * @docsCategory core plugins/EmailPlugin
  128. */
  129. export class EmailEventHandler<T extends string = string, Event extends EventWithContext = EventWithContext> {
  130. private setRecipientFn: (event: Event) => string;
  131. private setLanguageCodeFn: (event: Event) => LanguageCode | undefined;
  132. private setTemplateVarsFn: SetTemplateVarsFn<Event>;
  133. private setAttachmentsFn?: SetAttachmentsFn<Event>;
  134. private setOptionalAddressFieldsFn?: SetOptionalAddressFieldsFn<Event>;
  135. private filterFns: Array<(event: Event) => boolean> = [];
  136. private configurations: EmailTemplateConfig[] = [];
  137. private defaultSubject: string;
  138. private from: string;
  139. private optionalAddressFields: {
  140. cc?: string;
  141. bcc?: string;
  142. };
  143. private _mockEvent: Omit<Event, 'ctx' | 'data'> | undefined;
  144. constructor(public listener: EmailEventListener<T>, public event: Type<Event>) {}
  145. /** @internal */
  146. get type(): T {
  147. return this.listener.type;
  148. }
  149. /** @internal */
  150. get mockEvent(): Omit<Event, 'ctx' | 'data'> | undefined {
  151. return this._mockEvent;
  152. }
  153. /**
  154. * @description
  155. * Defines a predicate function which is used to determine whether the event will trigger an email.
  156. * Multiple filter functions may be defined.
  157. */
  158. filter(filterFn: (event: Event) => boolean): EmailEventHandler<T, Event> {
  159. this.filterFns.push(filterFn);
  160. return this;
  161. }
  162. /**
  163. * @description
  164. * A function which defines how the recipient email address should be extracted from the incoming event.
  165. *
  166. * The recipient can be a plain email address: `'foobar@example.com'`
  167. * Or with a formatted name (includes unicode support): `'Ноде Майлер <foobar@example.com>'`
  168. * Or a comma-separated list of addresses: `'foobar@example.com, "Ноде Майлер" <bar@example.com>'`
  169. */
  170. setRecipient(setRecipientFn: (event: Event) => string): EmailEventHandler<T, Event> {
  171. this.setRecipientFn = setRecipientFn;
  172. return this;
  173. }
  174. /**
  175. * @description
  176. * A function which allows to override the language of the email. If not defined, the language from the context will be used.
  177. *
  178. * @since 1.8.0
  179. */
  180. setLanguageCode(
  181. setLanguageCodeFn: (event: Event) => LanguageCode | undefined,
  182. ): EmailEventHandler<T, Event> {
  183. this.setLanguageCodeFn = setLanguageCodeFn;
  184. return this;
  185. }
  186. /**
  187. * @description
  188. * A function which returns an object hash of variables which will be made available to the Handlebars template
  189. * and subject line for interpolation.
  190. */
  191. setTemplateVars(templateVarsFn: SetTemplateVarsFn<Event>): EmailEventHandler<T, Event> {
  192. this.setTemplateVarsFn = templateVarsFn;
  193. return this;
  194. }
  195. /**
  196. * @description
  197. * Sets the default subject of the email. The subject string may use Handlebars variables defined by the
  198. * setTemplateVars() method.
  199. */
  200. setSubject(defaultSubject: string): EmailEventHandler<T, Event> {
  201. this.defaultSubject = defaultSubject;
  202. return this;
  203. }
  204. /**
  205. * @description
  206. * Sets the default from field of the email. The from string may use Handlebars variables defined by the
  207. * setTemplateVars() method.
  208. */
  209. setFrom(from: string): EmailEventHandler<T, Event> {
  210. this.from = from;
  211. return this;
  212. }
  213. /**
  214. * @description
  215. * A function which allows {@link OptionalAddressFields} to be specified such as "cc" and "bcc".
  216. *
  217. * @since 1.1.0
  218. */
  219. setOptionalAddressFields(optionalAddressFieldsFn: SetOptionalAddressFieldsFn<Event>) {
  220. this.setOptionalAddressFieldsFn = optionalAddressFieldsFn;
  221. return this;
  222. }
  223. /**
  224. * @description
  225. * Defines one or more files to be attached to the email. An attachment can be specified
  226. * as either a `path` (to a file or URL) or as `content` which can be a string, Buffer or Stream.
  227. *
  228. * **Note:** When using the `content` to pass a Buffer or Stream, the raw data will get serialized
  229. * into the job queue. For this reason the total size of all attachments passed as `content` should kept to
  230. * **less than ~50k**. If the attachments are greater than that limit, a warning will be logged and
  231. * errors may result if using the DefaultJobQueuePlugin with certain DBs such as MySQL/MariaDB.
  232. *
  233. * @example
  234. * ```ts
  235. * const testAttachmentHandler = new EmailEventListener('activate-voucher')
  236. * .on(ActivateVoucherEvent)
  237. * // ... omitted some steps for brevity
  238. * .setAttachments(async (event) => {
  239. * const { imageUrl, voucherCode } = await getVoucherDataForUser(event.user.id);
  240. * return [
  241. * {
  242. * filename: `voucher-${voucherCode}.jpg`,
  243. * path: imageUrl,
  244. * },
  245. * ];
  246. * });
  247. * ```
  248. */
  249. setAttachments(setAttachmentsFn: SetAttachmentsFn<Event>) {
  250. this.setAttachmentsFn = setAttachmentsFn;
  251. return this;
  252. }
  253. /**
  254. * @description
  255. * Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific
  256. * templates for channels or languageCodes other than the default.
  257. *
  258. * @deprecated Define a custom TemplateLoader on plugin initalization to define templates based on the RequestContext.
  259. * E.g. `EmailPlugin.init({ templateLoader: new CustomTemplateLoader() })`
  260. */
  261. addTemplate(config: EmailTemplateConfig): EmailEventHandler<T, Event> {
  262. this.configurations.push(config);
  263. return this;
  264. }
  265. /**
  266. * @description
  267. * Allows data to be loaded asynchronously which can then be used as template variables.
  268. * The `loadDataFn` has access to the event, the TypeORM `Connection` object, and an
  269. * `inject()` function which can be used to inject any of the providers exported
  270. * by the {@link PluginCommonModule}. The return value of the `loadDataFn` will be
  271. * added to the `event` as the `data` property.
  272. *
  273. * @example
  274. * ```ts
  275. * new EmailEventListener('order-confirmation')
  276. * .on(OrderStateTransitionEvent)
  277. * .filter(event => event.toState === 'PaymentSettled' && !!event.order.customer)
  278. * .loadData(({ event, injector }) => {
  279. * const orderService = injector.get(OrderService);
  280. * return orderService.getOrderPayments(event.order.id);
  281. * })
  282. * .setTemplateVars(event => ({
  283. * order: event.order,
  284. * payments: event.data,
  285. * }));
  286. * ```
  287. */
  288. loadData<R>(
  289. loadDataFn: LoadDataFn<Event, R>,
  290. ): EmailEventHandlerWithAsyncData<R, T, Event, EventWithAsyncData<Event, R>> {
  291. const asyncHandler = new EmailEventHandlerWithAsyncData(loadDataFn, this.listener, this.event);
  292. asyncHandler.setRecipientFn = this.setRecipientFn;
  293. asyncHandler.setTemplateVarsFn = this.setTemplateVarsFn;
  294. asyncHandler.setAttachmentsFn = this.setAttachmentsFn;
  295. asyncHandler.setOptionalAddressFieldsFn = this.setOptionalAddressFieldsFn;
  296. asyncHandler.filterFns = this.filterFns;
  297. asyncHandler.configurations = this.configurations;
  298. asyncHandler.defaultSubject = this.defaultSubject;
  299. asyncHandler.from = this.from;
  300. asyncHandler._mockEvent = this._mockEvent as any;
  301. return asyncHandler;
  302. }
  303. /**
  304. * @description
  305. * Used internally by the EmailPlugin to handle incoming events.
  306. *
  307. * @internal
  308. */
  309. async handle(
  310. event: Event,
  311. globals: { [key: string]: any } = {},
  312. injector: Injector,
  313. ): Promise<IntermediateEmailDetails | undefined> {
  314. for (const filterFn of this.filterFns) {
  315. if (!filterFn(event)) {
  316. return;
  317. }
  318. }
  319. if (this instanceof EmailEventHandlerWithAsyncData) {
  320. try {
  321. (event as EventWithAsyncData<Event, any>).data = await this._loadDataFn({
  322. event,
  323. injector,
  324. });
  325. } catch (err: unknown) {
  326. if (err instanceof Error) {
  327. Logger.error(err.message, loggerCtx, err.stack);
  328. } else {
  329. Logger.error(String(err), loggerCtx);
  330. }
  331. return;
  332. }
  333. }
  334. if (!this.setRecipientFn) {
  335. throw new Error(
  336. `No setRecipientFn has been defined. ` +
  337. `Remember to call ".setRecipient()" when setting up the EmailEventHandler for ${this.type}`,
  338. );
  339. }
  340. if (this.from === undefined) {
  341. throw new Error(
  342. `No from field has been defined. ` +
  343. `Remember to call ".setFrom()" when setting up the EmailEventHandler for ${this.type}`,
  344. );
  345. }
  346. const { ctx } = event;
  347. const languageCode = this.setLanguageCodeFn?.(event) || ctx.languageCode;
  348. const configuration = this.getBestConfiguration(ctx.channel.code, languageCode);
  349. const subject = configuration ? configuration.subject : this.defaultSubject;
  350. if (subject == null) {
  351. throw new Error(
  352. `No subject field has been defined. ` +
  353. `Remember to call ".setSubject()" when setting up the EmailEventHandler for ${this.type}`,
  354. );
  355. }
  356. const recipient = this.setRecipientFn(event);
  357. const templateVars = this.setTemplateVarsFn ? this.setTemplateVarsFn(event, globals) : {};
  358. let attachmentsArray: EmailAttachment[] = [];
  359. try {
  360. attachmentsArray = (await this.setAttachmentsFn?.(event)) ?? [];
  361. } catch (e: any) {
  362. Logger.error(e, loggerCtx, e.stack);
  363. }
  364. const attachments = await serializeAttachments(attachmentsArray);
  365. const optionalAddressFields = (await this.setOptionalAddressFieldsFn?.(event)) ?? {};
  366. return {
  367. ctx: event.ctx.serialize(),
  368. type: this.type,
  369. recipient,
  370. from: this.from,
  371. templateVars: { ...globals, ...templateVars },
  372. subject,
  373. templateFile: configuration ? configuration.templateFile : 'body.hbs',
  374. attachments,
  375. ...optionalAddressFields,
  376. };
  377. }
  378. /**
  379. * @description
  380. * Optionally define a mock Event which is used by the dev mode mailbox app for generating mock emails
  381. * from this handler, which is useful when developing the email templates.
  382. */
  383. setMockEvent(event: Omit<Event, 'ctx' | 'data'>): EmailEventHandler<T, Event> {
  384. this._mockEvent = event;
  385. return this;
  386. }
  387. private getBestConfiguration(
  388. channelCode: string,
  389. languageCode: LanguageCode,
  390. ): EmailTemplateConfig | undefined {
  391. if (this.configurations.length === 0) {
  392. return;
  393. }
  394. const exactMatch = this.configurations.find(c => {
  395. return (
  396. (c.channelCode === channelCode || c.channelCode === 'default') &&
  397. c.languageCode === languageCode
  398. );
  399. });
  400. if (exactMatch) {
  401. return exactMatch;
  402. }
  403. const channelMatch = this.configurations.find(
  404. c => c.channelCode === channelCode && c.languageCode === 'default',
  405. );
  406. if (channelMatch) {
  407. return channelMatch;
  408. }
  409. return;
  410. }
  411. }
  412. /**
  413. * @description
  414. * Identical to the {@link EmailEventHandler} but with a `data` property added to the `event` based on the result
  415. * of the `.loadData()` function.
  416. *
  417. * @docsCategory core plugins/EmailPlugin
  418. */
  419. export class EmailEventHandlerWithAsyncData<
  420. Data,
  421. T extends string = string,
  422. InputEvent extends EventWithContext = EventWithContext,
  423. Event extends EventWithAsyncData<InputEvent, Data> = EventWithAsyncData<InputEvent, Data>,
  424. > extends EmailEventHandler<T, Event> {
  425. constructor(
  426. public _loadDataFn: LoadDataFn<InputEvent, Data>,
  427. listener: EmailEventListener<T>,
  428. event: Type<InputEvent>,
  429. ) {
  430. super(listener, event as any);
  431. }
  432. }