plugin.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import { createProxyHandler, EventBus, InternalServerError, Logger, Type, VendureConfig, VendurePlugin } from '@vendure/core';
  2. import fs from 'fs-extra';
  3. import { DevMailbox } from './dev-mailbox';
  4. import { EmailSender } from './email-sender';
  5. import { EmailEventHandler } from './event-listener';
  6. import { HandlebarsMjmlGenerator } from './handlebars-mjml-generator';
  7. import { TemplateLoader } from './template-loader';
  8. import { EmailPluginDevModeOptions, EmailPluginOptions, EmailTransportOptions, EventWithContext } from './types';
  9. /**
  10. * @description
  11. * The EmailPlugin creates and sends transactional emails based on Vendure events. It uses an [MJML](https://mjml.io/)-based
  12. * email generator to generate the email body and [Nodemailer](https://nodemailer.com/about/) to send the emais.
  13. *
  14. * ## Installation
  15. *
  16. * `yarn add @vendure/email-plugin`
  17. *
  18. * or
  19. *
  20. * `npm install @vendure/email-plugin`
  21. *
  22. * @example
  23. * ```ts
  24. * import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
  25. *
  26. * const config: VendureConfig = {
  27. * // Add an instance of the plugin to the plugins array
  28. * plugins: [
  29. * new EmailPlugin({
  30. * handlers: defaultEmailHandlers,
  31. * templatePath: path.join(__dirname, 'vendure/email/templates'),
  32. * transport: {
  33. * type: 'smtp',
  34. * host: 'smtp.example.com',
  35. * port: 587,
  36. * auth: {
  37. * user: 'username',
  38. * pass: 'password',
  39. * }
  40. * },
  41. * }),
  42. * ],
  43. * };
  44. * ```
  45. *
  46. * ## Email templates
  47. *
  48. * In the example above, the plugin has been configured to look in `<app-root>/vendure/email/templates`
  49. * for the email template files. If you used `@vendure/create` to create your application, the templates will have
  50. * been copied to that location during setup.
  51. *
  52. * If you are installing the EmailPlugin separately, then you'll need to copy the templates manually from
  53. * `node_modules/@vendure/email-plugin/templates` to a location of your choice, and then point the `templatePath` config
  54. * property at that directory.
  55. *
  56. * ## Customizing templates
  57. *
  58. * Emails are generated from templates which use [MJML](https://mjml.io/) syntax. MJML is an open-source HTML-like markup
  59. * language which makes the task of creating responsive email markup simple. By default, the templates are installed to
  60. * `<project root>/vendure/email/templates` and can be freely edited.
  61. *
  62. * Dynamic data such as the recipient's name or order items are specified using [Handlebars syntax](https://handlebarsjs.com/):
  63. *
  64. * ```HTML
  65. * <p>Dear {{ order.customer.firstName }} {{ order.customer.lastName }},</p>
  66. *
  67. * <p>Thank you for your order!</p>
  68. *
  69. * <mj-table cellpadding="6px">
  70. * {{#each order.lines }}
  71. * <tr class="order-row">
  72. * <td>{{ quantity }} x {{ productVariant.name }}</td>
  73. * <td>{{ productVariant.quantity }}</td>
  74. * <td>{{ formatMoney totalPrice }}</td>
  75. * </tr>
  76. * {{/each}}
  77. * </mj-table>
  78. * ```
  79. *
  80. * ### Handlebars helpers
  81. *
  82. * The following helper functions are available for use in email templates:
  83. *
  84. * * `formatMoney`: Formats an amount of money (which are always stored as integers in Vendure) as a decimal, e.g. `123` => `1.23`
  85. * * `formatDate`: Formats a Date value with the [dateformat](https://www.npmjs.com/package/dateformat) package.
  86. *
  87. * ## Extending the default email handlers
  88. *
  89. * The `defaultEmailHandlers` array defines the default handlers such as for handling new account registration, order confirmation, password reset
  90. * etc. These defaults can be extended by adding custom templates for languages other than the default, or even completely new types of emails
  91. * which respond to any of the available [VendureEvents](/docs/typescript-api/events/). See the {@link EmailEventHandler} documentation for
  92. * details on how to do so.
  93. *
  94. * ## Dev mode
  95. *
  96. * For development, the `transport` option can be replaced by `devMode: true`. Doing so configures Vendure to use the
  97. * file transport (See {@link FileTransportOptions}) and outputs emails as rendered HTML files in the directory specified by the
  98. * `outputPath` property.
  99. *
  100. * ```ts
  101. * new EmailPlugin({
  102. * devMode: true,
  103. * handlers: defaultEmailHandlers,
  104. * templatePath: path.join(__dirname, 'vendure/email/templates'),
  105. * outputPath: path.join(__dirname, 'test-emails'),
  106. * mailboxPort: 5003,
  107. * })
  108. * ```
  109. *
  110. * ### Dev mailbox
  111. *
  112. * In dev mode, specifying the optional `mailboxPort` will start a webmail-like interface available at the `/mailbox` path, e.g.
  113. * http://localhost:3000/mailbox. This is a simple way to view the output of all emails generated by the EmailPlugin while in dev mode.
  114. *
  115. * @docsCategory EmailPlugin
  116. */
  117. export class EmailPlugin implements VendurePlugin {
  118. private readonly transport: EmailTransportOptions;
  119. private readonly options: EmailPluginOptions | EmailPluginDevModeOptions;
  120. private eventBus: EventBus;
  121. private templateLoader: TemplateLoader;
  122. private emailSender: EmailSender;
  123. private generator: HandlebarsMjmlGenerator;
  124. private devMailbox: DevMailbox | undefined;
  125. constructor(options: EmailPluginOptions | EmailPluginDevModeOptions) {
  126. this.options = options;
  127. if (isDevModeOptions(options)) {
  128. this.transport = {
  129. type: 'file',
  130. raw: false,
  131. outputPath: options.outputPath,
  132. };
  133. } else {
  134. if (!options.transport) {
  135. throw new InternalServerError(
  136. `When devMode is not set to true, the 'transport' property must be set.`,
  137. );
  138. }
  139. this.transport = options.transport;
  140. }
  141. }
  142. configure(config: Required<VendureConfig>): Required<VendureConfig> | Promise<Required<VendureConfig>> {
  143. if (isDevModeOptions(this.options) && this.options.mailboxPort !== undefined) {
  144. this.devMailbox = new DevMailbox();
  145. this.devMailbox.serve(this.options);
  146. this.devMailbox.handleMockEvent((handler, event) => this.handleEvent(handler, event));
  147. const route = 'mailbox';
  148. config.middleware.push({
  149. handler: createProxyHandler({ port: this.options.mailboxPort, route, label: 'Dev Mailbox' }),
  150. route,
  151. });
  152. }
  153. return config;
  154. }
  155. async onBootstrap(inject: <T>(type: Type<T>) => T): Promise<void> {
  156. this.eventBus = inject(EventBus);
  157. this.templateLoader = new TemplateLoader(this.options.templatePath);
  158. this.emailSender = new EmailSender();
  159. this.generator = new HandlebarsMjmlGenerator();
  160. await this.setupEventSubscribers();
  161. if (this.generator.onInit) {
  162. await this.generator.onInit.call(this.generator, this.options);
  163. }
  164. }
  165. async onClose() {
  166. if (this.devMailbox) {
  167. this.devMailbox.destroy();
  168. }
  169. }
  170. async setupEventSubscribers() {
  171. for (const handler of this.options.handlers) {
  172. this.eventBus.subscribe(handler.event, event => {
  173. return this.handleEvent(handler, event);
  174. });
  175. }
  176. if (this.transport.type === 'file') {
  177. // ensure the configured directory exists before
  178. // we attempt to write files to it
  179. const emailPath = this.transport.outputPath;
  180. await fs.ensureDir(emailPath);
  181. }
  182. }
  183. private async handleEvent(handler: EmailEventHandler, event: EventWithContext) {
  184. Logger.debug(`Handling event "${handler.type}"`, 'EmailPlugin');
  185. const { type } = handler;
  186. const result = handler.handle(event, this.options.globalTemplateVars);
  187. if (!result) {
  188. return;
  189. }
  190. const bodySource = await this.templateLoader.loadTemplate(
  191. type,
  192. result.templateFile,
  193. );
  194. const generated = await this.generator.generate(
  195. result.subject,
  196. bodySource,
  197. result.templateVars,
  198. );
  199. const emailDetails = { ...generated, recipient: result.recipient };
  200. await this.emailSender.send(emailDetails, this.transport);
  201. }
  202. }
  203. function isDevModeOptions(
  204. input: EmailPluginOptions | EmailPluginDevModeOptions,
  205. ): input is EmailPluginDevModeOptions {
  206. return (input as EmailPluginDevModeOptions).devMode === true;
  207. }