From c6686cd978215da365dc3854cec242ddb542e582 Mon Sep 17 00:00:00 2001 From: Surafel Tariku Date: Fri, 28 Apr 2023 14:25:54 +0300 Subject: [PATCH] feat(email-plugin): Add support for dynamic templates & SMTP settings Closes #2043, closes #2044 Co-authored-by: Martijn --- packages/email-plugin/src/common.ts | 16 +++- packages/email-plugin/src/email-processor.ts | 62 +++++++++++---- packages/email-plugin/src/event-handler.ts | 18 +++-- .../src/handlebars-mjml-generator.ts | 23 +++--- packages/email-plugin/src/mock-events.ts | 2 +- packages/email-plugin/src/plugin.spec.ts | 53 ++++++++++++- packages/email-plugin/src/plugin.ts | 69 +++++++++++++++-- packages/email-plugin/src/template-loader.ts | 32 +++++--- packages/email-plugin/src/types.ts | 75 +++++++++++++++++-- 9 files changed, 292 insertions(+), 58 deletions(-) diff --git a/packages/email-plugin/src/common.ts b/packages/email-plugin/src/common.ts index 00e98b5315..e067b963b2 100644 --- a/packages/email-plugin/src/common.ts +++ b/packages/email-plugin/src/common.ts @@ -1,7 +1,21 @@ -import { EmailPluginDevModeOptions, EmailPluginOptions } from './types'; +import { Injector, RequestContext } from '@vendure/core'; + +import { EmailPluginDevModeOptions, EmailPluginOptions, EmailTransportOptions } from './types'; export function isDevModeOptions( input: EmailPluginOptions | EmailPluginDevModeOptions, ): input is EmailPluginDevModeOptions { return (input as EmailPluginDevModeOptions).devMode === true; } + +export async function resolveTransportSettings( + options: EmailPluginOptions, + injector: Injector, + ctx?: RequestContext +): Promise { + if (typeof options.transport === 'function') { + return options.transport(injector, ctx); + } else { + return options.transport; + } +} diff --git a/packages/email-plugin/src/email-processor.ts b/packages/email-plugin/src/email-processor.ts index 7e9a1bd89d..d57c242e18 100644 --- a/packages/email-plugin/src/email-processor.ts +++ b/packages/email-plugin/src/email-processor.ts @@ -1,16 +1,24 @@ import { Inject, Injectable } from '@nestjs/common'; -import { InternalServerError, Logger } from '@vendure/core'; +import { ModuleRef } from '@nestjs/core'; +import { Ctx, Injector, InternalServerError, Logger, RequestContext } from '@vendure/core'; import fs from 'fs-extra'; import { deserializeAttachments } from './attachment-utils'; -import { isDevModeOptions } from './common'; +import { isDevModeOptions, resolveTransportSettings } from './common'; import { EMAIL_PLUGIN_OPTIONS, loggerCtx } from './constants'; import { EmailGenerator } from './email-generator'; import { EmailSender } from './email-sender'; import { HandlebarsMjmlGenerator } from './handlebars-mjml-generator'; import { NodemailerEmailSender } from './nodemailer-email-sender'; -import { TemplateLoader } from './template-loader'; -import { EmailDetails, EmailPluginOptions, EmailTransportOptions, IntermediateEmailDetails } from './types'; +import { FileBasedTemplateLoader } from './template-loader'; +import { + EmailDetails, + EmailPluginOptions, + EmailTransportOptions, + InitializedEmailPluginOptions, + IntermediateEmailDetails, + TemplateLoader, +} from './types'; /** * This class combines the template loading, generation, and email sending - the actual "work" of @@ -19,15 +27,21 @@ import { EmailDetails, EmailPluginOptions, EmailTransportOptions, IntermediateEm */ @Injectable() export class EmailProcessor { - protected templateLoader: TemplateLoader; protected emailSender: EmailSender; protected generator: EmailGenerator; - protected transport: EmailTransportOptions; + protected transport: + | EmailTransportOptions + | (( + injector?: Injector, + ctx?: RequestContext, + ) => EmailTransportOptions | Promise); - constructor(@Inject(EMAIL_PLUGIN_OPTIONS) protected options: EmailPluginOptions) {} + constructor( + @Inject(EMAIL_PLUGIN_OPTIONS) protected options: InitializedEmailPluginOptions, + private moduleRef: ModuleRef, + ) {} async init() { - this.templateLoader = new TemplateLoader(this.options.templatePath); this.emailSender = this.options.emailSender ? this.options.emailSender : new NodemailerEmailSender(); this.generator = this.options.emailGenerator ? this.options.emailGenerator @@ -44,22 +58,31 @@ export class EmailProcessor { } else { if (!this.options.transport) { throw new InternalServerError( - 'When devMode is not set to true, the \'transport\' property must be set.', + "When devMode is not set to true, the 'transport' property must be set.", ); } this.transport = this.options.transport; } - if (this.transport.type === 'file') { + const transport = await this.getTransportSettings(); + if (transport.type === 'file') { // ensure the configured directory exists before // we attempt to write files to it - const emailPath = this.transport.outputPath; + const emailPath = transport.outputPath; await fs.ensureDir(emailPath); } } async process(data: IntermediateEmailDetails) { try { - const bodySource = await this.templateLoader.loadTemplate(data.type, data.templateFile); + const ctx = RequestContext.deserialize(data.ctx); + const bodySource = await this.options.templateLoader.loadTemplate( + new Injector(this.moduleRef), + ctx, + { + templateName: data.templateFile, + type: data.type, + }, + ); const generated = this.generator.generate(data.from, data.subject, bodySource, data.templateVars); const emailDetails: EmailDetails = { ...generated, @@ -69,7 +92,8 @@ export class EmailProcessor { bcc: data.bcc, replyTo: data.replyTo, }; - await this.emailSender.send(emailDetails, this.transport); + const transportSettings = await this.getTransportSettings(ctx); + await this.emailSender.send(emailDetails, transportSettings); return true; } catch (err: unknown) { if (err instanceof Error) { @@ -80,4 +104,16 @@ export class EmailProcessor { throw err; } } + + async getTransportSettings(ctx?: RequestContext): Promise { + if (isDevModeOptions(this.options)) { + return { + type: 'file', + raw: false, + outputPath: this.options.outputPath, + }; + } else { + return resolveTransportSettings(this.options, new Injector(this.moduleRef), ctx); + } + } } diff --git a/packages/email-plugin/src/event-handler.ts b/packages/email-plugin/src/event-handler.ts index c41640888f..4d67e03596 100644 --- a/packages/email-plugin/src/event-handler.ts +++ b/packages/email-plugin/src/event-handler.ts @@ -144,7 +144,7 @@ export class EmailEventHandler | undefined; - constructor(public listener: EmailEventListener, public event: Type) {} + constructor(public listener: EmailEventListener, public event: Type) { } /** @internal */ get type(): T { @@ -268,6 +268,9 @@ export class EmailEventHandler { this.configurations.push(config); @@ -346,14 +349,14 @@ export class EmailEventHandler Handlebars.registerPartial(name, content)); + } this.registerHelpers(); } @@ -38,14 +45,6 @@ export class HandlebarsMjmlGenerator implements EmailGenerator { return { from: fromResult, subject: subjectResult, body }; } - private registerPartials(partialsPath: string) { - const partialsFiles = fs.readdirSync(partialsPath); - for (const partialFile of partialsFiles) { - const partialContent = fs.readFileSync(path.join(partialsPath, partialFile), 'utf-8'); - Handlebars.registerPartial(path.basename(partialFile, '.hbs'), partialContent); - } - } - private registerHelpers() { Handlebars.registerHelper('formatDate', (date: Date | undefined, format: string | object) => { if (!date) { diff --git a/packages/email-plugin/src/mock-events.ts b/packages/email-plugin/src/mock-events.ts index df032bf457..3114b59980 100644 --- a/packages/email-plugin/src/mock-events.ts +++ b/packages/email-plugin/src/mock-events.ts @@ -51,7 +51,7 @@ export const mockOrderStateTransitionEvent = new OrderStateTransitionEvent( { adjustmentSource: 'Promotion:1', type: AdjustmentType.PROMOTION, - amount: -1000, + amount: -1000 as any, description: '$10 off computer equipment', }, ], diff --git a/packages/email-plugin/src/plugin.spec.ts b/packages/email-plugin/src/plugin.spec.ts index b12d4db586..b8d7fc3ae2 100644 --- a/packages/email-plugin/src/plugin.spec.ts +++ b/packages/email-plugin/src/plugin.spec.ts @@ -3,9 +3,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants'; import { + DefaultLogger, EventBus, + Injector, LanguageCode, Logger, + LogLevel, Order, OrderStateTransitionEvent, PluginCommonModule, @@ -18,8 +21,8 @@ import { createReadStream, readFileSync } from 'fs'; import path from 'path'; import { Readable } from 'stream'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; - import { orderConfirmationHandler } from './default-email-handlers'; +import { EmailProcessor } from './email-processor'; import { EmailSender } from './email-sender'; import { EmailEventHandler } from './event-handler'; import { EmailEventListener } from './event-listener'; @@ -859,6 +862,54 @@ describe('EmailPlugin', () => { expect(onSend.mock.calls[0][0].replyTo).toBe('foo@bar.com'); }); }); + + describe('Dynamic transport settings', () => { + let injectorArg: Injector | undefined; + let ctxArg: RequestContext | undefined; + + it('Initializes with async transport settings', async () => { + const handler = new EmailEventListener('test') + .on(MockEvent) + .setFrom('"test from" ') + .setRecipient(() => 'test@test.com') + .setSubject('Hello') + .setTemplateVars(event => ({ subjectVar: 'foo' })); + module = await initPluginWithHandlers([handler], { + transport: async (injector, ctx) => { + injectorArg = injector; + ctxArg = ctx; + return { + type: 'testing', + onSend: () => {}, + } + } + }); + const ctx = RequestContext.deserialize({ + _channel: { code: DEFAULT_CHANNEL_CODE }, + _languageCode: LanguageCode.en, + } as any); + module!.get(EventBus).publish(new MockEvent(ctx, true)); + await pause(); + expect(module).toBeDefined(); + expect(typeof (module.get(EmailPlugin) as any).options.transport).toBe('function'); + }); + + it('Passes injector and context to transport function', async () => { + const ctx = RequestContext.deserialize({ + _channel: { code: DEFAULT_CHANNEL_CODE }, + _languageCode: LanguageCode.en, + } as any); + module!.get(EventBus).publish(new MockEvent(ctx, true)); + await pause(); + expect(injectorArg?.constructor.name).toBe('Injector'); + expect(ctxArg?.constructor.name).toBe('RequestContext'); + }); + + it('Resolves async transport settings', async () => { + const transport = await module!.get(EmailProcessor).getTransportSettings(); + expect(transport.type).toBe('testing'); + }); + }); }); class FakeCustomSender implements EmailSender { diff --git a/packages/email-plugin/src/plugin.ts b/packages/email-plugin/src/plugin.ts index e977e31d69..ebfe7003e0 100644 --- a/packages/email-plugin/src/plugin.ts +++ b/packages/email-plugin/src/plugin.ts @@ -15,19 +15,25 @@ import { PluginCommonModule, ProcessContext, registerPluginStartupMessage, + RequestContext, Type, + UserInputError, VendurePlugin, } from '@vendure/core'; +import Module from 'module'; -import { isDevModeOptions } from './common'; +import { isDevModeOptions, resolveTransportSettings } from './common'; import { EMAIL_PLUGIN_OPTIONS, loggerCtx } from './constants'; import { DevMailbox } from './dev-mailbox'; import { EmailProcessor } from './email-processor'; import { EmailEventHandler, EmailEventHandlerWithAsyncData } from './event-handler'; +import { FileBasedTemplateLoader } from './template-loader'; import { EmailPluginDevModeOptions, EmailPluginOptions, + EmailTransportOptions, EventWithContext, + InitializedEmailPluginOptions, IntermediateEmailDetails, } from './types'; @@ -91,6 +97,14 @@ import { * `node_modules/\@vendure/email-plugin/templates` to a location of your choice, and then point the `templatePath` config * property at that directory. * + * * ### Dynamic Email Templates + * Instead of passing a static value to `templatePath`, use `templateLoader` to define a template path. + * ```ts + * EmailPlugin.init({ + * ..., + * templateLoader: new FileBasedTemplateLoader(my/order-confirmation/templates) + * }) + * ``` * ## Customizing templates * * Emails are generated from templates which use [MJML](https://mjml.io/) syntax. MJML is an open-source HTML-like markup @@ -178,6 +192,36 @@ import { * * For all available methods of extending a handler, see the {@link EmailEventHandler} documentation. * + * ## Dynamic SMTP settings + * + * Instead of defining static transport settings, you can also provide a function that dynamically resolves + * channel aware transport settings. + * + * @example + * ```ts + * import { defaultEmailHandlers, EmailPlugin } from '\@vendure/email-plugin'; + * import { MyTransportService } from './transport.services.ts'; + * const config: VendureConfig = { + * plugins: [ + * EmailPlugin.init({ + * handlers: defaultEmailHandlers, + * templatePath: path.join(__dirname, 'static/email/templates'), + * transport: (injector, ctx) => { + * if (ctx) { + * return injector.get(MyTransportService).getSettings(ctx); + * } else { + * return { + type: 'smtp', + host: 'smtp.example.com', + // ... etc. + } + * } + * } + * }), + * ], + * }; + * ``` + * * ## Dev mode * * For development, the `transport` option can be replaced by `devMode: true`. Doing so configures Vendure to use the @@ -236,7 +280,7 @@ import { compatibility: '^2.0.0-beta.0', }) export class EmailPlugin implements OnApplicationBootstrap, OnApplicationShutdown, NestModule { - private static options: EmailPluginOptions | EmailPluginDevModeOptions; + private static options: InitializedEmailPluginOptions; private devMailbox: DevMailbox | undefined; private jobQueue: JobQueue | undefined; private testingProcessor: EmailProcessor | undefined; @@ -248,14 +292,24 @@ export class EmailPlugin implements OnApplicationBootstrap, OnApplicationShutdow private emailProcessor: EmailProcessor, private jobQueueService: JobQueueService, private processContext: ProcessContext, - @Inject(EMAIL_PLUGIN_OPTIONS) private options: EmailPluginOptions, - ) {} + @Inject(EMAIL_PLUGIN_OPTIONS) private options: InitializedEmailPluginOptions, + ) { } /** * Set the plugin options. */ static init(options: EmailPluginOptions | EmailPluginDevModeOptions): Type { - this.options = options; + if (options.templateLoader) { + Logger.info(`Using custom template loader '${options.templateLoader.constructor.name}'`); + } else if (!options.templateLoader && options.templatePath) { + // TODO: this else-if can be removed when deprecated templatePath is removed, + // because we will either have a custom template loader, or the default loader with a default path + options.templateLoader = new FileBasedTemplateLoader(options.templatePath); + } else { + throw new + Error('You must either supply a templatePath or provide a custom templateLoader'); + } + this.options = options as InitializedEmailPluginOptions; return EmailPlugin; } @@ -263,10 +317,11 @@ export class EmailPlugin implements OnApplicationBootstrap, OnApplicationShutdow async onApplicationBootstrap(): Promise { await this.initInjectableStrategies(); await this.setupEventSubscribers(); - if (!isDevModeOptions(this.options) && this.options.transport.type === 'testing') { + const transport = await resolveTransportSettings(this.options, new Injector(this.moduleRef)); + if (!isDevModeOptions(this.options) && transport.type === 'testing') { // When running tests, we don't want to go through the JobQueue system, // so we just call the email sending logic directly. - this.testingProcessor = new EmailProcessor(this.options); + this.testingProcessor = new EmailProcessor(this.options, this.moduleRef); await this.testingProcessor.init(); } else { await this.emailProcessor.init(); diff --git a/packages/email-plugin/src/template-loader.ts b/packages/email-plugin/src/template-loader.ts index c39d1b90a0..8bcf45fc7f 100644 --- a/packages/email-plugin/src/template-loader.ts +++ b/packages/email-plugin/src/template-loader.ts @@ -1,20 +1,32 @@ -import { LanguageCode } from '@vendure/common/lib/generated-types'; -import fs from 'fs-extra'; +import { Injector, RequestContext } from '@vendure/core'; +import fs from 'fs/promises'; import path from 'path'; +import { LoadTemplateInput, Partial, TemplateLoader } from './types'; /** * Loads email templates according to the configured TemplateConfig values. */ -export class TemplateLoader { - constructor(private templatePath: string) {} +export class FileBasedTemplateLoader implements TemplateLoader { + + constructor(private templatePath: string) { } async loadTemplate( - type: string, - templateFileName: string, + _injector: Injector, + _ctx: RequestContext, + { type, templateName }: LoadTemplateInput, ): Promise { - // TODO: logic to select other files based on channel / language - const templatePath = path.join(this.templatePath, type, templateFileName); - const body = await fs.readFile(templatePath, 'utf-8'); - return body; + const templatePath = path.join(this.templatePath, type, templateName); + return fs.readFile(templatePath, 'utf-8'); + } + + async loadPartials(): Promise { + const partialsPath = path.join(this.templatePath, 'partials'); + const partialsFiles = await fs.readdir(partialsPath); + return Promise.all(partialsFiles.map(async (file) => { + return { + name: path.basename(file, '.hbs'), + content: await fs.readFile(path.join(partialsPath, file), 'utf-8') + } + })); } } diff --git a/packages/email-plugin/src/types.ts b/packages/email-plugin/src/types.ts index c57c05fee3..1940fa798f 100644 --- a/packages/email-plugin/src/types.ts +++ b/packages/email-plugin/src/types.ts @@ -1,6 +1,6 @@ import { LanguageCode } from '@vendure/common/lib/generated-types'; import { Omit } from '@vendure/common/lib/omit'; -import { Injector, RequestContext, VendureEvent } from '@vendure/core'; +import { Injector, RequestContext, SerializedRequestContext, VendureEvent } from '@vendure/core'; import { Attachment } from 'nodemailer/lib/mailer'; import SESTransport from 'nodemailer/lib/ses-transport' import SMTPTransport from 'nodemailer/lib/smtp-transport'; @@ -42,13 +42,23 @@ export interface EmailPluginOptions { * @description * The path to the location of the email templates. In a default Vendure installation, * the templates are installed to `/vendure/email/templates`. + * + * @deprecated Use `templateLoader` to define a template path: `templateLoader: new FileBasedTemplateLoader('../your-path/templates')` */ - templatePath: string; + templatePath?: string; + /** + * @description + * An optional TemplateLoader which can be used to load templates from a custom location or async service. + * The default uses the FileBasedTemplateLoader which loads templates from `/vendure/email/templates` + * + * @since 2.0.0 + */ + templateLoader?: TemplateLoader; /** * @description * Configures how the emails are sent. */ - transport: EmailTransportOptions; + transport: EmailTransportOptions | ((injector?: Injector, ctx?: RequestContext) => EmailTransportOptions | Promise) /** * @description * An array of {@link EmailEventHandler}s which define which Vendure events will trigger @@ -80,6 +90,11 @@ export interface EmailPluginOptions { emailGenerator?: EmailGenerator; } +/** + * EmailPLuginOptions type after initialization, where templateLoader is no longer optional + */ +export type InitializedEmailPluginOptions = EmailPluginOptions & { templateLoader: TemplateLoader }; + /** * @description * Configuration for running the EmailPlugin in development mode. @@ -285,6 +300,7 @@ export type SerializedAttachment = OptionalToNullable< >; export type IntermediateEmailDetails = { + ctx: SerializedRequestContext; type: string; from: string; recipient: string; @@ -301,9 +317,8 @@ export type IntermediateEmailDetails = { * @description * Configures the {@link EmailEventHandler} to handle a particular channel & languageCode * combination. - * - * @docsCategory EmailPlugin - * @docsPage Email Plugin Types + * + * @deprecated Use a custom {@link TemplateLoader} instead. */ export interface EmailTemplateConfig { /** @@ -331,6 +346,54 @@ export interface EmailTemplateConfig { subject: string; } +export interface LoadTemplateInput { + type: string, + templateName: string +} + +export interface Partial { + name: string, + content: string +} + +/** + * @description + * Load an email template based on the given request context, type and template name + * and return the template as a string. + * + * @example + * ```TypeScript + * import { EmailPlugin, TemplateLoader } from '@vendure/email-plugin'; + * + * class MyTemplateLoader implements TemplateLoader { + * loadTemplate(injector, ctx, { type, templateName }){ + * return myCustomTemplateFunction(ctx); + * } + * } + * + * // In vendure-config.ts: + * ... + * EmailPlugin.init({ + * templateLoader: new MyTemplateLoader() + * ... + * }) + * ``` + * + * @docsCategory EmailPlugin + * @docsPage Custom Template Loader + */ +export interface TemplateLoader { + /** + * Load template and return it's content as a string + */ + loadTemplate(injector: Injector, ctx: RequestContext, input: LoadTemplateInput): Promise; + /** + * Load partials and return their contents. + * This method is only called during initalization, i.e. during server startup. + */ + loadPartials?(): Promise; +} + /** * @description * A function used to define template variables available to email templates.