Skip to content

Commit

Permalink
feat(email-plugin): Add support for dynamic templates & SMTP settings
Browse files Browse the repository at this point in the history
Closes #2043, closes #2044 

Co-authored-by: Martijn <martijn.brug@gmail.com>
  • Loading branch information
dalyathan and martijnvdbrug authored Apr 28, 2023
1 parent d628659 commit c6686cd
Show file tree
Hide file tree
Showing 9 changed files with 292 additions and 58 deletions.
16 changes: 15 additions & 1 deletion packages/email-plugin/src/common.ts
Original file line number Diff line number Diff line change
@@ -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<EmailTransportOptions> {
if (typeof options.transport === 'function') {
return options.transport(injector, ctx);
} else {
return options.transport;
}
}
62 changes: 49 additions & 13 deletions packages/email-plugin/src/email-processor.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<EmailTransportOptions>);

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
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -80,4 +104,16 @@ export class EmailProcessor {
throw err;
}
}

async getTransportSettings(ctx?: RequestContext): Promise<EmailTransportOptions> {
if (isDevModeOptions(this.options)) {
return {
type: 'file',
raw: false,
outputPath: this.options.outputPath,
};
} else {
return resolveTransportSettings(this.options, new Injector(this.moduleRef), ctx);
}
}
}
18 changes: 11 additions & 7 deletions packages/email-plugin/src/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
};
private _mockEvent: Omit<Event, 'ctx' | 'data'> | undefined;

constructor(public listener: EmailEventListener<T>, public event: Type<Event>) {}
constructor(public listener: EmailEventListener<T>, public event: Type<Event>) { }

/** @internal */
get type(): T {
Expand Down Expand Up @@ -268,6 +268,9 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
* @description
* Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific
* templates for channels or languageCodes other than the default.
*
* @deprecated Define a custom TemplateLoader on plugin initalization to define templates based on the RequestContext.
* E.g. `EmailPlugin.init({ templateLoader: new CustomTemplateLoader() })`
*/
addTemplate(config: EmailTemplateConfig): EmailEventHandler<T, Event> {
this.configurations.push(config);
Expand Down Expand Up @@ -346,14 +349,14 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
}
if (!this.setRecipientFn) {
throw new Error(
'No setRecipientFn has been defined. ' +
`Remember to call ".setRecipient()" when setting up the EmailEventHandler for ${this.type}`,
`No setRecipientFn has been defined. ` +
`Remember to call ".setRecipient()" when setting up the EmailEventHandler for ${this.type}`,
);
}
if (this.from === undefined) {
throw new Error(
'No from field has been defined. ' +
`Remember to call ".setFrom()" when setting up the EmailEventHandler for ${this.type}`,
`No from field has been defined. ` +
`Remember to call ".setFrom()" when setting up the EmailEventHandler for ${this.type}`,
);
}
const { ctx } = event;
Expand All @@ -362,8 +365,8 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
const subject = configuration ? configuration.subject : this.defaultSubject;
if (subject == null) {
throw new Error(
'No subject field has been defined. ' +
`Remember to call ".setSubject()" when setting up the EmailEventHandler for ${this.type}`,
`No subject field has been defined. ` +
`Remember to call ".setSubject()" when setting up the EmailEventHandler for ${this.type}`,
);
}
const recipient = this.setRecipientFn(event);
Expand All @@ -377,6 +380,7 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
const attachments = await serializeAttachments(attachmentsArray);
const optionalAddressFields = (await this.setOptionalAddressFieldsFn?.(event)) ?? {};
return {
ctx: event.ctx.serialize(),
type: this.type,
recipient,
from: this.from,
Expand Down
23 changes: 11 additions & 12 deletions packages/email-plugin/src/handlebars-mjml-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import mjml2html from 'mjml';
import path from 'path';

import { EmailGenerator } from './email-generator';
import { EmailPluginDevModeOptions, EmailPluginOptions } from './types';
import {
EmailPluginDevModeOptions,
EmailPluginOptions,
InitializedEmailPluginOptions,
Partial,
} from './types';

/**
* @description
Expand All @@ -16,9 +21,11 @@ import { EmailPluginDevModeOptions, EmailPluginOptions } from './types';
* @docsPage EmailGenerator
*/
export class HandlebarsMjmlGenerator implements EmailGenerator {
onInit(options: EmailPluginOptions | EmailPluginDevModeOptions) {
const partialsPath = path.join(options.templatePath, 'partials');
this.registerPartials(partialsPath);
async onInit(options: InitializedEmailPluginOptions) {
if (options.templateLoader.loadPartials) {
const partials = await options.templateLoader.loadPartials();
partials.forEach(({ name, content }) => Handlebars.registerPartial(name, content));
}
this.registerHelpers();
}

Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/email-plugin/src/mock-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
Expand Down
53 changes: 52 additions & 1 deletion packages/email-plugin/src/plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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" <noreply@test.com>')
.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 {
Expand Down
Loading

0 comments on commit c6686cd

Please sign in to comment.