event-handler.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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. EmailTemplateConfig,
  9. EventWithAsyncData,
  10. EventWithContext,
  11. IntermediateEmailDetails,
  12. LoadDataFn,
  13. SetAttachmentsFn,
  14. SetTemplateVarsFn,
  15. } from './types';
  16. /**
  17. * @description
  18. * The EmailEventHandler defines how the EmailPlugin will respond to a given event.
  19. *
  20. * A handler is created by creating a new {@link EmailEventListener} and calling the `.on()` method
  21. * to specify which event to respond to.
  22. *
  23. * @example
  24. * ```ts
  25. * const confirmationHandler = new EmailEventListener('order-confirmation')
  26. * .on(OrderStateTransitionEvent)
  27. * .filter(event => event.toState === 'PaymentSettled')
  28. * .setRecipient(event => event.order.customer.emailAddress)
  29. * .setSubject(`Order confirmation for #{{ order.code }}`)
  30. * .setTemplateVars(event => ({ order: event.order }));
  31. * ```
  32. *
  33. * This example creates a handler which listens for the `OrderStateTransitionEvent` and if the Order has
  34. * transitioned to the `'PaymentSettled'` state, it will generate and send an email.
  35. *
  36. * ## Handling other languages
  37. *
  38. * By default, the handler will respond to all events on all channels and use the same subject ("Order confirmation for #12345" above)
  39. * and body template. Where the server is intended to support multiple languages, the `.addTemplate()` method may be used
  40. * to defined the subject and body template for specific language and channel combinations.
  41. *
  42. * @example
  43. * ```ts
  44. * const extendedConfirmationHandler = confirmationHandler
  45. * .addTemplate({
  46. * channelCode: 'default',
  47. * languageCode: LanguageCode.de,
  48. * templateFile: 'body.de.hbs',
  49. * subject: 'Bestellbestätigung für #{{ order.code }}',
  50. * })
  51. * ```
  52. *
  53. * @docsCategory EmailPlugin
  54. */
  55. export class EmailEventHandler<T extends string = string, Event extends EventWithContext = EventWithContext> {
  56. private setRecipientFn: (event: Event) => string;
  57. private setTemplateVarsFn: SetTemplateVarsFn<Event>;
  58. private setAttachmentsFn?: SetAttachmentsFn<Event>;
  59. private filterFns: Array<(event: Event) => boolean> = [];
  60. private configurations: EmailTemplateConfig[] = [];
  61. private defaultSubject: string;
  62. private from: string;
  63. private _mockEvent: Omit<Event, 'ctx' | 'data'> | undefined;
  64. constructor(public listener: EmailEventListener<T>, public event: Type<Event>) {}
  65. /** @internal */
  66. get type(): T {
  67. return this.listener.type;
  68. }
  69. /** @internal */
  70. get mockEvent(): Omit<Event, 'ctx' | 'data'> | undefined {
  71. return this._mockEvent;
  72. }
  73. /**
  74. * @description
  75. * Defines a predicate function which is used to determine whether the event will trigger an email.
  76. * Multiple filter functions may be defined.
  77. */
  78. filter(filterFn: (event: Event) => boolean): EmailEventHandler<T, Event> {
  79. this.filterFns.push(filterFn);
  80. return this;
  81. }
  82. /**
  83. * @description
  84. * A function which defines how the recipient email address should be extracted from the incoming event.
  85. */
  86. setRecipient(setRecipientFn: (event: Event) => string): EmailEventHandler<T, Event> {
  87. this.setRecipientFn = setRecipientFn;
  88. return this;
  89. }
  90. /**
  91. * @description
  92. * A function which returns an object hash of variables which will be made available to the Handlebars template
  93. * and subject line for interpolation.
  94. */
  95. setTemplateVars(templateVarsFn: SetTemplateVarsFn<Event>): EmailEventHandler<T, Event> {
  96. this.setTemplateVarsFn = templateVarsFn;
  97. return this;
  98. }
  99. /**
  100. * @description
  101. * Sets the default subject of the email. The subject string may use Handlebars variables defined by the
  102. * setTemplateVars() method.
  103. */
  104. setSubject(defaultSubject: string): EmailEventHandler<T, Event> {
  105. this.defaultSubject = defaultSubject;
  106. return this;
  107. }
  108. /**
  109. * @description
  110. * Sets the default from field of the email. The from string may use Handlebars variables defined by the
  111. * setTemplateVars() method.
  112. */
  113. setFrom(from: string): EmailEventHandler<T, Event> {
  114. this.from = from;
  115. return this;
  116. }
  117. /**
  118. * @description
  119. * Defines one or more files to be attached to the email. An attachment _must_ specify
  120. * a `path` property which can be either a file system path _or_ a URL to the file.
  121. *
  122. * @example
  123. * ```TypeScript
  124. * const testAttachmentHandler = new EmailEventListener('activate-voucher')
  125. * .on(ActivateVoucherEvent)
  126. * // ... omitted some steps for brevity
  127. * .setAttachments(async (event) => {
  128. * const { imageUrl, voucherCode } = await getVoucherDataForUser(event.user.id);
  129. * return [
  130. * {
  131. * filename: `voucher-${voucherCode}.jpg`,
  132. * path: imageUrl,
  133. * },
  134. * ];
  135. * });
  136. * ```
  137. */
  138. setAttachments(setAttachmentsFn: SetAttachmentsFn<Event>) {
  139. this.setAttachmentsFn = setAttachmentsFn;
  140. return this;
  141. }
  142. /**
  143. * @description
  144. * Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific
  145. * templates for channels or languageCodes other than the default.
  146. */
  147. addTemplate(config: EmailTemplateConfig): EmailEventHandler<T, Event> {
  148. this.configurations.push(config);
  149. return this;
  150. }
  151. /**
  152. * @description
  153. * Allows data to be loaded asynchronously which can then be used as template variables.
  154. * The `loadDataFn` has access to the event, the TypeORM `Connection` object, and an
  155. * `inject()` function which can be used to inject any of the providers exported
  156. * by the {@link PluginCommonModule}. The return value of the `loadDataFn` will be
  157. * added to the `event` as the `data` property.
  158. *
  159. * @example
  160. * ```TypeScript
  161. * new EmailEventListener('order-confirmation')
  162. * .on(OrderStateTransitionEvent)
  163. * .filter(event => event.toState === 'PaymentSettled' && !!event.order.customer)
  164. * .loadData(({ event, inject}) => {
  165. * const orderService = inject(OrderService);
  166. * return orderService.getOrderPayments(event.order.id);
  167. * })
  168. * .setTemplateVars(event => ({
  169. * order: event.order,
  170. * payments: event.data,
  171. * }));
  172. * ```
  173. */
  174. loadData<R>(
  175. loadDataFn: LoadDataFn<Event, R>,
  176. ): EmailEventHandlerWithAsyncData<R, T, Event, EventWithAsyncData<Event, R>> {
  177. const asyncHandler = new EmailEventHandlerWithAsyncData(loadDataFn, this.listener, this.event);
  178. asyncHandler.setRecipientFn = this.setRecipientFn;
  179. asyncHandler.setTemplateVarsFn = this.setTemplateVarsFn;
  180. asyncHandler.setAttachmentsFn = this.setAttachmentsFn;
  181. asyncHandler.filterFns = this.filterFns;
  182. asyncHandler.configurations = this.configurations;
  183. asyncHandler.defaultSubject = this.defaultSubject;
  184. asyncHandler.from = this.from;
  185. asyncHandler._mockEvent = this._mockEvent as any;
  186. return asyncHandler;
  187. }
  188. /**
  189. * @description
  190. * Used internally by the EmailPlugin to handle incoming events.
  191. *
  192. * @internal
  193. */
  194. async handle(
  195. event: Event,
  196. globals: { [key: string]: any } = {},
  197. injector: Injector,
  198. ): Promise<IntermediateEmailDetails | undefined> {
  199. for (const filterFn of this.filterFns) {
  200. if (!filterFn(event)) {
  201. return;
  202. }
  203. }
  204. if (this instanceof EmailEventHandlerWithAsyncData) {
  205. try {
  206. (event as EventWithAsyncData<Event, any>).data = await this._loadDataFn({
  207. event,
  208. injector,
  209. });
  210. } catch (err: unknown) {
  211. if (err instanceof Error) {
  212. Logger.error(err.message, loggerCtx, err.stack);
  213. } else {
  214. Logger.error(String(err), loggerCtx);
  215. }
  216. return;
  217. }
  218. }
  219. if (!this.setRecipientFn) {
  220. throw new Error(
  221. `No setRecipientFn has been defined. ` +
  222. `Remember to call ".setRecipient()" when setting up the EmailEventHandler for ${this.type}`,
  223. );
  224. }
  225. if (this.from === undefined) {
  226. throw new Error(
  227. `No from field has been defined. ` +
  228. `Remember to call ".setFrom()" when setting up the EmailEventHandler for ${this.type}`,
  229. );
  230. }
  231. const { ctx } = event;
  232. const configuration = this.getBestConfiguration(ctx.channel.code, ctx.languageCode);
  233. const subject = configuration ? configuration.subject : this.defaultSubject;
  234. if (subject == null) {
  235. throw new Error(
  236. `No subject field has been defined. ` +
  237. `Remember to call ".setSubject()" when setting up the EmailEventHandler for ${this.type}`,
  238. );
  239. }
  240. const recipient = this.setRecipientFn(event);
  241. const templateVars = this.setTemplateVarsFn ? this.setTemplateVarsFn(event, globals) : {};
  242. const attachments = await serializeAttachments((await this.setAttachmentsFn?.(event)) ?? []);
  243. return {
  244. type: this.type,
  245. recipient,
  246. from: this.from,
  247. templateVars: { ...globals, ...templateVars },
  248. subject,
  249. templateFile: configuration ? configuration.templateFile : 'body.hbs',
  250. attachments,
  251. };
  252. }
  253. /**
  254. * @description
  255. * Optionally define a mock Event which is used by the dev mode mailbox app for generating mock emails
  256. * from this handler, which is useful when developing the email templates.
  257. */
  258. setMockEvent(event: Omit<Event, 'ctx' | 'data'>): EmailEventHandler<T, Event> {
  259. this._mockEvent = event;
  260. return this;
  261. }
  262. private getBestConfiguration(
  263. channelCode: string,
  264. languageCode: LanguageCode,
  265. ): EmailTemplateConfig | undefined {
  266. if (this.configurations.length === 0) {
  267. return;
  268. }
  269. const exactMatch = this.configurations.find(c => {
  270. return (
  271. (c.channelCode === channelCode || c.channelCode === 'default') &&
  272. c.languageCode === languageCode
  273. );
  274. });
  275. if (exactMatch) {
  276. return exactMatch;
  277. }
  278. const channelMatch = this.configurations.find(
  279. c => c.channelCode === channelCode && c.languageCode === 'default',
  280. );
  281. if (channelMatch) {
  282. return channelMatch;
  283. }
  284. return;
  285. }
  286. }
  287. /**
  288. * @description
  289. * Identical to the {@link EmailEventHandler} but with a `data` property added to the `event` based on the result
  290. * of the `.loadData()` function.
  291. *
  292. * @docsCategory EmailPlugin
  293. */
  294. export class EmailEventHandlerWithAsyncData<
  295. Data,
  296. T extends string = string,
  297. InputEvent extends EventWithContext = EventWithContext,
  298. Event extends EventWithAsyncData<InputEvent, Data> = EventWithAsyncData<InputEvent, Data>
  299. > extends EmailEventHandler<T, Event> {
  300. constructor(
  301. public _loadDataFn: LoadDataFn<InputEvent, Data>,
  302. listener: EmailEventListener<T>,
  303. event: Type<InputEvent>,
  304. ) {
  305. super(listener, event as any);
  306. }
  307. }