event-handler.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  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. * ```TypeScript
  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. * ```TypeScript {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 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. * ```TypeScript
  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. addTemplate(config: EmailTemplateConfig): EmailEventHandler<T, Event> {
  259. this.configurations.push(config);
  260. return this;
  261. }
  262. /**
  263. * @description
  264. * Allows data to be loaded asynchronously which can then be used as template variables.
  265. * The `loadDataFn` has access to the event, the TypeORM `Connection` object, and an
  266. * `inject()` function which can be used to inject any of the providers exported
  267. * by the {@link PluginCommonModule}. The return value of the `loadDataFn` will be
  268. * added to the `event` as the `data` property.
  269. *
  270. * @example
  271. * ```TypeScript
  272. * new EmailEventListener('order-confirmation')
  273. * .on(OrderStateTransitionEvent)
  274. * .filter(event => event.toState === 'PaymentSettled' && !!event.order.customer)
  275. * .loadData(({ event, injector }) => {
  276. * const orderService = injector.get(OrderService);
  277. * return orderService.getOrderPayments(event.order.id);
  278. * })
  279. * .setTemplateVars(event => ({
  280. * order: event.order,
  281. * payments: event.data,
  282. * }));
  283. * ```
  284. */
  285. loadData<R>(
  286. loadDataFn: LoadDataFn<Event, R>,
  287. ): EmailEventHandlerWithAsyncData<R, T, Event, EventWithAsyncData<Event, R>> {
  288. const asyncHandler = new EmailEventHandlerWithAsyncData(loadDataFn, this.listener, this.event);
  289. asyncHandler.setRecipientFn = this.setRecipientFn;
  290. asyncHandler.setTemplateVarsFn = this.setTemplateVarsFn;
  291. asyncHandler.setAttachmentsFn = this.setAttachmentsFn;
  292. asyncHandler.setOptionalAddressFieldsFn = this.setOptionalAddressFieldsFn;
  293. asyncHandler.filterFns = this.filterFns;
  294. asyncHandler.configurations = this.configurations;
  295. asyncHandler.defaultSubject = this.defaultSubject;
  296. asyncHandler.from = this.from;
  297. asyncHandler._mockEvent = this._mockEvent as any;
  298. return asyncHandler;
  299. }
  300. /**
  301. * @description
  302. * Used internally by the EmailPlugin to handle incoming events.
  303. *
  304. * @internal
  305. */
  306. async handle(
  307. event: Event,
  308. globals: { [key: string]: any } = {},
  309. injector: Injector,
  310. ): Promise<IntermediateEmailDetails | undefined> {
  311. for (const filterFn of this.filterFns) {
  312. if (!filterFn(event)) {
  313. return;
  314. }
  315. }
  316. if (this instanceof EmailEventHandlerWithAsyncData) {
  317. try {
  318. (event as EventWithAsyncData<Event, any>).data = await this._loadDataFn({
  319. event,
  320. injector,
  321. });
  322. } catch (err: unknown) {
  323. if (err instanceof Error) {
  324. Logger.error(err.message, loggerCtx, err.stack);
  325. } else {
  326. Logger.error(String(err), loggerCtx);
  327. }
  328. return;
  329. }
  330. }
  331. if (!this.setRecipientFn) {
  332. throw new Error(
  333. `No setRecipientFn has been defined. ` +
  334. `Remember to call ".setRecipient()" when setting up the EmailEventHandler for ${this.type}`,
  335. );
  336. }
  337. if (this.from === undefined) {
  338. throw new Error(
  339. `No from field has been defined. ` +
  340. `Remember to call ".setFrom()" when setting up the EmailEventHandler for ${this.type}`,
  341. );
  342. }
  343. const { ctx } = event;
  344. const languageCode = this.setLanguageCodeFn?.(event) || ctx.languageCode;
  345. const configuration = this.getBestConfiguration(ctx.channel.code, languageCode);
  346. const subject = configuration ? configuration.subject : this.defaultSubject;
  347. if (subject == null) {
  348. throw new Error(
  349. `No subject field has been defined. ` +
  350. `Remember to call ".setSubject()" when setting up the EmailEventHandler for ${this.type}`,
  351. );
  352. }
  353. const recipient = this.setRecipientFn(event);
  354. const templateVars = this.setTemplateVarsFn ? this.setTemplateVarsFn(event, globals) : {};
  355. let attachmentsArray: EmailAttachment[] = [];
  356. try {
  357. attachmentsArray = (await this.setAttachmentsFn?.(event)) ?? [];
  358. } catch (e) {
  359. Logger.error(e, loggerCtx, e.stack);
  360. }
  361. const attachments = await serializeAttachments(attachmentsArray);
  362. const optionalAddressFields = (await this.setOptionalAddressFieldsFn?.(event)) ?? {};
  363. return {
  364. type: this.type,
  365. recipient,
  366. from: this.from,
  367. templateVars: { ...globals, ...templateVars },
  368. subject,
  369. templateFile: configuration ? configuration.templateFile : 'body.hbs',
  370. attachments,
  371. ...optionalAddressFields,
  372. };
  373. }
  374. /**
  375. * @description
  376. * Optionally define a mock Event which is used by the dev mode mailbox app for generating mock emails
  377. * from this handler, which is useful when developing the email templates.
  378. */
  379. setMockEvent(event: Omit<Event, 'ctx' | 'data'>): EmailEventHandler<T, Event> {
  380. this._mockEvent = event;
  381. return this;
  382. }
  383. private getBestConfiguration(
  384. channelCode: string,
  385. languageCode: LanguageCode,
  386. ): EmailTemplateConfig | undefined {
  387. if (this.configurations.length === 0) {
  388. return;
  389. }
  390. const exactMatch = this.configurations.find(c => {
  391. return (
  392. (c.channelCode === channelCode || c.channelCode === 'default') &&
  393. c.languageCode === languageCode
  394. );
  395. });
  396. if (exactMatch) {
  397. return exactMatch;
  398. }
  399. const channelMatch = this.configurations.find(
  400. c => c.channelCode === channelCode && c.languageCode === 'default',
  401. );
  402. if (channelMatch) {
  403. return channelMatch;
  404. }
  405. return;
  406. }
  407. }
  408. /**
  409. * @description
  410. * Identical to the {@link EmailEventHandler} but with a `data` property added to the `event` based on the result
  411. * of the `.loadData()` function.
  412. *
  413. * @docsCategory EmailPlugin
  414. */
  415. export class EmailEventHandlerWithAsyncData<
  416. Data,
  417. T extends string = string,
  418. InputEvent extends EventWithContext = EventWithContext,
  419. Event extends EventWithAsyncData<InputEvent, Data> = EventWithAsyncData<InputEvent, Data>,
  420. > extends EmailEventHandler<T, Event> {
  421. constructor(
  422. public _loadDataFn: LoadDataFn<InputEvent, Data>,
  423. listener: EmailEventListener<T>,
  424. event: Type<InputEvent>,
  425. ) {
  426. super(listener, event as any);
  427. }
  428. }