diff --git a/packages/email-plugin/package.json b/packages/email-plugin/package.json index 4376bc319a..d5472000a6 100644 --- a/packages/email-plugin/package.json +++ b/packages/email-plugin/package.json @@ -22,7 +22,7 @@ "dateformat": "^3.0.3", "express": "^4.16.4", "fs-extra": "^8.0.1", - "handlebars": "^4.1.2", + "handlebars": "^4.7.3", "mjml": "^4.3.0", "nodemailer": "^5.0.0" }, diff --git a/packages/email-plugin/src/handlebars-mjml-generator.ts b/packages/email-plugin/src/handlebars-mjml-generator.ts index 92543598b4..38bd5e9f4b 100644 --- a/packages/email-plugin/src/handlebars-mjml-generator.ts +++ b/packages/email-plugin/src/handlebars-mjml-generator.ts @@ -21,9 +21,14 @@ export class HandlebarsMjmlGenerator implements EmailGenerator { const compiledFrom = Handlebars.compile(from); const compiledSubject = Handlebars.compile(subject); const compiledTemplate = Handlebars.compile(template); - const fromResult = compiledFrom(templateVars); - const subjectResult = compiledSubject(templateVars); - const mjml = compiledTemplate(templateVars); + // We enable prototype properties here, aware of the security implications + // described here: https://handlebarsjs.com/api-reference/runtime-options.html#options-to-control-prototype-access + // This is needed because some Vendure entities use getters on the entity + // prototype (e.g. Order.total) which may need to be interpolated. + const templateOptions: RuntimeOptions = { allowProtoPropertiesByDefault: true }; + const fromResult = compiledFrom(templateVars, { allowProtoPropertiesByDefault: true }); + const subjectResult = compiledSubject(templateVars, { allowProtoPropertiesByDefault: true }); + const mjml = compiledTemplate(templateVars, { allowProtoPropertiesByDefault: true }); const body = mjml2html(mjml).html; return { from: fromResult, subject: subjectResult, body }; } @@ -37,14 +42,20 @@ export class HandlebarsMjmlGenerator implements EmailGenerator { } private registerHelpers() { - Handlebars.registerHelper('formatDate', (date: Date, format: string | object) => { + Handlebars.registerHelper('formatDate', (date: Date | undefined, format: string | object) => { + if (!date) { + return date; + } if (typeof format !== 'string') { format = 'default'; } return dateFormat(date, format); }); - Handlebars.registerHelper('formatMoney', (amount: number) => { + Handlebars.registerHelper('formatMoney', (amount?: number) => { + if (amount == null) { + return amount; + } return (amount / 100).toFixed(2); }); } diff --git a/packages/email-plugin/src/plugin.spec.ts b/packages/email-plugin/src/plugin.spec.ts index 595a501745..360037c687 100644 --- a/packages/email-plugin/src/plugin.spec.ts +++ b/packages/email-plugin/src/plugin.spec.ts @@ -8,6 +8,7 @@ import { Order, OrderStateTransitionEvent, PluginCommonModule, + RequestContext, VendureEvent, } from '@vendure/core'; import path from 'path'; @@ -22,16 +23,18 @@ describe('EmailPlugin', () => { let plugin: EmailPlugin; let eventBus: EventBus; let onSend: jest.Mock; + let module: TestingModule; async function initPluginWithHandlers( handlers: Array>, options?: Partial, ) { onSend = jest.fn(); - const module = await Test.createTestingModule({ + module = await Test.createTestingModule({ imports: [ TypeOrmModule.forRoot({ type: 'sqljs', + retryAttempts: 0, }), PluginCommonModule, EmailPlugin.init({ @@ -53,11 +56,17 @@ describe('EmailPlugin', () => { return module; } + afterEach(async () => { + if (module) { + await module.close(); + } + }); + it('setting from, recipient, subject', async () => { - const ctx = { - channel: { code: DEFAULT_CHANNEL_CODE }, - languageCode: LanguageCode.en, - } as any; + const ctx = RequestContext.fromObject({ + _channel: { code: DEFAULT_CHANNEL_CODE }, + _languageCode: LanguageCode.en, + }); const handler = new EmailEventListener('test') .on(MockEvent) .setFrom('"test from" ') @@ -65,21 +74,20 @@ describe('EmailPlugin', () => { .setSubject('Hello') .setTemplateVars(event => ({ subjectVar: 'foo' })); - const module = await initPluginWithHandlers([handler]); + await initPluginWithHandlers([handler]); eventBus.publish(new MockEvent(ctx, true)); await pause(); expect(onSend.mock.calls[0][0].subject).toBe('Hello'); expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com'); expect(onSend.mock.calls[0][0].from).toBe('"test from" '); - await module.close(); }); describe('event filtering', () => { - const ctx = { - channel: { code: DEFAULT_CHANNEL_CODE }, - languageCode: LanguageCode.en, - } as any; + const ctx = RequestContext.fromObject({ + _channel: { code: DEFAULT_CHANNEL_CODE }, + _languageCode: LanguageCode.en, + }); it('single filter', async () => { const handler = new EmailEventListener('test') @@ -89,7 +97,7 @@ describe('EmailPlugin', () => { .setFrom('"test from" ') .setSubject('test subject'); - const module = await initPluginWithHandlers([handler]); + await initPluginWithHandlers([handler]); eventBus.publish(new MockEvent(ctx, false)); await pause(); @@ -98,28 +106,28 @@ describe('EmailPlugin', () => { eventBus.publish(new MockEvent(ctx, true)); await pause(); expect(onSend).toHaveBeenCalledTimes(1); - await module.close(); }); it('multiple filters', async () => { const handler = new EmailEventListener('test') .on(MockEvent) .filter(event => event.shouldSend === true) - .filter(event => !!event.ctx.user) + .filter(event => !!event.ctx.activeUserId) .setFrom('"test from" ') .setRecipient(() => 'test@test.com') .setSubject('test subject'); - const module = await initPluginWithHandlers([handler]); + await initPluginWithHandlers([handler]); eventBus.publish(new MockEvent(ctx, true)); await pause(); expect(onSend).not.toHaveBeenCalled(); - eventBus.publish(new MockEvent({ ...ctx, user: 'joe' }, true)); + const ctxWithUser = RequestContext.fromObject({ ...ctx, _session: { user: { id: 42 } } }); + + eventBus.publish(new MockEvent(ctxWithUser, true)); await pause(); expect(onSend).toHaveBeenCalledTimes(1); - await module.close(); }); it('with .loadData() after .filter()', async () => { @@ -131,7 +139,7 @@ describe('EmailPlugin', () => { .setFrom('"test from" ') .setSubject('test subject'); - const module = await initPluginWithHandlers([handler]); + await initPluginWithHandlers([handler]); eventBus.publish(new MockEvent(ctx, false)); await pause(); @@ -140,15 +148,14 @@ describe('EmailPlugin', () => { eventBus.publish(new MockEvent(ctx, true)); await pause(); expect(onSend).toHaveBeenCalledTimes(1); - await module.close(); }); }); describe('templateVars', () => { - const ctx = { - channel: { code: DEFAULT_CHANNEL_CODE }, - languageCode: LanguageCode.en, - } as any; + const ctx = RequestContext.fromObject({ + _channel: { code: DEFAULT_CHANNEL_CODE }, + _languageCode: LanguageCode.en, + }); it('interpolates subject', async () => { const handler = new EmailEventListener('test') @@ -158,12 +165,11 @@ describe('EmailPlugin', () => { .setSubject('Hello {{ subjectVar }}') .setTemplateVars(event => ({ subjectVar: 'foo' })); - const module = await initPluginWithHandlers([handler]); + await initPluginWithHandlers([handler]); eventBus.publish(new MockEvent(ctx, true)); await pause(); expect(onSend.mock.calls[0][0].subject).toBe('Hello foo'); - await module.close(); }); it('interpolates body', async () => { @@ -174,12 +180,31 @@ describe('EmailPlugin', () => { .setSubject('Hello') .setTemplateVars(event => ({ testVar: 'this is the test var' })); - const module = await initPluginWithHandlers([handler]); + await initPluginWithHandlers([handler]); eventBus.publish(new MockEvent(ctx, true)); await pause(); expect(onSend.mock.calls[0][0].body).toContain('this is the test var'); - await module.close(); + }); + + /** + * Intended to test the ability for Handlebars to interpolate + * getters on the Order entity prototype. + * See https://github.com/vendure-ecommerce/vendure/issues/259 + */ + it('interpolates body with property from entity', async () => { + const handler = new EmailEventListener('test') + .on(MockEvent) + .setFrom('"test from" ') + .setRecipient(() => 'test@test.com') + .setSubject('Hello') + .setTemplateVars(event => ({ order: new Order({ subTotal: 123 }) })); + + await initPluginWithHandlers([handler]); + + eventBus.publish(new MockEvent(ctx, true)); + await pause(); + expect(onSend.mock.calls[0][0].body).toContain('Total: 123'); }); it('interpolates globalTemplateVars', async () => { @@ -189,14 +214,13 @@ describe('EmailPlugin', () => { .setRecipient(() => 'test@test.com') .setSubject('Hello {{ globalVar }}'); - const module = await initPluginWithHandlers([handler], { + await initPluginWithHandlers([handler], { globalTemplateVars: { globalVar: 'baz' }, }); eventBus.publish(new MockEvent(ctx, true)); await pause(); expect(onSend.mock.calls[0][0].subject).toBe('Hello baz'); - await module.close(); }); it('interpolates from', async () => { @@ -206,14 +230,13 @@ describe('EmailPlugin', () => { .setRecipient(() => 'test@test.com') .setSubject('Hello'); - const module = await initPluginWithHandlers([handler], { + await initPluginWithHandlers([handler], { globalTemplateVars: { globalVar: 'baz' }, }); eventBus.publish(new MockEvent(ctx, true)); await pause(); expect(onSend.mock.calls[0][0].from).toBe('"test from baz" '); - await module.close(); }); it('globalTemplateVars available in setTemplateVars method', async () => { @@ -224,14 +247,13 @@ describe('EmailPlugin', () => { .setSubject('Hello {{ testVar }}') .setTemplateVars((event, globals) => ({ testVar: globals.globalVar + ' quux' })); - const module = await initPluginWithHandlers([handler], { + await initPluginWithHandlers([handler], { globalTemplateVars: { globalVar: 'baz' }, }); eventBus.publish(new MockEvent(ctx, true)); await pause(); expect(onSend.mock.calls[0][0].subject).toBe('Hello baz quux'); - await module.close(); }); it('setTemplateVars overrides globals', async () => { @@ -242,20 +264,56 @@ describe('EmailPlugin', () => { .setSubject('Hello {{ name }}') .setTemplateVars((event, globals) => ({ name: 'quux' })); - const module = await initPluginWithHandlers([handler], { globalTemplateVars: { name: 'baz' } }); + await initPluginWithHandlers([handler], { globalTemplateVars: { name: 'baz' } }); eventBus.publish(new MockEvent(ctx, true)); await pause(); expect(onSend.mock.calls[0][0].subject).toBe('Hello quux'); - await module.close(); + }); + }); + + describe('handlebars helpers', () => { + const ctx = RequestContext.fromObject({ + _channel: { code: DEFAULT_CHANNEL_CODE }, + _languageCode: LanguageCode.en, + }); + + it('formateDate', async () => { + const handler = new EmailEventListener('test-helpers') + .on(MockEvent) + .setFrom('"test from" ') + .setRecipient(() => 'test@test.com') + .setSubject('Hello') + .setTemplateVars(event => ({ myDate: new Date('2020-01-01T10:00:00.000Z'), myPrice: 0 })); + + await initPluginWithHandlers([handler]); + + eventBus.publish(new MockEvent(ctx, true)); + await pause(); + expect(onSend.mock.calls[0][0].body).toContain('Date: Wed Jan 01 2020 11:00:00'); + }); + + it('formateMoney', async () => { + const handler = new EmailEventListener('test-helpers') + .on(MockEvent) + .setFrom('"test from" ') + .setRecipient(() => 'test@test.com') + .setSubject('Hello') + .setTemplateVars(event => ({ myDate: new Date(), myPrice: 123 })); + + await initPluginWithHandlers([handler]); + + eventBus.publish(new MockEvent(ctx, true)); + await pause(); + expect(onSend.mock.calls[0][0].body).toContain('Price: 1.23'); }); }); describe('multiple configs', () => { - const ctx = { - channel: { code: DEFAULT_CHANNEL_CODE }, - languageCode: LanguageCode.en, - } as any; + const ctx = RequestContext.fromObject({ + _channel: { code: DEFAULT_CHANNEL_CODE }, + _languageCode: LanguageCode.en, + }); it('additional LanguageCode', async () => { const handler = new EmailEventListener('test') @@ -271,18 +329,19 @@ describe('EmailPlugin', () => { subject: 'Servus, {{ name }}!', }); - const module = await initPluginWithHandlers([handler]); + await initPluginWithHandlers([handler]); - eventBus.publish(new MockEvent({ ...ctx, languageCode: LanguageCode.ta }, true)); + const ctxTa = RequestContext.fromObject({ ...ctx, _languageCode: LanguageCode.ta }); + eventBus.publish(new MockEvent(ctxTa, true)); await pause(); expect(onSend.mock.calls[0][0].subject).toBe('Hello, Test!'); expect(onSend.mock.calls[0][0].body).toContain('Default body.'); - eventBus.publish(new MockEvent({ ...ctx, languageCode: LanguageCode.de }, true)); + const ctxDe = RequestContext.fromObject({ ...ctx, _languageCode: LanguageCode.de }); + eventBus.publish(new MockEvent(ctxDe, true)); await pause(); expect(onSend.mock.calls[1][0].subject).toBe('Servus, Test!'); expect(onSend.mock.calls[1][0].body).toContain('German body.'); - await module.close(); }); }); @@ -299,18 +358,20 @@ describe('EmailPlugin', () => { .setRecipient(() => 'test@test.com') .setTemplateVars(event => ({ testData: event.data })); - const module = await initPluginWithHandlers([handler]); + await initPluginWithHandlers([handler]); eventBus.publish( new MockEvent( - { channel: { code: DEFAULT_CHANNEL_CODE }, languageCode: LanguageCode.en }, + RequestContext.fromObject({ + _channel: { code: DEFAULT_CHANNEL_CODE }, + _languageCode: LanguageCode.en, + }), true, ), ); await pause(); expect(onSend.mock.calls[0][0].subject).toBe('Hello, loaded data!'); - await module.close(); }); it('works when loadData is called after other setup', async () => { @@ -325,11 +386,14 @@ describe('EmailPlugin', () => { }) .setTemplateVars(event => ({ testData: event.data })); - const module = await initPluginWithHandlers([handler]); + await initPluginWithHandlers([handler]); eventBus.publish( new MockEvent( - { channel: { code: DEFAULT_CHANNEL_CODE }, languageCode: LanguageCode.en }, + RequestContext.fromObject({ + _channel: { code: DEFAULT_CHANNEL_CODE }, + _languageCode: LanguageCode.en, + }), true, ), ); @@ -338,27 +402,21 @@ describe('EmailPlugin', () => { expect(onSend.mock.calls[0][0].subject).toBe('Hello, loaded data!'); expect(onSend.mock.calls[0][0].from).toBe('"test from" '); expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com'); - await module.close(); }); }); describe('orderConfirmationHandler', () => { - let module: TestingModule; beforeEach(async () => { module = await initPluginWithHandlers([orderConfirmationHandler], { templatePath: path.join(__dirname, '../templates'), }); }); - afterEach(async () => { - await module.close(); + const ctx = RequestContext.fromObject({ + _channel: { code: DEFAULT_CHANNEL_CODE }, + _languageCode: LanguageCode.en, }); - const ctx = { - channel: { code: DEFAULT_CHANNEL_CODE }, - languageCode: LanguageCode.en, - } as any; - const order = ({ code: 'ABCDE', customer: { @@ -400,10 +458,10 @@ describe('EmailPlugin', () => { }); }); -const pause = () => new Promise(resolve => setTimeout(resolve, 50)); +const pause = () => new Promise(resolve => setTimeout(resolve, 100)); class MockEvent extends VendureEvent { - constructor(public ctx: any, public shouldSend: boolean) { + constructor(public ctx: RequestContext, public shouldSend: boolean) { super(); } } diff --git a/packages/email-plugin/test-templates/test-helpers/body.hbs b/packages/email-plugin/test-templates/test-helpers/body.hbs new file mode 100644 index 0000000000..89992e15b1 --- /dev/null +++ b/packages/email-plugin/test-templates/test-helpers/body.hbs @@ -0,0 +1,10 @@ +{{> header }} + + + + Price: {{ formatMoney myPrice }} + Date: {{ formatDate myDate }} + + + +{{> footer }} diff --git a/packages/email-plugin/test-templates/test/body.hbs b/packages/email-plugin/test-templates/test/body.hbs index a093a8c34a..20422ab78a 100644 --- a/packages/email-plugin/test-templates/test/body.hbs +++ b/packages/email-plugin/test-templates/test/body.hbs @@ -3,6 +3,7 @@ Default body. {{ testVar }} + Total: {{ order.total }} diff --git a/yarn.lock b/yarn.lock index 7e8947e65d..3828498a45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7573,6 +7573,17 @@ handlebars@^4.4.0: optionalDependencies: uglify-js "^3.1.4" +handlebars@^4.7.3: + version "4.7.3" + resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.3.tgz#8ece2797826886cf8082d1726ff21d2a022550ee" + integrity sha512-SRGwSYuNfx8DwHD/6InAPzD6RgeruWLT+B8e8a7gGs8FWgHzlExpTFMEq2IA6QpAfOClpKHy6+8IqTjeBCu6Kg== + dependencies: + neo-async "^2.6.0" + optimist "^0.6.1" + source-map "^0.6.1" + optionalDependencies: + uglify-js "^3.1.4" + har-schema@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"