plugin.ts 9.5 KB

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