Explorar el Código

feat(server): Implement email generation based on Handlebars and MJML

Relates to #39. Still need to make the templates themselves for each of the defaul email types
Michael Bromley hace 7 años
padre
commit
31e923c045

+ 2 - 0
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 { HandlebarsMjmlGenerator } from './src/email/handlebars-mjml-generator';
 import { DefaultAssetServerPlugin } from './src/plugin/default-asset-server/default-asset-server-plugin';
 
 /**
@@ -33,6 +34,7 @@ export const devConfig: VendureConfig = {
     },
     customFields: {},
     emailOptions: {
+        generator: new HandlebarsMjmlGenerator(),
         transport: {
             type: 'file',
             outputPath: path.join(__dirname, 'test-emails'),

+ 4 - 0
server/package.json

@@ -36,11 +36,13 @@
     "graphql-tag": "^2.9.2",
     "graphql-tools": "^3.1.1",
     "graphql-type-json": "^0.2.1",
+    "handlebars": "^4.0.12",
     "http-proxy-middleware": "^0.19.0",
     "i18next": "^11.6.0",
     "i18next-express-middleware": "^1.3.2",
     "i18next-icu": "^0.4.0",
     "i18next-node-fs-backend": "^2.0.0",
+    "mjml": "^4.2.0",
     "ms": "^2.1.1",
     "mysql": "^2.16.0",
     "nanoid": "^1.2.4",
@@ -57,9 +59,11 @@
     "@types/express": "^4.0.39",
     "@types/faker": "^4.1.3",
     "@types/fs-extra": "^5.0.4",
+    "@types/handlebars": "^4.0.39",
     "@types/i18next": "^8.4.3",
     "@types/i18next-express-middleware": "^0.0.33",
     "@types/jest": "^23.3.1",
+    "@types/mjml": "^4.0.1",
     "@types/nanoid": "^1.2.0",
     "@types/node": "^9.3.0",
     "@types/nodemailer": "^4.6.5",

+ 24 - 3
server/src/config/email/email-options.ts

@@ -4,26 +4,43 @@ import { Type } from 'shared/shared-types';
 import { EmailContext, GeneratedEmailContext } from '../../email/email-context';
 import { VendureEvent } from '../../event-bus/vendure-event';
 
-export type TemplateConfig<R = any> = {
-    subject: (data: R) => string;
+export type TemplateConfig<C = any, R = any> = {
+    /**
+     * A function which uses the EmailContext to provide a context object for the
+     * template engine. That is, the templates will have access to the object
+     * returned by this function.
+     */
+    templateContext: (emailContext: C) => R;
+    /**
+     * The subject line for the email.
+     */
+    subject: string;
+    /**
+     * The path to the template file for the body of the email.
+     */
     templatePath: string;
 };
+
 export type TemplateByLanguage<C = any> = { defaultLanguage: TemplateConfig<C> } & {
     [languageCode: string]: TemplateConfig<C>;
 };
+
 export type TemplateByChannel<C = any> = { defaultChannel: TemplateByLanguage<C> } & {
     [channelCode: string]: TemplateByLanguage<C>;
 };
+
 export type CreateContextResult = {
     recipient: string;
     languageCode: LanguageCode;
     channelCode: string;
 };
+
 export type EmailTypeConfig<T extends string, E extends VendureEvent = any> = {
     triggerEvent: Type<E>;
     createContext: (event: E) => CreateContextResult | undefined;
     templates: TemplateByChannel<EmailContext<T, E>>;
 };
+
 export type EmailTypes<T extends string> = { [emailType in T]: EmailTypeConfig<T> };
 
 export function configEmailType<T extends string, E extends VendureEvent = VendureEvent>(
@@ -33,5 +50,9 @@ export function configEmailType<T extends string, E extends VendureEvent = Vendu
 }
 
 export interface EmailGenerator<T extends string = any, E extends VendureEvent = any> {
-    generate(context: EmailContext<T, E>): GeneratedEmailContext<T, E> | Promise<GeneratedEmailContext<T, E>>;
+    generate(
+        subject: string,
+        template: string,
+        context: EmailContext<T, E>,
+    ): GeneratedEmailContext<T, E> | Promise<GeneratedEmailContext<T, E>>;
 }

+ 5 - 2
server/src/config/email/noop-email-generator.ts

@@ -2,8 +2,11 @@ import { EmailContext, GeneratedEmailContext } from '../../email/email-context';
 
 import { EmailGenerator } from './email-options';
 
+/**
+ * Simply passes through the subject and template content without modification.
+ */
 export class NoopEmailGenerator implements EmailGenerator {
-    generate(context: EmailContext): GeneratedEmailContext | Promise<GeneratedEmailContext> {
-        return new GeneratedEmailContext(context, 'email subject', 'email subject');
+    generate(subject: string, template: string, context: EmailContext): GeneratedEmailContext {
+        return new GeneratedEmailContext(context, subject, template);
     }
 }

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

@@ -1,3 +1,4 @@
+import * as path from 'path';
 import { LanguageCode } from 'shared/generated-types';
 import { DEFAULT_CHANNEL_CODE } from 'shared/shared-constants';
 
@@ -23,8 +24,9 @@ export const defaultEmailTypes: EmailTypes<DefaultEmailType> = {
         templates: {
             defaultChannel: {
                 defaultLanguage: {
-                    subject: data => `Your order receipt`,
-                    templatePath: 'awd',
+                    templateContext: emailContext => ({ order: emailContext.event.order }),
+                    subject: `Your order receipt for {{ order.code }}`,
+                    templatePath: path.join(__dirname, 'templates', 'order-receipt', 'order-receipt.hbs'),
                 },
             },
         },

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

@@ -0,0 +1,21 @@
+import * as Handlebars from 'handlebars';
+import mjml2html from 'mjml';
+
+import { EmailGenerator } from '../config/email/email-options';
+
+import { EmailContext, GeneratedEmailContext } from './email-context';
+
+/**
+ * Uses Handlebars (https://handlebarsjs.com/) to output MJML (https://mjml.io) which is then
+ * compiled down to responsive email HTML.
+ */
+export class HandlebarsMjmlGenerator implements EmailGenerator {
+    generate(subject: string, template: string, context: EmailContext): GeneratedEmailContext {
+        const compiledTemplate = Handlebars.compile(template);
+        const compiledSubject = Handlebars.compile(subject);
+        const subjectResult = compiledSubject(context);
+        const mjml = compiledTemplate(context);
+        const bodyResult = mjml2html(mjml);
+        return new GeneratedEmailContext(context, subjectResult, bodyResult.html);
+    }
+}

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

@@ -0,0 +1,30 @@
+<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>

+ 25 - 2
server/src/email/transactional-email.service.ts

@@ -9,7 +9,7 @@ import { assertNever } from 'shared/shared-utils';
 import * as Stream from 'stream';
 
 import { ConfigService } from '../config/config.service';
-import { EmailTypeConfig } from '../config/email/email-options';
+import { EmailTypeConfig, TemplateConfig } from '../config/email/email-options';
 import { FileTransportOptions } from '../config/email/email-transport-options';
 import { EventBus } from '../event-bus/event-bus';
 import { VendureEvent } from '../event-bus/vendure-event';
@@ -53,11 +53,34 @@ export class TransactionalEmailService {
                 type,
                 event,
             });
-            const generatedEmailContext = await generator.generate(emailContext);
+            const templateConfig = this.getTemplateConfig(type, emailContext);
+            const templateContents = await this.loadTemplateContents(templateConfig.templatePath);
+            const templateContext = templateConfig.templateContext(emailContext);
+            const generatedEmailContext = await generator.generate(
+                templateConfig.subject,
+                templateContents,
+                templateContext,
+            );
             await this.send(generatedEmailContext);
         }
     }
 
+    /**
+     * Returns the corresponding TemplateConfig based on the channelCode and languageCode of the
+     * EmailContext object.
+     */
+    private getTemplateConfig(type: string, context: EmailContext): TemplateConfig {
+        const { emailTypes } = this.configService.emailOptions;
+        const typeConfig = emailTypes[type].templates;
+        const channelConfig = typeConfig[context.channelCode] || typeConfig.defaultChannel;
+        const languageConfig = channelConfig[context.languageCode] || channelConfig.defaultLanguage;
+        return languageConfig;
+    }
+
+    private loadTemplateContents(filePath: string): Promise<string> {
+        return fs.readFile(filePath, 'utf-8');
+    }
+
     private async send(email: GeneratedEmailContext) {
         const { transport } = this.configService.emailOptions;
         let transporter: Mail;

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 668 - 10
server/yarn.lock


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio