plugin.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  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, FileBasedTemplateLoader } 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. * templateLoader: new FileBasedTemplateLoader(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. * ### Setting global variables using `globalTemplateVars`
  126. *
  127. * `globalTemplateVars` is an object that can be passed to the configuration of the Email Plugin with static object variables.
  128. * You can also pass an async function that will be called with the `RequestContext` and the `Injector` so you can access services
  129. * and e.g. load channel specific theme configurations.
  130. *
  131. * @example
  132. * ```ts
  133. * EmailPlugin.init({
  134. * globalTemplateVars: {
  135. * primaryColor: '#FF0000',
  136. * fromAddress: 'no-reply@ourstore.com'
  137. * }
  138. * })
  139. * ```
  140. * or
  141. * ```ts
  142. * EmailPlugin.init({
  143. * globalTemplateVars: async (ctx, injector) => {
  144. * const myAsyncService = injector.get(MyAsyncService);
  145. * const asyncValue = await myAsyncService.get(ctx);
  146. * const channel = ctx.channel;
  147. * const { primaryColor } = channel.customFields.theme;
  148. * const theme = {
  149. * primaryColor,
  150. * asyncValue,
  151. * };
  152. * return theme;
  153. * }
  154. * })
  155. * ```
  156. *
  157. * ### Handlebars helpers
  158. *
  159. * The following helper functions are available for use in email templates:
  160. *
  161. * * `formatMoney`: Formats an amount of money (which are always stored as integers in Vendure) as a decimal, e.g. `123` => `1.23`
  162. * * `formatDate`: Formats a Date value with the [dateformat](https://www.npmjs.com/package/dateformat) package.
  163. *
  164. * ## Extending the default email handler
  165. *
  166. * The `defaultEmailHandlers` array defines the default handler such as for handling new account registration, order confirmation, password reset
  167. * etc. These defaults can be extended by adding custom templates for languages other than the default, or even completely new types of emails
  168. * which respond to any of the available [VendureEvents](/reference/typescript-api/events/).
  169. *
  170. * A good way to learn how to create your own email handler is to take a look at the
  171. * [source code of the default handler](https://github.com/vendure-ecommerce/vendure/blob/master/packages/email-plugin/src/handler/default-email-handlers.ts).
  172. * New handler are defined in exactly the same way.
  173. *
  174. * It is also possible to modify the default handler:
  175. *
  176. * ```ts
  177. * // Rather than importing `defaultEmailHandlers`, you can
  178. * // import the handler individually
  179. * import {
  180. * orderConfirmationHandler,
  181. * emailVerificationHandler,
  182. * passwordResetHandler,
  183. * emailAddressChangeHandler,
  184. * } from '\@vendure/email-plugin';
  185. * import { CustomerService } from '\@vendure/core';
  186. *
  187. * // This allows you to then customize each handler to your needs.
  188. * // For example, let's set a new subject line to the order confirmation:
  189. * const myOrderConfirmationHandler = orderConfirmationHandler
  190. * .setSubject(`We received your order!`);
  191. *
  192. * // Another example: loading additional data and setting new
  193. * // template variables.
  194. * const myPasswordResetHandler = passwordResetHandler
  195. * .loadData(async ({ event, injector }) => {
  196. * const customerService = injector.get(CustomerService);
  197. * const customer = await customerService.findOneByUserId(event.ctx, event.user.id);
  198. * return { customer };
  199. * })
  200. * .setTemplateVars(event => ({
  201. * passwordResetToken: event.user.getNativeAuthenticationMethod().passwordResetToken,
  202. * customer: event.data.customer,
  203. * }));
  204. *
  205. * // Then you pass the handler to the EmailPlugin init method
  206. * // individually
  207. * EmailPlugin.init({
  208. * handler: [
  209. * myOrderConfirmationHandler,
  210. * myPasswordResetHandler,
  211. * emailVerificationHandler,
  212. * emailAddressChangeHandler,
  213. * ],
  214. * // ...
  215. * }),
  216. * ```
  217. *
  218. * For all available methods of extending a handler, see the {@link EmailEventHandler} documentation.
  219. *
  220. * ## Dynamic SMTP settings
  221. *
  222. * Instead of defining static transport settings, you can also provide a function that dynamically resolves
  223. * channel aware transport settings.
  224. *
  225. * @example
  226. * ```ts
  227. * import { defaultEmailHandlers, EmailPlugin, FileBasedTemplateLoader } from '\@vendure/email-plugin';
  228. * import { MyTransportService } from './transport.services.ts';
  229. * const config: VendureConfig = {
  230. * plugins: [
  231. * EmailPlugin.init({
  232. * handler: defaultEmailHandlers,
  233. * templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
  234. * transport: (injector, ctx) => {
  235. * if (ctx) {
  236. * return injector.get(MyTransportService).getSettings(ctx);
  237. * } else {
  238. * return {
  239. * type: 'smtp',
  240. * host: 'smtp.example.com',
  241. * // ... etc.
  242. * }
  243. * }
  244. * }
  245. * }),
  246. * ],
  247. * };
  248. * ```
  249. *
  250. * ## Dev mode
  251. *
  252. * For development, the `transport` option can be replaced by `devMode: true`. Doing so configures Vendure to use the
  253. * file transport (See {@link FileTransportOptions}) and outputs emails as rendered HTML files in the directory specified by the
  254. * `outputPath` property.
  255. *
  256. * ```ts
  257. * EmailPlugin.init({
  258. * devMode: true,
  259. * route: 'mailbox',
  260. * handler: defaultEmailHandlers,
  261. * templateLoader: new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
  262. * outputPath: path.join(__dirname, 'test-emails'),
  263. * })
  264. * ```
  265. *
  266. * ### Dev mailbox
  267. *
  268. * In dev mode, a webmail-like interface available at the `/mailbox` path, e.g.
  269. * http://localhost:3000/mailbox. This is a simple way to view the output of all emails generated by the EmailPlugin while in dev mode.
  270. *
  271. * ## Troubleshooting SMTP Connections
  272. *
  273. * If you are having trouble sending email over and SMTP connection, set the `logging` and `debug` options to `true`. This will
  274. * send detailed information from the SMTP transporter to the configured logger (defaults to console). For maximum detail combine
  275. * this with a detail log level in the configured VendureLogger:
  276. *
  277. * ```ts
  278. * const config: VendureConfig = {
  279. * logger: new DefaultLogger({ level: LogLevel.Debug })
  280. * // ...
  281. * plugins: [
  282. * EmailPlugin.init({
  283. * // ...
  284. * transport: {
  285. * type: 'smtp',
  286. * host: 'smtp.example.com',
  287. * port: 587,
  288. * auth: {
  289. * user: 'username',
  290. * pass: 'password',
  291. * },
  292. * logging: true,
  293. * debug: true,
  294. * },
  295. * }),
  296. * ],
  297. * };
  298. * ```
  299. *
  300. * @docsCategory core plugins/EmailPlugin
  301. */
  302. @VendurePlugin({
  303. imports: [PluginCommonModule],
  304. providers: [{ provide: EMAIL_PLUGIN_OPTIONS, useFactory: () => EmailPlugin.options }, EmailProcessor],
  305. compatibility: '^3.0.0',
  306. })
  307. export class EmailPlugin implements OnApplicationBootstrap, OnApplicationShutdown, NestModule {
  308. private static options: InitializedEmailPluginOptions;
  309. private devMailbox: DevMailbox | undefined;
  310. private jobQueue: JobQueue<IntermediateEmailDetails> | undefined;
  311. private testingProcessor: EmailProcessor | undefined;
  312. /** @internal */
  313. constructor(
  314. private eventBus: EventBus,
  315. private moduleRef: ModuleRef,
  316. private emailProcessor: EmailProcessor,
  317. private jobQueueService: JobQueueService,
  318. private processContext: ProcessContext,
  319. @Inject(EMAIL_PLUGIN_OPTIONS) private options: InitializedEmailPluginOptions,
  320. ) {}
  321. /**
  322. * Set the plugin options.
  323. */
  324. static init(options: EmailPluginOptions | EmailPluginDevModeOptions): Type<EmailPlugin> {
  325. if (options.templateLoader) {
  326. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
  327. Logger.info(`Using custom template loader '${options.templateLoader.constructor.name}'`);
  328. } else if (!options.templateLoader && options.templatePath) {
  329. // TODO: this else-if can be removed when deprecated templatePath is removed,
  330. // because we will either have a custom template loader, or the default loader with a default path
  331. options.templateLoader = new FileBasedTemplateLoader(options.templatePath);
  332. } else {
  333. throw new Error('You must either supply a templatePath or provide a custom templateLoader');
  334. }
  335. this.options = options as InitializedEmailPluginOptions;
  336. return EmailPlugin;
  337. }
  338. /** @internal */
  339. async onApplicationBootstrap(): Promise<void> {
  340. await this.initInjectableStrategies();
  341. await this.setupEventSubscribers();
  342. const transport = await resolveTransportSettings(this.options, new Injector(this.moduleRef));
  343. if (!isDevModeOptions(this.options) && transport.type === 'testing') {
  344. // When running tests, we don't want to go through the JobQueue system,
  345. // so we just call the email sending logic directly.
  346. this.testingProcessor = new EmailProcessor(this.options, this.moduleRef, this.eventBus);
  347. await this.testingProcessor.init();
  348. } else {
  349. await this.emailProcessor.init();
  350. this.jobQueue = await this.jobQueueService.createQueue({
  351. name: 'send-email',
  352. process: job => {
  353. return this.emailProcessor.process(job.data);
  354. },
  355. });
  356. }
  357. }
  358. async onApplicationShutdown() {
  359. await this.destroyInjectableStrategies();
  360. }
  361. configure(consumer: MiddlewareConsumer) {
  362. if (isDevModeOptions(this.options) && this.processContext.isServer) {
  363. Logger.info('Creating dev mailbox middleware', loggerCtx);
  364. this.devMailbox = new DevMailbox();
  365. consumer.apply(this.devMailbox.serve(this.options)).forRoutes(this.options.route);
  366. this.devMailbox.handleMockEvent((handler, event) => this.handleEvent(handler, event));
  367. registerPluginStartupMessage('Dev mailbox', this.options.route);
  368. }
  369. }
  370. private async initInjectableStrategies() {
  371. const injector = new Injector(this.moduleRef);
  372. if (typeof this.options.emailGenerator?.init === 'function') {
  373. await this.options.emailGenerator.init(injector);
  374. }
  375. if (typeof this.options.emailSender?.init === 'function') {
  376. await this.options.emailSender.init(injector);
  377. }
  378. }
  379. private async destroyInjectableStrategies() {
  380. if (typeof this.options.emailGenerator?.destroy === 'function') {
  381. await this.options.emailGenerator.destroy();
  382. }
  383. if (typeof this.options.emailSender?.destroy === 'function') {
  384. await this.options.emailSender.destroy();
  385. }
  386. }
  387. private async setupEventSubscribers() {
  388. for (const handler of EmailPlugin.options.handlers) {
  389. this.eventBus.ofType(handler.event).subscribe(event => {
  390. return this.handleEvent(handler, event);
  391. });
  392. }
  393. }
  394. private async handleEvent(
  395. handler: EmailEventHandler | EmailEventHandlerWithAsyncData<any>,
  396. event: EventWithContext,
  397. ) {
  398. Logger.debug(`Handling event "${handler.type}"`, loggerCtx);
  399. const { type } = handler;
  400. try {
  401. const injector = new Injector(this.moduleRef);
  402. let globalTemplateVars = this.options.globalTemplateVars;
  403. if (typeof globalTemplateVars === 'function') {
  404. globalTemplateVars = await globalTemplateVars(event.ctx, injector);
  405. }
  406. const result = await handler.handle(
  407. event as any,
  408. globalTemplateVars as { [key: string]: any },
  409. injector,
  410. );
  411. if (!result) {
  412. return;
  413. }
  414. if (this.jobQueue) {
  415. await this.jobQueue.add(result, { retries: 5 });
  416. } else if (this.testingProcessor) {
  417. await this.testingProcessor.process(result);
  418. }
  419. } catch (e: any) {
  420. Logger.error(e.message, loggerCtx, e.stack);
  421. }
  422. }
  423. }