plugin.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import {
  2. Inject,
  3. MiddlewareConsumer,
  4. NestModule,
  5. OnApplicationBootstrap,
  6. OnApplicationShutdown,
  7. } from '@nestjs/common';
  8. import { ModuleRef } from '@nestjs/core';
  9. import {
  10. EventBus,
  11. Injector,
  12. JobQueue,
  13. JobQueueService,
  14. Logger,
  15. PluginCommonModule,
  16. ProcessContext,
  17. registerPluginStartupMessage,
  18. Type,
  19. VendurePlugin,
  20. } from '@vendure/core';
  21. import { isDevModeOptions, resolveTransportSettings } from './common';
  22. import { EMAIL_PLUGIN_OPTIONS, loggerCtx } from './constants';
  23. import { DevMailbox } from './dev-mailbox';
  24. import { EmailProcessor } from './email-processor';
  25. import { EmailEventHandler, EmailEventHandlerWithAsyncData } from './handler/event-handler';
  26. import { FileBasedTemplateLoader } from './template-loader/file-based-template-loader';
  27. import {
  28. EmailPluginDevModeOptions,
  29. EmailPluginOptions,
  30. EventWithContext,
  31. InitializedEmailPluginOptions,
  32. IntermediateEmailDetails,
  33. } from './types';
  34. /**
  35. * @description
  36. * The EmailPlugin creates and sends transactional emails based on Vendure events. By default, it uses an [MJML](https://mjml.io/)-based
  37. * email generator to generate the email body and [Nodemailer](https://nodemailer.com/about/) to send the emails.
  38. *
  39. * ## High-level description
  40. * Vendure has an internal events system (see {@link EventBus}) that allows plugins to subscribe to events. The EmailPlugin is configured with {@link EmailEventHandler}s
  41. * that listen for a specific event and when it is published, the handler defines which template to use to generate the resulting email.
  42. *
  43. * The plugin comes with a set of default handler for the following events:
  44. * - Order confirmation
  45. * - New customer email address verification
  46. * - Password reset request
  47. * - Email address change request
  48. *
  49. * You can also create your own handler and register them with the plugin - see the {@link EmailEventHandler} docs for more details.
  50. *
  51. * ## Installation
  52. *
  53. * `yarn add \@vendure/email-plugin`
  54. *
  55. * or
  56. *
  57. * `npm install \@vendure/email-plugin`
  58. *
  59. * @example
  60. * ```ts
  61. * import { defaultEmailHandlers, EmailPlugin } from '\@vendure/email-plugin';
  62. *
  63. * const config: VendureConfig = {
  64. * // Add an instance of the plugin to the plugins array
  65. * plugins: [
  66. * EmailPlugin.init({
  67. * handler: defaultEmailHandlers,
  68. * templatePath: path.join(__dirname, 'static/email/templates'),
  69. * transport: {
  70. * type: 'smtp',
  71. * host: 'smtp.example.com',
  72. * port: 587,
  73. * auth: {
  74. * user: 'username',
  75. * pass: 'password',
  76. * }
  77. * },
  78. * }),
  79. * ],
  80. * };
  81. * ```
  82. *
  83. * ## Email templates
  84. *
  85. * In the example above, the plugin has been configured to look in `<app-root>/static/email/templates`
  86. * for the email template files. If you used `\@vendure/create` to create your application, the templates will have
  87. * been copied to that location during setup.
  88. *
  89. * If you are installing the EmailPlugin separately, then you'll need to copy the templates manually from
  90. * `node_modules/\@vendure/email-plugin/templates` to a location of your choice, and then point the `templatePath` config
  91. * property at that directory.
  92. *
  93. * * ### Dynamic Email Templates
  94. * Instead of passing a static value to `templatePath`, use `templateLoader` to define a template path.
  95. * ```ts
  96. * EmailPlugin.init({
  97. * ...,
  98. * templateLoader: new FileBasedTemplateLoader(my/order-confirmation/templates)
  99. * })
  100. * ```
  101. * ## Customizing templates
  102. *
  103. * Emails are generated from templates which use [MJML](https://mjml.io/) syntax. MJML is an open-source HTML-like markup
  104. * language which makes the task of creating responsive email markup simple. By default, the templates are installed to
  105. * `<project root>/vendure/email/templates` and can be freely edited.
  106. *
  107. * Dynamic data such as the recipient's name or order items are specified using [Handlebars syntax](https://handlebarsjs.com/):
  108. *
  109. * ```html
  110. * <p>Dear {{ order.customer.firstName }} {{ order.customer.lastName }},</p>
  111. *
  112. * <p>Thank you for your order!</p>
  113. *
  114. * <mj-table cellpadding="6px">
  115. * {{#each order.lines }}
  116. * <tr class="order-row">
  117. * <td>{{ quantity }} x {{ productVariant.name }}</td>
  118. * <td>{{ productVariant.quantity }}</td>
  119. * <td>{{ formatMoney totalPrice }}</td>
  120. * </tr>
  121. * {{/each}}
  122. * </mj-table>
  123. * ```
  124. *
  125. * ### Handlebars helpers
  126. *
  127. * The following helper functions are available for use in email templates:
  128. *
  129. * * `formatMoney`: Formats an amount of money (which are always stored as integers in Vendure) as a decimal, e.g. `123` => `1.23`
  130. * * `formatDate`: Formats a Date value with the [dateformat](https://www.npmjs.com/package/dateformat) package.
  131. *
  132. * ## Extending the default email handler
  133. *
  134. * The `defaultEmailHandlers` array defines the default handler such as for handling new account registration, order confirmation, password reset
  135. * etc. These defaults can be extended by adding custom templates for languages other than the default, or even completely new types of emails
  136. * which respond to any of the available [VendureEvents](/reference/typescript-api/events/).
  137. *
  138. * A good way to learn how to create your own email handler is to take a look at the
  139. * [source code of the default handler](https://github.com/vendure-ecommerce/vendure/blob/master/packages/email-plugin/src/handler/default-email-handlers.ts).
  140. * New handler are defined in exactly the same way.
  141. *
  142. * It is also possible to modify the default handler:
  143. *
  144. * ```ts
  145. * // Rather than importing `defaultEmailHandlers`, you can
  146. * // import the handler individually
  147. * import {
  148. * orderConfirmationHandler,
  149. * emailVerificationHandler,
  150. * passwordResetHandler,
  151. * emailAddressChangeHandler,
  152. * } from '\@vendure/email-plugin';
  153. * import { CustomerService } from '\@vendure/core';
  154. *
  155. * // This allows you to then customize each handler to your needs.
  156. * // For example, let's set a new subject line to the order confirmation:
  157. * const myOrderConfirmationHandler = orderConfirmationHandler
  158. * .setSubject(`We received your order!`);
  159. *
  160. * // Another example: loading additional data and setting new
  161. * // template variables.
  162. * const myPasswordResetHandler = passwordResetHandler
  163. * .loadData(async ({ event, injector }) => {
  164. * const customerService = injector.get(CustomerService);
  165. * const customer = await customerService.findOneByUserId(event.ctx, event.user.id);
  166. * return { customer };
  167. * })
  168. * .setTemplateVars(event => ({
  169. * passwordResetToken: event.user.getNativeAuthenticationMethod().passwordResetToken,
  170. * customer: event.data.customer,
  171. * }));
  172. *
  173. * // Then you pass the handler to the EmailPlugin init method
  174. * // individually
  175. * EmailPlugin.init({
  176. * handler: [
  177. * myOrderConfirmationHandler,
  178. * myPasswordResetHandler,
  179. * emailVerificationHandler,
  180. * emailAddressChangeHandler,
  181. * ],
  182. * // ...
  183. * }),
  184. * ```
  185. *
  186. * For all available methods of extending a handler, see the {@link EmailEventHandler} documentation.
  187. *
  188. * ## Dynamic SMTP settings
  189. *
  190. * Instead of defining static transport settings, you can also provide a function that dynamically resolves
  191. * channel aware transport settings.
  192. *
  193. * @example
  194. * ```ts
  195. * import { defaultEmailHandlers, EmailPlugin } from '\@vendure/email-plugin';
  196. * import { MyTransportService } from './transport.services.ts';
  197. * const config: VendureConfig = {
  198. * plugins: [
  199. * EmailPlugin.init({
  200. * handler: defaultEmailHandlers,
  201. * templatePath: path.join(__dirname, 'static/email/templates'),
  202. * transport: (injector, ctx) => {
  203. * if (ctx) {
  204. * return injector.get(MyTransportService).getSettings(ctx);
  205. * } else {
  206. * return {
  207. * type: 'smtp',
  208. * host: 'smtp.example.com',
  209. * // ... etc.
  210. * }
  211. * }
  212. * }
  213. * }),
  214. * ],
  215. * };
  216. * ```
  217. *
  218. * ## Dev mode
  219. *
  220. * For development, the `transport` option can be replaced by `devMode: true`. Doing so configures Vendure to use the
  221. * file transport (See {@link FileTransportOptions}) and outputs emails as rendered HTML files in the directory specified by the
  222. * `outputPath` property.
  223. *
  224. * ```ts
  225. * EmailPlugin.init({
  226. * devMode: true,
  227. * route: 'mailbox',
  228. * handler: defaultEmailHandlers,
  229. * templatePath: path.join(__dirname, 'vendure/email/templates'),
  230. * outputPath: path.join(__dirname, 'test-emails'),
  231. * })
  232. * ```
  233. *
  234. * ### Dev mailbox
  235. *
  236. * In dev mode, a webmail-like interface available at the `/mailbox` path, e.g.
  237. * http://localhost:3000/mailbox. This is a simple way to view the output of all emails generated by the EmailPlugin while in dev mode.
  238. *
  239. * ## Troubleshooting SMTP Connections
  240. *
  241. * If you are having trouble sending email over and SMTP connection, set the `logging` and `debug` options to `true`. This will
  242. * send detailed information from the SMTP transporter to the configured logger (defaults to console). For maximum detail combine
  243. * this with a detail log level in the configured VendureLogger:
  244. *
  245. * ```ts
  246. * const config: VendureConfig = {
  247. * logger: new DefaultLogger({ level: LogLevel.Debug })
  248. * // ...
  249. * plugins: [
  250. * EmailPlugin.init({
  251. * // ...
  252. * transport: {
  253. * type: 'smtp',
  254. * host: 'smtp.example.com',
  255. * port: 587,
  256. * auth: {
  257. * user: 'username',
  258. * pass: 'password',
  259. * },
  260. * logging: true,
  261. * debug: true,
  262. * },
  263. * }),
  264. * ],
  265. * };
  266. * ```
  267. *
  268. * @docsCategory core plugins/EmailPlugin
  269. */
  270. @VendurePlugin({
  271. imports: [PluginCommonModule],
  272. providers: [{ provide: EMAIL_PLUGIN_OPTIONS, useFactory: () => EmailPlugin.options }, EmailProcessor],
  273. compatibility: '^2.0.0',
  274. })
  275. export class EmailPlugin implements OnApplicationBootstrap, OnApplicationShutdown, NestModule {
  276. private static options: InitializedEmailPluginOptions;
  277. private devMailbox: DevMailbox | undefined;
  278. private jobQueue: JobQueue<IntermediateEmailDetails> | undefined;
  279. private testingProcessor: EmailProcessor | undefined;
  280. /** @internal */
  281. constructor(
  282. private eventBus: EventBus,
  283. private moduleRef: ModuleRef,
  284. private emailProcessor: EmailProcessor,
  285. private jobQueueService: JobQueueService,
  286. private processContext: ProcessContext,
  287. @Inject(EMAIL_PLUGIN_OPTIONS) private options: InitializedEmailPluginOptions,
  288. ) {}
  289. /**
  290. * Set the plugin options.
  291. */
  292. static init(options: EmailPluginOptions | EmailPluginDevModeOptions): Type<EmailPlugin> {
  293. if (options.templateLoader) {
  294. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
  295. Logger.info(`Using custom template loader '${options.templateLoader.constructor.name}'`);
  296. } else if (!options.templateLoader && options.templatePath) {
  297. // TODO: this else-if can be removed when deprecated templatePath is removed,
  298. // because we will either have a custom template loader, or the default loader with a default path
  299. options.templateLoader = new FileBasedTemplateLoader(options.templatePath);
  300. } else {
  301. throw new Error('You must either supply a templatePath or provide a custom templateLoader');
  302. }
  303. this.options = options as InitializedEmailPluginOptions;
  304. return EmailPlugin;
  305. }
  306. /** @internal */
  307. async onApplicationBootstrap(): Promise<void> {
  308. await this.initInjectableStrategies();
  309. await this.setupEventSubscribers();
  310. const transport = await resolveTransportSettings(this.options, new Injector(this.moduleRef));
  311. if (!isDevModeOptions(this.options) && transport.type === 'testing') {
  312. // When running tests, we don't want to go through the JobQueue system,
  313. // so we just call the email sending logic directly.
  314. this.testingProcessor = new EmailProcessor(this.options, this.moduleRef, this.eventBus);
  315. await this.testingProcessor.init();
  316. } else {
  317. await this.emailProcessor.init();
  318. this.jobQueue = await this.jobQueueService.createQueue({
  319. name: 'send-email',
  320. process: job => {
  321. return this.emailProcessor.process(job.data);
  322. },
  323. });
  324. }
  325. }
  326. async onApplicationShutdown() {
  327. await this.destroyInjectableStrategies();
  328. }
  329. configure(consumer: MiddlewareConsumer) {
  330. if (isDevModeOptions(this.options) && this.processContext.isServer) {
  331. Logger.info('Creating dev mailbox middleware', loggerCtx);
  332. this.devMailbox = new DevMailbox();
  333. consumer.apply(this.devMailbox.serve(this.options)).forRoutes(this.options.route);
  334. this.devMailbox.handleMockEvent((handler, event) => this.handleEvent(handler, event));
  335. registerPluginStartupMessage('Dev mailbox', this.options.route);
  336. }
  337. }
  338. private async initInjectableStrategies() {
  339. const injector = new Injector(this.moduleRef);
  340. if (typeof this.options.emailGenerator?.init === 'function') {
  341. await this.options.emailGenerator.init(injector);
  342. }
  343. if (typeof this.options.emailSender?.init === 'function') {
  344. await this.options.emailSender.init(injector);
  345. }
  346. }
  347. private async destroyInjectableStrategies() {
  348. if (typeof this.options.emailGenerator?.destroy === 'function') {
  349. await this.options.emailGenerator.destroy();
  350. }
  351. if (typeof this.options.emailSender?.destroy === 'function') {
  352. await this.options.emailSender.destroy();
  353. }
  354. }
  355. private async setupEventSubscribers() {
  356. for (const handler of EmailPlugin.options.handlers) {
  357. this.eventBus.ofType(handler.event).subscribe(event => {
  358. return this.handleEvent(handler, event);
  359. });
  360. }
  361. }
  362. private async handleEvent(
  363. handler: EmailEventHandler | EmailEventHandlerWithAsyncData<any>,
  364. event: EventWithContext,
  365. ) {
  366. Logger.debug(`Handling event "${handler.type}"`, loggerCtx);
  367. const { type } = handler;
  368. try {
  369. const injector = new Injector(this.moduleRef);
  370. const result = await handler.handle(
  371. event as any,
  372. EmailPlugin.options.globalTemplateVars,
  373. injector,
  374. );
  375. if (!result) {
  376. return;
  377. }
  378. if (this.jobQueue) {
  379. await this.jobQueue.add(result, { retries: 5 });
  380. } else if (this.testingProcessor) {
  381. await this.testingProcessor.process(result);
  382. }
  383. } catch (e: any) {
  384. Logger.error(e.message, loggerCtx, e.stack);
  385. }
  386. }
  387. }