plugin.ts 8.6 KB

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