event-handler.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  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 setTemplateVarsFn: SetTemplateVarsFn<Event>;
  132. private setAttachmentsFn?: SetAttachmentsFn<Event>;
  133. private setOptionalAddressFieldsFn?: SetOptionalAddressFieldsFn<Event>;
  134. private filterFns: Array<(event: Event) => boolean> = [];
  135. private configurations: EmailTemplateConfig[] = [];
  136. private defaultSubject: string;
  137. private from: string;
  138. private optionalAddressFields: {
  139. cc?: string;
  140. bcc?: string;
  141. };
  142. private _mockEvent: Omit<Event, 'ctx' | 'data'> | undefined;
  143. constructor(public listener: EmailEventListener<T>, public event: Type<Event>) {}
  144. /** @internal */
  145. get type(): T {
  146. return this.listener.type;
  147. }
  148. /** @internal */
  149. get mockEvent(): Omit<Event, 'ctx' | 'data'> | undefined {
  150. return this._mockEvent;
  151. }
  152. /**
  153. * @description
  154. * Defines a predicate function which is used to determine whether the event will trigger an email.
  155. * Multiple filter functions may be defined.
  156. */
  157. filter(filterFn: (event: Event) => boolean): EmailEventHandler<T, Event> {
  158. this.filterFns.push(filterFn);
  159. return this;
  160. }
  161. /**
  162. * @description
  163. * A function which defines how the recipient email address should be extracted from the incoming event.
  164. *
  165. * The recipient can be a plain email address: `'foobar@example.com'`
  166. * Or with a formatted name (includes unicode support): `'Ноде Майлер <foobar@example.com>'`
  167. * Or a comma-separated list of addresses: `'foobar@example.com, "Ноде Майлер" <bar@example.com>'`
  168. */
  169. setRecipient(setRecipientFn: (event: Event) => string): EmailEventHandler<T, Event> {
  170. this.setRecipientFn = setRecipientFn;
  171. return this;
  172. }
  173. /**
  174. * @description
  175. * A function which returns an object hash of variables which will be made available to the Handlebars template
  176. * and subject line for interpolation.
  177. */
  178. setTemplateVars(templateVarsFn: SetTemplateVarsFn<Event>): EmailEventHandler<T, Event> {
  179. this.setTemplateVarsFn = templateVarsFn;
  180. return this;
  181. }
  182. /**
  183. * @description
  184. * Sets the default subject of the email. The subject string may use Handlebars variables defined by the
  185. * setTemplateVars() method.
  186. */
  187. setSubject(defaultSubject: string): EmailEventHandler<T, Event> {
  188. this.defaultSubject = defaultSubject;
  189. return this;
  190. }
  191. /**
  192. * @description
  193. * Sets the default from field of the email. The from string may use Handlebars variables defined by the
  194. * setTemplateVars() method.
  195. */
  196. setFrom(from: string): EmailEventHandler<T, Event> {
  197. this.from = from;
  198. return this;
  199. }
  200. /**
  201. * @description
  202. * A function which allows {@link OptionalAddressFields} to be specified such as "cc" and "bcc".
  203. *
  204. * @since 1.1.0
  205. */
  206. setOptionalAddressFields(optionalAddressFieldsFn: SetOptionalAddressFieldsFn<Event>) {
  207. this.setOptionalAddressFieldsFn = optionalAddressFieldsFn;
  208. return this;
  209. }
  210. /**
  211. * @description
  212. * Defines one or more files to be attached to the email. An attachment can be specified
  213. * as either a `path` (to a file or URL) or as `content` which can be a string, Buffer or Stream.
  214. *
  215. * **Note:** When using the `content` to pass a Buffer or Stream, the raw data will get serialized
  216. * into the job queue. For this reason the total size of all attachments passed as `content` should kept to
  217. * **less than ~50k**. If the attachments are greater than that limit, a warning will be logged and
  218. * errors may result if using the DefaultJobQueuePlugin with certain DBs such as MySQL/MariaDB.
  219. *
  220. * @example
  221. * ```TypeScript
  222. * const testAttachmentHandler = new EmailEventListener('activate-voucher')
  223. * .on(ActivateVoucherEvent)
  224. * // ... omitted some steps for brevity
  225. * .setAttachments(async (event) => {
  226. * const { imageUrl, voucherCode } = await getVoucherDataForUser(event.user.id);
  227. * return [
  228. * {
  229. * filename: `voucher-${voucherCode}.jpg`,
  230. * path: imageUrl,
  231. * },
  232. * ];
  233. * });
  234. * ```
  235. */
  236. setAttachments(setAttachmentsFn: SetAttachmentsFn<Event>) {
  237. this.setAttachmentsFn = setAttachmentsFn;
  238. return this;
  239. }
  240. /**
  241. * @description
  242. * Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific
  243. * templates for channels or languageCodes other than the default.
  244. */
  245. addTemplate(config: EmailTemplateConfig): EmailEventHandler<T, Event> {
  246. this.configurations.push(config);
  247. return this;
  248. }
  249. /**
  250. * @description
  251. * Allows data to be loaded asynchronously which can then be used as template variables.
  252. * The `loadDataFn` has access to the event, the TypeORM `Connection` object, and an
  253. * `inject()` function which can be used to inject any of the providers exported
  254. * by the {@link PluginCommonModule}. The return value of the `loadDataFn` will be
  255. * added to the `event` as the `data` property.
  256. *
  257. * @example
  258. * ```TypeScript
  259. * new EmailEventListener('order-confirmation')
  260. * .on(OrderStateTransitionEvent)
  261. * .filter(event => event.toState === 'PaymentSettled' && !!event.order.customer)
  262. * .loadData(({ event, injector }) => {
  263. * const orderService = injector.get(OrderService);
  264. * return orderService.getOrderPayments(event.order.id);
  265. * })
  266. * .setTemplateVars(event => ({
  267. * order: event.order,
  268. * payments: event.data,
  269. * }));
  270. * ```
  271. */
  272. loadData<R>(
  273. loadDataFn: LoadDataFn<Event, R>,
  274. ): EmailEventHandlerWithAsyncData<R, T, Event, EventWithAsyncData<Event, R>> {
  275. const asyncHandler = new EmailEventHandlerWithAsyncData(loadDataFn, this.listener, this.event);
  276. asyncHandler.setRecipientFn = this.setRecipientFn;
  277. asyncHandler.setTemplateVarsFn = this.setTemplateVarsFn;
  278. asyncHandler.setAttachmentsFn = this.setAttachmentsFn;
  279. asyncHandler.setOptionalAddressFieldsFn = this.setOptionalAddressFieldsFn;
  280. asyncHandler.filterFns = this.filterFns;
  281. asyncHandler.configurations = this.configurations;
  282. asyncHandler.defaultSubject = this.defaultSubject;
  283. asyncHandler.from = this.from;
  284. asyncHandler._mockEvent = this._mockEvent as any;
  285. return asyncHandler;
  286. }
  287. /**
  288. * @description
  289. * Used internally by the EmailPlugin to handle incoming events.
  290. *
  291. * @internal
  292. */
  293. async handle(
  294. event: Event,
  295. globals: { [key: string]: any } = {},
  296. injector: Injector,
  297. ): Promise<IntermediateEmailDetails | undefined> {
  298. for (const filterFn of this.filterFns) {
  299. if (!filterFn(event)) {
  300. return;
  301. }
  302. }
  303. if (this instanceof EmailEventHandlerWithAsyncData) {
  304. try {
  305. (event as EventWithAsyncData<Event, any>).data = await this._loadDataFn({
  306. event,
  307. injector,
  308. });
  309. } catch (err: unknown) {
  310. if (err instanceof Error) {
  311. Logger.error(err.message, loggerCtx, err.stack);
  312. } else {
  313. Logger.error(String(err), loggerCtx);
  314. }
  315. return;
  316. }
  317. }
  318. if (!this.setRecipientFn) {
  319. throw new Error(
  320. `No setRecipientFn has been defined. ` +
  321. `Remember to call ".setRecipient()" when setting up the EmailEventHandler for ${this.type}`,
  322. );
  323. }
  324. if (this.from === undefined) {
  325. throw new Error(
  326. `No from field has been defined. ` +
  327. `Remember to call ".setFrom()" when setting up the EmailEventHandler for ${this.type}`,
  328. );
  329. }
  330. const { ctx } = event;
  331. const configuration = this.getBestConfiguration(ctx.channel.code, ctx.languageCode);
  332. const subject = configuration ? configuration.subject : this.defaultSubject;
  333. if (subject == null) {
  334. throw new Error(
  335. `No subject field has been defined. ` +
  336. `Remember to call ".setSubject()" when setting up the EmailEventHandler for ${this.type}`,
  337. );
  338. }
  339. const recipient = this.setRecipientFn(event);
  340. const templateVars = this.setTemplateVarsFn ? this.setTemplateVarsFn(event, globals) : {};
  341. let attachmentsArray: EmailAttachment[] = [];
  342. try {
  343. attachmentsArray = (await this.setAttachmentsFn?.(event)) ?? [];
  344. } catch (e) {
  345. Logger.error(e, loggerCtx, e.stack);
  346. }
  347. const attachments = await serializeAttachments(attachmentsArray);
  348. const optionalAddressFields = (await this.setOptionalAddressFieldsFn?.(event)) ?? {};
  349. return {
  350. type: this.type,
  351. recipient,
  352. from: this.from,
  353. templateVars: { ...globals, ...templateVars },
  354. subject,
  355. templateFile: configuration ? configuration.templateFile : 'body.hbs',
  356. attachments,
  357. ...optionalAddressFields,
  358. };
  359. }
  360. /**
  361. * @description
  362. * Optionally define a mock Event which is used by the dev mode mailbox app for generating mock emails
  363. * from this handler, which is useful when developing the email templates.
  364. */
  365. setMockEvent(event: Omit<Event, 'ctx' | 'data'>): EmailEventHandler<T, Event> {
  366. this._mockEvent = event;
  367. return this;
  368. }
  369. private getBestConfiguration(
  370. channelCode: string,
  371. languageCode: LanguageCode,
  372. ): EmailTemplateConfig | undefined {
  373. if (this.configurations.length === 0) {
  374. return;
  375. }
  376. const exactMatch = this.configurations.find(c => {
  377. return (
  378. (c.channelCode === channelCode || c.channelCode === 'default') &&
  379. c.languageCode === languageCode
  380. );
  381. });
  382. if (exactMatch) {
  383. return exactMatch;
  384. }
  385. const channelMatch = this.configurations.find(
  386. c => c.channelCode === channelCode && c.languageCode === 'default',
  387. );
  388. if (channelMatch) {
  389. return channelMatch;
  390. }
  391. return;
  392. }
  393. }
  394. /**
  395. * @description
  396. * Identical to the {@link EmailEventHandler} but with a `data` property added to the `event` based on the result
  397. * of the `.loadData()` function.
  398. *
  399. * @docsCategory EmailPlugin
  400. */
  401. export class EmailEventHandlerWithAsyncData<
  402. Data,
  403. T extends string = string,
  404. InputEvent extends EventWithContext = EventWithContext,
  405. Event extends EventWithAsyncData<InputEvent, Data> = EventWithAsyncData<InputEvent, Data>,
  406. > extends EmailEventHandler<T, Event> {
  407. constructor(
  408. public _loadDataFn: LoadDataFn<InputEvent, Data>,
  409. listener: EmailEventListener<T>,
  410. event: Type<InputEvent>,
  411. ) {
  412. super(listener, event as any);
  413. }
  414. }