event-handler.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  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. * ## Handling other languages
  39. *
  40. * By default, the handler will respond to all events on all channels and use the same subject ("Order confirmation for #12345" above)
  41. * and body template. Where the server is intended to support multiple languages, the `.addTemplate()` method may be used
  42. * to defined the subject and body template for specific language and channel combinations.
  43. *
  44. * @example
  45. * ```ts
  46. * const extendedConfirmationHandler = confirmationHandler
  47. * .addTemplate({
  48. * channelCode: 'default',
  49. * languageCode: LanguageCode.de,
  50. * templateFile: 'body.de.hbs',
  51. * subject: 'Bestellbestätigung für #{{ order.code }}',
  52. * })
  53. * ```
  54. *
  55. * @docsCategory EmailPlugin
  56. */
  57. export class EmailEventHandler<T extends string = string, Event extends EventWithContext = EventWithContext> {
  58. private setRecipientFn: (event: Event) => string;
  59. private setTemplateVarsFn: SetTemplateVarsFn<Event>;
  60. private setAttachmentsFn?: SetAttachmentsFn<Event>;
  61. private setOptionalAddressFieldsFn?: SetOptionalAddressFieldsFn<Event>;
  62. private filterFns: Array<(event: Event) => boolean> = [];
  63. private configurations: EmailTemplateConfig[] = [];
  64. private defaultSubject: string;
  65. private from: string;
  66. private optionalAddressFields: {
  67. cc?: string;
  68. bcc?: string;
  69. };
  70. private _mockEvent: Omit<Event, 'ctx' | 'data'> | undefined;
  71. constructor(public listener: EmailEventListener<T>, public event: Type<Event>) {}
  72. /** @internal */
  73. get type(): T {
  74. return this.listener.type;
  75. }
  76. /** @internal */
  77. get mockEvent(): Omit<Event, 'ctx' | 'data'> | undefined {
  78. return this._mockEvent;
  79. }
  80. /**
  81. * @description
  82. * Defines a predicate function which is used to determine whether the event will trigger an email.
  83. * Multiple filter functions may be defined.
  84. */
  85. filter(filterFn: (event: Event) => boolean): EmailEventHandler<T, Event> {
  86. this.filterFns.push(filterFn);
  87. return this;
  88. }
  89. /**
  90. * @description
  91. * A function which defines how the recipient email address should be extracted from the incoming event.
  92. *
  93. * The recipient can be a plain email address: `'foobar@example.com'`
  94. * Or with a formatted name (includes unicode support): `'Ноде Майлер <foobar@example.com>'`
  95. * Or a comma-separated list of addresses: `'foobar@example.com, "Ноде Майлер" <bar@example.com>'`
  96. */
  97. setRecipient(setRecipientFn: (event: Event) => string): EmailEventHandler<T, Event> {
  98. this.setRecipientFn = setRecipientFn;
  99. return this;
  100. }
  101. /**
  102. * @description
  103. * A function which returns an object hash of variables which will be made available to the Handlebars template
  104. * and subject line for interpolation.
  105. */
  106. setTemplateVars(templateVarsFn: SetTemplateVarsFn<Event>): EmailEventHandler<T, Event> {
  107. this.setTemplateVarsFn = templateVarsFn;
  108. return this;
  109. }
  110. /**
  111. * @description
  112. * Sets the default subject of the email. The subject string may use Handlebars variables defined by the
  113. * setTemplateVars() method.
  114. */
  115. setSubject(defaultSubject: string): EmailEventHandler<T, Event> {
  116. this.defaultSubject = defaultSubject;
  117. return this;
  118. }
  119. /**
  120. * @description
  121. * Sets the default from field of the email. The from string may use Handlebars variables defined by the
  122. * setTemplateVars() method.
  123. */
  124. setFrom(from: string): EmailEventHandler<T, Event> {
  125. this.from = from;
  126. return this;
  127. }
  128. /**
  129. * @description
  130. * A function which allows {@link OptionalAddressFields} to be specified such as "cc" and "bcc".
  131. *
  132. * @since 1.1.0
  133. */
  134. setOptionalAddressFields(optionalAddressFieldsFn: SetOptionalAddressFieldsFn<Event>) {
  135. this.setOptionalAddressFieldsFn = optionalAddressFieldsFn;
  136. return this;
  137. }
  138. /**
  139. * @description
  140. * Defines one or more files to be attached to the email. An attachment can be specified
  141. * as either a `path` (to a file or URL) or as `content` which can be a string, Buffer or Stream.
  142. *
  143. * **Note:** When using the `content` to pass a Buffer or Stream, the raw data will get serialized
  144. * into the job queue. For this reason the total size of all attachments passed as `content` should kept to
  145. * **less than ~50k**. If the attachments are greater than that limit, a warning will be logged and
  146. * errors may result if using the DefaultJobQueuePlugin with certain DBs such as MySQL/MariaDB.
  147. *
  148. * @example
  149. * ```TypeScript
  150. * const testAttachmentHandler = new EmailEventListener('activate-voucher')
  151. * .on(ActivateVoucherEvent)
  152. * // ... omitted some steps for brevity
  153. * .setAttachments(async (event) => {
  154. * const { imageUrl, voucherCode } = await getVoucherDataForUser(event.user.id);
  155. * return [
  156. * {
  157. * filename: `voucher-${voucherCode}.jpg`,
  158. * path: imageUrl,
  159. * },
  160. * ];
  161. * });
  162. * ```
  163. */
  164. setAttachments(setAttachmentsFn: SetAttachmentsFn<Event>) {
  165. this.setAttachmentsFn = setAttachmentsFn;
  166. return this;
  167. }
  168. /**
  169. * @description
  170. * Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific
  171. * templates for channels or languageCodes other than the default.
  172. */
  173. addTemplate(config: EmailTemplateConfig): EmailEventHandler<T, Event> {
  174. this.configurations.push(config);
  175. return this;
  176. }
  177. /**
  178. * @description
  179. * Allows data to be loaded asynchronously which can then be used as template variables.
  180. * The `loadDataFn` has access to the event, the TypeORM `Connection` object, and an
  181. * `inject()` function which can be used to inject any of the providers exported
  182. * by the {@link PluginCommonModule}. The return value of the `loadDataFn` will be
  183. * added to the `event` as the `data` property.
  184. *
  185. * @example
  186. * ```TypeScript
  187. * new EmailEventListener('order-confirmation')
  188. * .on(OrderStateTransitionEvent)
  189. * .filter(event => event.toState === 'PaymentSettled' && !!event.order.customer)
  190. * .loadData(({ event, injector }) => {
  191. * const orderService = injector.get(OrderService);
  192. * return orderService.getOrderPayments(event.order.id);
  193. * })
  194. * .setTemplateVars(event => ({
  195. * order: event.order,
  196. * payments: event.data,
  197. * }));
  198. * ```
  199. */
  200. loadData<R>(
  201. loadDataFn: LoadDataFn<Event, R>,
  202. ): EmailEventHandlerWithAsyncData<R, T, Event, EventWithAsyncData<Event, R>> {
  203. const asyncHandler = new EmailEventHandlerWithAsyncData(loadDataFn, this.listener, this.event);
  204. asyncHandler.setRecipientFn = this.setRecipientFn;
  205. asyncHandler.setTemplateVarsFn = this.setTemplateVarsFn;
  206. asyncHandler.setAttachmentsFn = this.setAttachmentsFn;
  207. asyncHandler.setOptionalAddressFieldsFn = this.setOptionalAddressFieldsFn;
  208. asyncHandler.filterFns = this.filterFns;
  209. asyncHandler.configurations = this.configurations;
  210. asyncHandler.defaultSubject = this.defaultSubject;
  211. asyncHandler.from = this.from;
  212. asyncHandler._mockEvent = this._mockEvent as any;
  213. return asyncHandler;
  214. }
  215. /**
  216. * @description
  217. * Used internally by the EmailPlugin to handle incoming events.
  218. *
  219. * @internal
  220. */
  221. async handle(
  222. event: Event,
  223. globals: { [key: string]: any } = {},
  224. injector: Injector,
  225. ): Promise<IntermediateEmailDetails | undefined> {
  226. for (const filterFn of this.filterFns) {
  227. if (!filterFn(event)) {
  228. return;
  229. }
  230. }
  231. if (this instanceof EmailEventHandlerWithAsyncData) {
  232. try {
  233. (event as EventWithAsyncData<Event, any>).data = await this._loadDataFn({
  234. event,
  235. injector,
  236. });
  237. } catch (err: unknown) {
  238. if (err instanceof Error) {
  239. Logger.error(err.message, loggerCtx, err.stack);
  240. } else {
  241. Logger.error(String(err), loggerCtx);
  242. }
  243. return;
  244. }
  245. }
  246. if (!this.setRecipientFn) {
  247. throw new Error(
  248. `No setRecipientFn has been defined. ` +
  249. `Remember to call ".setRecipient()" when setting up the EmailEventHandler for ${this.type}`,
  250. );
  251. }
  252. if (this.from === undefined) {
  253. throw new Error(
  254. `No from field has been defined. ` +
  255. `Remember to call ".setFrom()" when setting up the EmailEventHandler for ${this.type}`,
  256. );
  257. }
  258. const { ctx } = event;
  259. const configuration = this.getBestConfiguration(ctx.channel.code, ctx.languageCode);
  260. const subject = configuration ? configuration.subject : this.defaultSubject;
  261. if (subject == null) {
  262. throw new Error(
  263. `No subject field has been defined. ` +
  264. `Remember to call ".setSubject()" when setting up the EmailEventHandler for ${this.type}`,
  265. );
  266. }
  267. const recipient = this.setRecipientFn(event);
  268. const templateVars = this.setTemplateVarsFn ? this.setTemplateVarsFn(event, globals) : {};
  269. let attachmentsArray: EmailAttachment[] = [];
  270. try {
  271. attachmentsArray = (await this.setAttachmentsFn?.(event)) ?? [];
  272. } catch (e) {
  273. Logger.error(e, loggerCtx, e.stack);
  274. }
  275. const attachments = await serializeAttachments(attachmentsArray);
  276. const optionalAddressFields = (await this.setOptionalAddressFieldsFn?.(event)) ?? {};
  277. return {
  278. type: this.type,
  279. recipient,
  280. from: this.from,
  281. templateVars: { ...globals, ...templateVars },
  282. subject,
  283. templateFile: configuration ? configuration.templateFile : 'body.hbs',
  284. attachments,
  285. ...optionalAddressFields,
  286. };
  287. }
  288. /**
  289. * @description
  290. * Optionally define a mock Event which is used by the dev mode mailbox app for generating mock emails
  291. * from this handler, which is useful when developing the email templates.
  292. */
  293. setMockEvent(event: Omit<Event, 'ctx' | 'data'>): EmailEventHandler<T, Event> {
  294. this._mockEvent = event;
  295. return this;
  296. }
  297. private getBestConfiguration(
  298. channelCode: string,
  299. languageCode: LanguageCode,
  300. ): EmailTemplateConfig | undefined {
  301. if (this.configurations.length === 0) {
  302. return;
  303. }
  304. const exactMatch = this.configurations.find(c => {
  305. return (
  306. (c.channelCode === channelCode || c.channelCode === 'default') &&
  307. c.languageCode === languageCode
  308. );
  309. });
  310. if (exactMatch) {
  311. return exactMatch;
  312. }
  313. const channelMatch = this.configurations.find(
  314. c => c.channelCode === channelCode && c.languageCode === 'default',
  315. );
  316. if (channelMatch) {
  317. return channelMatch;
  318. }
  319. return;
  320. }
  321. }
  322. /**
  323. * @description
  324. * Identical to the {@link EmailEventHandler} but with a `data` property added to the `event` based on the result
  325. * of the `.loadData()` function.
  326. *
  327. * @docsCategory EmailPlugin
  328. */
  329. export class EmailEventHandlerWithAsyncData<
  330. Data,
  331. T extends string = string,
  332. InputEvent extends EventWithContext = EventWithContext,
  333. Event extends EventWithAsyncData<InputEvent, Data> = EventWithAsyncData<InputEvent, Data>
  334. > extends EmailEventHandler<T, Event> {
  335. constructor(
  336. public _loadDataFn: LoadDataFn<InputEvent, Data>,
  337. listener: EmailEventListener<T>,
  338. event: Type<InputEvent>,
  339. ) {
  340. super(listener, event as any);
  341. }
  342. }