Преглед изворни кода

feat(server): Passable order confirmation email implementation

Relates to #39. The design could use some work but the basic functionality is there
Michael Bromley пре 7 година
родитељ
комит
f825c8930a

+ 1 - 0
.gitignore

@@ -391,3 +391,4 @@ server/e2e/__data__/*
 server/assets
 !server/e2e/__data__/.gitkeep
 server/test-emails
+server/src/email/preview

+ 1 - 1
docs/diagrams/email-component-diagram.puml

@@ -18,7 +18,7 @@ package EmailEventHandlers {
 node EmailContext
 note right of EmailContext
     new EmailContext({
-        type: 'order-receipt',
+        type: 'order-confirmation',
         recipient: 'user@example.com',
         data: { ... }
     })

+ 3 - 1
server/dev-config.ts

@@ -4,6 +4,7 @@ import { API_PATH, API_PORT } from 'shared/shared-constants';
 import { fakePalPaymentHandler } from './src/config/payment-method/fakepal-payment-method-config';
 import { gripePaymentHandler } from './src/config/payment-method/gripe-payment-method-config';
 import { OrderProcessOptions, VendureConfig } from './src/config/vendure-config';
+import { defaultEmailTypes } from './src/email/default-email-types';
 import { HandlebarsMjmlGenerator } from './src/email/handlebars-mjml-generator';
 import { DefaultAssetServerPlugin } from './src/plugin/default-asset-server/default-asset-server-plugin';
 
@@ -34,7 +35,8 @@ export const devConfig: VendureConfig = {
     },
     customFields: {},
     emailOptions: {
-        generator: new HandlebarsMjmlGenerator(),
+        emailTypes: defaultEmailTypes,
+        generator: new HandlebarsMjmlGenerator(path.join(__dirname, 'src', 'email', 'templates', 'partials')),
         transport: {
             type: 'file',
             outputPath: path.join(__dirname, 'test-emails'),

+ 4 - 1
server/package.json

@@ -13,7 +13,8 @@
     "test:cov": "jest --coverage",
     "test:e2e": "jest --config ./e2e/config/jest-e2e.json --runInBand",
     "test:e2e:watch": "jest --config ./e2e/config/jest-e2e.json --watch --runInBand",
-    "build": "rimraf dist && tsc -p tsconfig.build.json && gulp"
+    "build": "rimraf dist && tsc -p tsconfig.build.json && gulp",
+    "generate-test-email": "node -r ts-node/register -r tsconfig-paths/register src/email/generate-test-email.ts"
   },
   "main": "dist/index.js",
   "files": [
@@ -29,6 +30,7 @@
     "bcrypt": "^3.0.1",
     "body-parser": "^1.18.3",
     "cookie-session": "^2.0.0-beta.3",
+    "dateformat": "^3.0.3",
     "express": "^4.16.3",
     "fs-extra": "^7.0.0",
     "graphql": "^14.0.0",
@@ -74,6 +76,7 @@
     "jest": "^23.5.0",
     "node-libcurl": "^1.3.3",
     "nodemon": "^1.14.1",
+    "opn": "^5.4.0",
     "rimraf": "^2.6.2",
     "sql.js": "^0.5.0",
     "ts-jest": "^23.1.4",

+ 1 - 2
server/src/config/default-config.ts

@@ -3,7 +3,6 @@ import { API_PATH, API_PORT } from 'shared/shared-constants';
 import { CustomFields } from 'shared/shared-types';
 
 import { ReadOnlyRequired } from '../common/types/common-types';
-import { defaultEmailTypes } from '../email/default-email-types';
 
 import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
 import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
@@ -65,7 +64,7 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
         paymentMethodHandlers: [],
     },
     emailOptions: {
-        emailTypes: defaultEmailTypes,
+        emailTypes: {},
         generator: new NoopEmailGenerator(),
         transport: {
             type: 'none',

+ 1 - 1
server/src/config/email/email-options.ts

@@ -52,7 +52,7 @@ export function configEmailType<T extends string, E extends VendureEvent = Vendu
 export interface EmailGenerator<T extends string = any, E extends VendureEvent = any> {
     generate(
         subject: string,
-        template: string,
+        body: string,
         context: EmailContext<T, E>,
     ): GeneratedEmailContext<T, E> | Promise<GeneratedEmailContext<T, E>>;
 }

+ 9 - 4
server/src/email/default-email-types.ts

@@ -6,10 +6,10 @@ import { configEmailType, EmailTypes } from '../config/email/email-options';
 
 import { OrderStateTransitionEvent } from '../event-bus/events/order-state-transition-event';
 
-export type DefaultEmailType = 'order-receipt';
+export type DefaultEmailType = 'order-confirmation';
 
 export const defaultEmailTypes: EmailTypes<DefaultEmailType> = {
-    'order-receipt': configEmailType({
+    'order-confirmation': configEmailType({
         triggerEvent: OrderStateTransitionEvent,
         createContext: e => {
             const customer = e.order.customer;
@@ -25,8 +25,13 @@ export const defaultEmailTypes: EmailTypes<DefaultEmailType> = {
             defaultChannel: {
                 defaultLanguage: {
                     templateContext: emailContext => ({ order: emailContext.event.order }),
-                    subject: `Your order receipt for {{ order.code }}`,
-                    templatePath: path.join(__dirname, 'templates', 'order-receipt', 'order-receipt.hbs'),
+                    subject: `Order confirmation for #{{ order.code }}`,
+                    templatePath: path.join(
+                        __dirname,
+                        'templates',
+                        'order-confirmation',
+                        'order-confirmation.hbs',
+                    ),
                 },
             },
         },

+ 169 - 0
server/src/email/generate-test-email.ts

@@ -0,0 +1,169 @@
+import * as fs from 'fs-extra';
+import * as opn from 'opn';
+import * as path from 'path';
+import { LanguageCode } from 'shared/generated-types';
+
+import { RequestContext } from '../api/common/request-context';
+import { NoopEmailGenerator } from '../config/email/noop-email-generator';
+import { EmailOptions } from '../config/vendure-config';
+import { Channel } from '../entity/channel/channel.entity';
+import { Customer } from '../entity/customer/customer.entity';
+import { OrderItem } from '../entity/order-item/order-item.entity';
+import { OrderLine } from '../entity/order-line/order-line.entity';
+import { Order } from '../entity/order/order.entity';
+import { ProductVariant } from '../entity/product-variant/product-variant.entity';
+import { OrderStateTransitionEvent } from '../event-bus/events/order-state-transition-event';
+
+import { defaultEmailTypes } from './default-email-types';
+import { EmailContext } from './email-context';
+import { HandlebarsMjmlGenerator } from './handlebars-mjml-generator';
+import { TemplateLoader } from './template-loader';
+// tslint:disable:no-console
+
+const generator = new HandlebarsMjmlGenerator(path.join(__dirname, 'templates', 'partials'));
+const emailOptions: EmailOptions<any> = {
+    emailTypes: defaultEmailTypes,
+    generator: new NoopEmailGenerator(),
+    transport: {
+        type: 'none',
+    },
+};
+const loader = new TemplateLoader({ emailOptions } as any);
+
+const emailType = process.argv[2];
+if (!emailType) {
+    failWith(`Please specify an emailType as the first argument. Example: order-confirmation`);
+}
+
+// tslint:disable-next-line
+generateEmail(emailType);
+
+/**
+ * Generates an .html file for the emailType specified as the first argument to the script.
+ */
+async function generateEmail(type: string) {
+    let emailContext: EmailContext | undefined;
+    switch (type) {
+        case 'order-confirmation':
+            emailContext = getOrderReceiptContext();
+    }
+    if (!emailContext) {
+        failWith(`Could not create a context for type "${type}"`);
+        return;
+    }
+    const { subject, body, templateContext } = await loader.loadTemplate(type, emailContext);
+    const generatedEmailContext = await generator.generate(subject, body, templateContext);
+
+    const previewDir = path.join(__dirname, 'preview');
+    await fs.ensureDir(previewDir);
+    await fs.writeFile(path.join(previewDir, `${type}.html`), generatedEmailContext.body);
+    await opn(path.join(previewDir, `${type}.html`));
+}
+
+function getOrderReceiptContext(): EmailContext<'order-confirmation', OrderStateTransitionEvent> | undefined {
+    const event = new OrderStateTransitionEvent(
+        'ArrangingPayment',
+        'PaymentSettled',
+        createRequestContext(),
+        new Order({
+            id: '6',
+            createdAt: '2018-10-31T15:18:29.261Z',
+            updatedAt: '2018-10-31T15:24:17.000Z',
+            code: 'T3EPGJKTVZPBD6Z9',
+            state: 'ArrangingPayment',
+            active: true,
+            customer: new Customer({
+                id: '3',
+                firstName: 'Horacio',
+                lastName: 'Franecki',
+            }),
+            lines: [
+                new OrderLine({
+                    id: '5',
+                    featuredAsset: {
+                        preview: 'http://localhost:3000/assets/mikkel-bech-748940-unsplash__49__preview.jpg',
+                    },
+                    productVariant: new ProductVariant({
+                        id: '3',
+                        name: 'en Intelligent Cotton Salad Small',
+                        sku: '5x7ss',
+                    }),
+                    items: [
+                        new OrderItem({
+                            id: '6',
+                            unitPrice: 745,
+                            unitPriceIncludesTax: false,
+                            taxRate: 20,
+                            pendingAdjustments: [],
+                        }),
+                    ],
+                }),
+                new OrderLine({
+                    id: '6',
+                    featuredAsset: {
+                        preview: 'http://localhost:3000/assets/mikkel-bech-748940-unsplash__49__preview.jpg',
+                    },
+                    productVariant: new ProductVariant({
+                        id: '4',
+                        name: 'en Intelligent Cotton Salad Large',
+                        sku: '5x7ss',
+                    }),
+                    items: [
+                        new OrderItem({
+                            id: '7',
+                            unitPrice: 745,
+                            unitPriceIncludesTax: false,
+                            taxRate: 20,
+                            pendingAdjustments: [],
+                        }),
+                    ],
+                }),
+            ],
+            subTotal: 1788,
+            subTotalBeforeTax: 1490,
+            shipping: 1000,
+            shippingMethod: {
+                code: 'express-flat-rate',
+                description: 'Express Shipping',
+                id: '2',
+            },
+            shippingAddress: {
+                fullName: 'Horacio Franecki',
+                company: '',
+                streetLine1: '6000 Pagac Land',
+                streetLine2: '',
+                city: 'Port Kirsten',
+                province: 'Avon',
+                postalCode: 'ZU32 9CP',
+                country: 'Cabo Verde',
+                phoneNumber: '',
+            },
+            payments: [],
+            pendingAdjustments: [],
+        }),
+    );
+
+    const contextConfig = defaultEmailTypes['order-confirmation'].createContext(event);
+    if (contextConfig) {
+        return new EmailContext({
+            ...contextConfig,
+            type: 'order-confirmation',
+            event,
+        });
+    }
+}
+
+function createRequestContext(): RequestContext {
+    return new RequestContext({
+        languageCode: LanguageCode.en,
+        session: {} as any,
+        isAuthorized: false,
+        authorizedAsOwnerOnly: true,
+        channel: new Channel(),
+    });
+}
+
+function failWith(message: string) {
+    console.error(message);
+    process.exit(1);
+}

+ 29 - 0
server/src/email/handlebars-mjml-generator.ts

@@ -1,5 +1,8 @@
+import * as dateFormat from 'dateformat';
+import * as fs from 'fs-extra';
 import * as Handlebars from 'handlebars';
 import mjml2html from 'mjml';
+import * as path from 'path';
 
 import { EmailGenerator } from '../config/email/email-options';
 
@@ -10,6 +13,11 @@ import { EmailContext, GeneratedEmailContext } from './email-context';
  * compiled down to responsive email HTML.
  */
 export class HandlebarsMjmlGenerator implements EmailGenerator {
+    constructor(partialsPath: string) {
+        this.registerPartials(partialsPath);
+        this.registerHelpers();
+    }
+
     generate(subject: string, template: string, context: EmailContext): GeneratedEmailContext {
         const compiledTemplate = Handlebars.compile(template);
         const compiledSubject = Handlebars.compile(subject);
@@ -18,4 +26,25 @@ export class HandlebarsMjmlGenerator implements EmailGenerator {
         const bodyResult = mjml2html(mjml);
         return new GeneratedEmailContext(context, subjectResult, bodyResult.html);
     }
+
+    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, format: string | object) => {
+            if (typeof format !== 'string') {
+                format = 'default';
+            }
+            return dateFormat(date, format);
+        });
+
+        Handlebars.registerHelper('formatMoney', (amount: number) => {
+            return (amount / 100).toFixed(2);
+        });
+    }
 }

+ 91 - 0
server/src/email/templates/order-confirmation/order-confirmation.hbs

@@ -0,0 +1,91 @@
+{{> header title="Order Receipt" }}
+
+<mj-section background-color="#fafafa">
+    <mj-column>
+
+        <mj-text font-style="italic"
+                 font-size="20px"
+                 font-family="Helvetica Neue"
+                 color="#626262">Order Confirmation</mj-text>
+
+        <mj-text color="#525252">
+            Dear {{ order.customer.firstName }} {{ order.customer.lastName }},
+        </mj-text>
+        <mj-text color="#525252">
+            Thank you for your order!
+        </mj-text>
+
+    </mj-column>
+</mj-section>
+
+<mj-section background-color="#568feb" padding-bottom="15px" padding-top="15">
+    <mj-column>
+        <mj-text align="center" color="#FFF" font-size="15px" padding-left="25px" padding-right="25px" padding-bottom="0px" padding-top="20"><strong>Order Code</strong></mj-text>
+        <mj-text align="center" color="#FFF" font-size="13px" padding-left="25px" padding-right="25px" padding-bottom="20px" padding-top="10">{{ order.code }}</mj-text>
+    </mj-column>
+    <mj-column>
+        <mj-text align="center" color="#FFF" font-size="15px" padding-left="25px" padding-right="25px" padding-bottom="0px" padding-top="20"><strong>Order Date</strong></mj-text>
+        <mj-text align="center" color="#FFF" font-size="13px" padding-left="25px" padding-right="25px" padding-bottom="20px" padding-top="10">{{ formatDate order.orderPlacedAt }}</mj-text>
+    </mj-column>
+    <mj-column>
+        <mj-text align="center" color="#FFF" font-size="15px" padding-left="25px" padding-right="25px" padding-bottom="0px" padding-top="20"><strong>Total Price</strong></mj-text>
+        <mj-text align="center" color="#FFF" font-size="13px" padding-left="25px" padding-right="25px" padding-bottom="20px" padding-top="10">${{ formatMoney order.total }}</mj-text>
+    </mj-column>
+</mj-section>
+
+
+<mj-section background-color="#f5f5f5">
+    <mj-column>
+        <mj-text>
+            {{#with order.shippingAddress }}
+                <h3>Shipping To: {{ fullName }}</h3>
+                <ul class="address">
+                    {{#if company}}<li>{{ company }}</li>{{/if}}
+                    {{#if streetLine1}}<li>{{ streetLine1 }}</li>{{/if}}
+                    {{#if streetLine2}}<li>{{ streetLine2 }}</li>{{/if}}
+                    {{#if city}}<li>{{ city }}</li>{{/if}}
+                    {{#if province}}<li>{{ province }}</li>{{/if}}
+                    {{#if postalCode}}<li>{{ postalCode }}</li>{{/if}}
+                    {{#if country}}<li>{{ country }}</li>{{/if}}
+                    {{#if phoneNumber}}<li>{{ phoneNumber }}</li>{{/if}}
+                </ul>
+            {{/with}}
+        </mj-text>
+    </mj-column>
+</mj-section>
+
+<mj-section>
+    <mj-column>
+        <mj-text>
+            <h3>Order Summary:</h3>
+        </mj-text>
+        <mj-table cellpadding="6px">
+            {{#each order.lines }}
+                <tr class="order-row">
+                    <td>
+                        <img alt="{{ productVariant.name }}"
+                             style="width: 50px; height: 50px;"
+                             src="{{ featuredAsset.preview }}?w=50&h=50" />
+                    </td>
+                    <td>{{ quantity }} x {{ productVariant.name }}</td>
+                    <td>{{ productVariant.quantity }}</td>
+                    <td>${{ formatMoney totalPrice }}</td>
+                </tr>
+            {{/each}}
+            <tr class="order-row">
+                <td colspan="3">Sub-total:</td>
+                <td>${{ formatMoney order.subTotal }}</td>
+            </tr>
+            <tr class="order-row">
+                <td colspan="3">Shipping ({{ order.shippingMethod.description }}):</td>
+                <td>${{ formatMoney order.shipping }}</td>
+            </tr>
+            <tr class="order-row total-row">
+                <td colspan="3">Total:</td>
+                <td>${{ formatMoney order.total }}</td>
+            </tr>
+        </mj-table>
+    </mj-column>
+</mj-section>
+
+{{> footer }}

+ 0 - 30
server/src/email/templates/order-receipt/order-receipt.hbs

@@ -1,30 +0,0 @@
-<mjml>
-    <mj-body>
-
-        <!-- Company Header -->
-        <mj-section background-color="#f0f0f0"></mj-section>
-
-        <!-- Image Header -->
-        <mj-section background-color="#f0f0f0"></mj-section>
-
-        <!-- Introduction Text -->
-        <mj-section background-color="#fafafa">
-            <mj-column width="400">
-
-                <mj-text font-style="italic"
-                         font-size="20px"
-                         font-family="Helvetica Neue"
-                         color="#626262">Order Receipt</mj-text>
-
-                <mj-text color="#525252">
-                    Hello {{ order.customer.firstName }} {{ order.customer.lastName }}! Thanks for your order.
-                </mj-text>
-
-                <mj-button background-color="#F45E43"
-                           href="#">Learn more</mj-button>
-
-            </mj-column>
-        </mj-section>
-
-    </mj-body>
-</mjml>

+ 9 - 0
server/src/email/templates/partials/footer.hbs

@@ -0,0 +1,9 @@
+<!--suppress ALL -->
+<mj-section background-color="#568feb" padding-bottom="5px" padding-top="0">
+    <mj-column width="100%">
+        <mj-text align="center" color="#FFF" >
+            <span style="font-size:15px">Footer text</span></mj-text>
+    </mj-column>
+</mj-section>
+</mj-body>
+</mjml>

+ 32 - 0
server/src/email/templates/partials/header.hbs

@@ -0,0 +1,32 @@
+<mjml>
+    <mj-head>
+        <mj-title>{{ title }}</mj-title>
+        <mj-style inline="inline">
+            h3 {
+            font-size: 18px;
+            color: #555;
+            font-weight: normal;
+            }
+            ul.address {
+            list-style-type: none;
+            padding: 0;
+            }
+            tr.order-row td {
+            border-bottom: 1px dashed #eee;
+            }
+            tr.order-row td:last-child {
+            text-align: center;
+            }
+            tr.total-row {
+            font-weight: bold;
+            }
+        </mj-style>
+    </mj-head>
+
+    <mj-body>
+        <!-- Company Header -->
+        <mj-section background-color="#f0f0f0">
+            <mj-column>
+                <mj-text>Company Header</mj-text>
+            </mj-column>
+        </mj-section>

+ 14 - 0
server/yarn.lock

@@ -1631,6 +1631,10 @@ datauri@^1.0.4:
     mimer "^0.3.2"
     semver "^5.5.0"
 
+dateformat@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
+
 debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@@ -3256,6 +3260,10 @@ is-windows@^1.0.0, is-windows@^1.0.1, is-windows@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
 
+is-wsl@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d"
+
 isarray@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
@@ -4925,6 +4933,12 @@ once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.4.0:
   dependencies:
     wrappy "1"
 
+opn@^5.4.0:
+  version "5.4.0"
+  resolved "https://registry.yarnpkg.com/opn/-/opn-5.4.0.tgz#cb545e7aab78562beb11aa3bfabc7042e1761035"
+  dependencies:
+    is-wsl "^1.1.0"
+
 optimist@^0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"