Просмотр исходного кода

fix(email-plugin): Fix Handlebars "cannot resolve property" error

Closes #259
Michael Bromley 6 лет назад
Родитель
Сommit
2984a90333

+ 1 - 1
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"
   },

+ 16 - 5
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);
         });
     }

+ 117 - 59
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<EmailEventHandler<string, any>>,
         options?: Partial<EmailPluginOptions>,
     ) {
         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" <noreply@test.com>')
@@ -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" <noreply@test.com>');
-        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" <noreply@test.com>')
                 .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" <noreply@test.com>')
                 .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" <noreply@test.com>')
                 .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" <noreply@test.com>')
+                .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" <noreply@test.com>');
-            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" <noreply@test.com>')
+                .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" <noreply@test.com>')
+                .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" <noreply@test.com>');
             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();
     }
 }

+ 10 - 0
packages/email-plugin/test-templates/test-helpers/body.hbs

@@ -0,0 +1,10 @@
+{{> header }}
+
+<mj-section>
+    <mj-column>
+        <mj-text>Price: {{ formatMoney myPrice }}</mj-text>
+        <mj-text>Date: {{ formatDate myDate }}</mj-text>
+    </mj-column>
+</mj-section>
+
+{{> footer }}

+ 1 - 0
packages/email-plugin/test-templates/test/body.hbs

@@ -3,6 +3,7 @@
 <mj-section>
     <mj-column>
         <mj-text>Default body. {{ testVar }}</mj-text>
+        <mj-text>Total: {{ order.total }}</mj-text>
     </mj-column>
 </mj-section>
 

+ 11 - 0
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"