plugin.ts 15 KB

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