Procházet zdrojové kódy

feat(email-plugin): Create dev mode mailbox server

Michael Bromley před 6 roky
rodič
revize
e38075f84c

+ 1 - 0
packages/dev-server/dev-config.ts

@@ -61,6 +61,7 @@ export const devConfig: VendureConfig = {
             handlers: defaultEmailHandlers,
             templatePath: path.join(__dirname, '../email-plugin/templates'),
             outputPath: path.join(__dirname, 'test-emails'),
+            mailboxPort: 5003,
         }),
         new AdminUiPlugin({
             port: 5001,

+ 154 - 0
packages/email-plugin/dev-mailbox.html

@@ -0,0 +1,154 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>Vendure Development Inbox</title>
+    <style>
+        body {
+            display: flex;
+            flex-direction: column;
+            height: 100vh;
+            margin: 0;
+            font-family: Helvetica, Arial, sans-serif;
+        }
+        .top-bar {
+            padding: 12px;
+            display: flex;
+            align-items: center;
+            background-color: #2a2929;
+            color: #efefef;
+        }
+        .heading {
+            margin: 0;
+        }
+        button#refresh {
+            margin-left: 12px;
+            border: 1px solid #15a9df;
+            border-radius: 3px;
+            padding: 3px 6px;
+            display: flex;
+            align-items: center;
+        }
+        button#refresh .label {
+            margin-left: 6px;
+            font-size: 16px;
+        }
+        .content {
+            display: flex;
+            flex: 1;
+            height: calc(100% - 60px);
+        }
+        .list {
+            width: 40vw;
+            min-width: 300px;
+            padding: 6px;
+            overflow: auto;
+        }
+        .row {
+            border-bottom: 1px dashed #ddd;
+            padding: 12px 6px;
+            cursor: pointer;
+            transition: background-color 0.2s;
+        }
+        .row:hover {
+            background-color: #efefef;
+        }
+        .meta {
+            display: flex;
+            justify-content: space-between;
+            color: #666;
+        }
+        .detail {
+            flex: 1;
+            border: 1px solid #999;
+            display: flex;
+            flex-direction: column;
+        }
+        .detail iframe {
+            height: 100%;
+            border: 1px solid #eee;
+            overflow: auto;
+        }
+        .metadata {
+            margin: 6px;
+        }
+    </style>
+</head>
+<body>
+<div class="top-bar">
+    <h1 class="heading">Vendure Dev Mailbox</h1>
+    <div>
+        <button id="refresh">
+            <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 36 36" preserveAspectRatio="xMidYMid meet" focusable="false" aria-hidden="true" role="img" width="16" height="16" fill="currentColor"><path class="clr-i-outline clr-i-outline-path-1" d="M32.84,15.72a1,1,0,1,0-2,.29A13.15,13.15,0,0,1,31,17.94,13,13,0,0,1,8.7,27h5.36a1,1,0,0,0,0-2h-9v9a1,1,0,1,0,2,0V28.2A15,15,0,0,0,32.84,15.72Z"/><path class="clr-i-outline clr-i-outline-path-2" d="M30.06,1A1.05,1.05,0,0,0,29,2V7.83A14.94,14.94,0,0,0,3,17.94a15.16,15.16,0,0,0,.2,2.48,1,1,0,0,0,1,.84h.16a1,1,0,0,0,.82-1.15A13.23,13.23,0,0,1,5,17.94a13,13,0,0,1,13-13A12.87,12.87,0,0,1,27.44,9H22.06a1,1,0,0,0,0,2H31V2A1,1,0,0,0,30.06,1Z"/></svg>
+            <span class="label">Refresh</span>
+        </button>
+    </div>
+</div>
+<div class="content">
+    <div class="list">
+    </div>
+    <div class="detail">
+
+    </div>
+</div>
+<script>
+    const refreshButton = document.querySelector('button#refresh');
+    refreshButton.addEventListener('click', refreshInbox);
+
+    const list = document.querySelector('.list');
+    refreshInbox();
+
+    function refreshInbox() {
+        fetch('./list')
+            .then(res => res.json())
+            .then(res => renderList(res));
+    }
+
+    function renderList(items) {
+        const list = document.querySelector('.list');
+        list.innerHTML = '';
+        const rows = items.forEach(item => {
+            const row = document.createElement('div');
+            row.classList.add('row');
+            row.innerHTML = `
+                <div class="meta">
+                    <div class="date">${item.date}</div>
+                    <div class="recipient">${item.recipient}</div>
+                </div>
+                <div class="subject">${item.subject}</div>`;
+
+            row.addEventListener('click', (e) => {
+                fetch('./item/' + item.fileName)
+                    .then(res => res.json())
+                    .then(res => renderEmail(res));
+            });
+            list.appendChild(row);
+        });
+    }
+
+    function renderEmail(email) {
+        const content = `
+            <div class="metadata">
+                <table>
+                    <tr>
+                        <td>Recipient:</td>
+                        <td>${email.recipient}</td>
+                    </tr>
+                    <tr>
+                        <td>Subject:</td>
+                        <td>${email.subject}</td>
+                    </tr>
+                    <tr>
+                        <td>Date:</td>
+                        <td>${new Date().toLocaleString()}</td>
+                    </tr>
+                </table>
+            </div>
+            <iframe srcdoc="${email.body.replace(/"/g, '&quot;')}"></iframe>
+        `;
+
+        document.querySelector('.detail').innerHTML = content;
+    }
+</script>
+</body>
+</html>

+ 7 - 1
packages/email-plugin/package.json

@@ -4,7 +4,11 @@
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
-  "files": ["lib/**/*", "templates/**/*"],
+  "files": [
+    "lib/**/*",
+    "templates/**/*",
+    "dev-mailbox.html"
+  ],
   "scripts": {
     "watch": "tsc -p ./tsconfig.build.json --watch",
     "build": "rimraf lib && tsc -p ./tsconfig.build.json"
@@ -14,6 +18,7 @@
   },
   "dependencies": {
     "dateformat": "^3.0.3",
+    "express": "^4.16.4",
     "fs-extra": "^7.0.1",
     "handlebars": "^4.0.12",
     "mjml": "^4.3.0",
@@ -23,6 +28,7 @@
   },
   "devDependencies": {
     "@types/dateformat": "^3.0.0",
+    "@types/express": "^4.16.1",
     "@types/fs-extra": "^5.0.4",
     "@types/handlebars": "^4.0.40",
     "@types/mjml": "^4.0.2",

+ 60 - 0
packages/email-plugin/src/dev-mailbox.ts

@@ -0,0 +1,60 @@
+import express from 'express';
+import fs from 'fs-extra';
+import http from 'http';
+import path from 'path';
+
+/**
+ * An email inbox application that serves the contents of the dev mode `outputPath` directory.
+ */
+export class DevMailbox {
+    server: http.Server;
+
+    serve(port: number, outputPath: string) {
+        const server = express();
+        server.get('/', (req, res) => {
+            res.sendFile(path.join(__dirname, '../../dev-mailbox.html'));
+        });
+        server.get('/list', async (req, res) => {
+            const list = await fs.readdir(outputPath);
+            const contents = await this.getEmailList(outputPath);
+            res.send(contents);
+        });
+        server.get('/item/:id', async (req, res) => {
+            const fileName = req.params.id;
+            const content = await this.getEmail(outputPath, fileName);
+            res.send(content);
+        });
+        this.server = server.listen(port);
+    }
+
+    destroy() {
+        this.server.close();
+    }
+
+    private async getEmailList(outputPath: string) {
+        const list = await fs.readdir(outputPath);
+        const contents: any[] = [];
+        for (const fileName of list) {
+            const json = await fs.readFile(path.join(outputPath, fileName), 'utf-8');
+            const content = JSON.parse(json);
+            contents.push({
+                fileName,
+                date: content.date,
+                subject: content.subject,
+                recipient: content.recipient,
+            });
+        }
+        contents.sort((a, b) => {
+            return a.date > b.date ? -1 : 1;
+        });
+        return contents;
+    }
+
+    private async getEmail(outputPath: string, fileName: string) {
+        const safeSuffix = path.normalize(fileName).replace(/^(\.\.(\/|\\|$))+/, '');
+        const safeFilePath = path.join(outputPath, safeSuffix);
+        const json = await fs.readFile(safeFilePath, 'utf-8');
+        const content = JSON.parse(json);
+        return content;
+    }
+}

+ 11 - 5
packages/email-plugin/src/email-sender.ts

@@ -37,7 +37,7 @@ export class EmailSender {
                 if (options.raw) {
                     await this.sendFileRaw(email, filePath);
                 } else {
-                    await this.sendFileHtml(email, filePath);
+                    await this.sendFileJson(email, filePath);
                 }
                 break;
             case 'sendmail':
@@ -72,8 +72,14 @@ export class EmailSender {
         });
     }
 
-    private async sendFileHtml(email: EmailDetails, pathWithoutExt: string) {
-        const content = `<html lang="en">
+    private async sendFileJson(email: EmailDetails, pathWithoutExt: string) {
+        const output = {
+            date: new Date().toLocaleString(),
+            recipient: email.recipient,
+            subject: email.subject,
+            body: email.body,
+        };
+        /*const content = `<html lang="en">
             <head>
                 <title>${email.subject}</title>
                 <style>
@@ -108,9 +114,9 @@ export class EmailSender {
             <iframe srcdoc="${email.body.replace(/"/g, '&quot;')}"></iframe>
             </body>
             </html>
-        `;
+        `;*/
 
-        await fs.writeFile(pathWithoutExt + '.html', content);
+        await fs.writeFile(pathWithoutExt + '.json', JSON.stringify(output, null, 2));
     }
 
     private async sendFileRaw(email: EmailDetails, pathWithoutExt: string) {

+ 35 - 5
packages/email-plugin/src/plugin.ts

@@ -1,6 +1,7 @@
-import { EventBus, InternalServerError, Type, VendurePlugin } from '@vendure/core';
+import { createProxyHandler, EventBus, InternalServerError, Type, VendureConfig, VendurePlugin } from '@vendure/core';
 import fs from 'fs-extra';
 
+import { DevMailbox } from './dev-mailbox';
 import { EmailSender } from './email-sender';
 import { EmailEventHandler } from './event-listener';
 import { HandlebarsMjmlGenerator } from './handlebars-mjml-generator';
@@ -89,21 +90,30 @@ import { EmailPluginDevModeOptions, EmailPluginOptions, EmailTransportOptions, E
  *
  * The `defaultEmailHandlers` array defines the default handlers such as for handling new account registration, order confirmation, password reset
  * etc. These defaults can be extended by adding custom templates for languages other than the default, or even completely new types of emails
- * which respond to any of the available [VendureEvents](/docs/typescript-api/events/). See the {@link EmailEventHandler} documentation for details on how to do so.
+ * which respond to any of the available [VendureEvents](/docs/typescript-api/events/). See the {@link EmailEventHandler} documentation for
+ * details on how to do so.
  *
  * ## Dev mode
  *
  * For development, the `transport` option can be replaced by `devMode: true`. Doing so configures Vendure to use the
- * [file transport]({{}}) and outputs emails as rendered HTML files in a directory named "test-emails" which is located adjacent to the directory
- * configured in the `templatePath`.
+ * file transport (See {@link FileTransportOptions}) and outputs emails as rendered HTML files in the directory specified by the
+ * `outputPath` property.
  *
  * ```ts
  * new EmailPlugin({
- *   templatePath: path.join(__dirname, 'vendure/email/templates'),
  *   devMode: true,
+ *   handlers: defaultEmailHandlers,
+ *   templatePath: path.join(__dirname, 'vendure/email/templates'),
+ *   outputPath: path.join(__dirname, 'test-emails'),
+ *   mailboxPort: 5003,
  * })
  * ```
  *
+ * ### Dev mailbox
+ *
+ * In dev mode, specifying the optional `mailboxPort` will start a webmail-like interface available at the `/mailbox` path, e.g.
+ * http://localhost:3000/mailbox. This is a simple way to view the output of all emails generated by the EmailPlugin while in dev mode.
+ *
  * @docsCategory EmailPlugin
  */
 export class EmailPlugin implements VendurePlugin {
@@ -113,6 +123,7 @@ export class EmailPlugin implements VendurePlugin {
     private templateLoader: TemplateLoader;
     private emailSender: EmailSender;
     private generator: HandlebarsMjmlGenerator;
+    private devMailbox: DevMailbox | undefined;
 
     constructor(options: EmailPluginOptions | EmailPluginDevModeOptions) {
         this.options = options;
@@ -132,6 +143,19 @@ export class EmailPlugin implements VendurePlugin {
         }
     }
 
+    configure(config: Required<VendureConfig>): Required<VendureConfig> | Promise<Required<VendureConfig>> {
+        if (isDevModeOptions(this.options) && this.options.mailboxPort !== undefined) {
+            this.devMailbox = new DevMailbox();
+            this.devMailbox.serve(this.options.mailboxPort, this.options.outputPath);
+            const route = 'mailbox';
+            config.middleware.push({
+                handler: createProxyHandler({ port: this.options.mailboxPort, route }, !config.silent),
+                route,
+            });
+        }
+        return config;
+    }
+
     async onBootstrap(inject: <T>(type: Type<T>) => T): Promise<void> {
         this.eventBus = inject(EventBus);
         this.templateLoader = new TemplateLoader(this.options.templatePath);
@@ -144,6 +168,12 @@ export class EmailPlugin implements VendurePlugin {
         }
     }
 
+    async onClose() {
+        if (this.devMailbox) {
+            this.devMailbox.destroy();
+        }
+    }
+
     async setupEventSubscribers() {
         for (const handler of this.options.handlers) {
             this.eventBus.subscribe(handler.event, event => {

+ 12 - 1
packages/email-plugin/src/types.ts

@@ -49,8 +49,19 @@ export interface EmailPluginOptions {
  * @docsCategory EmailPlugin
  */
 export interface EmailPluginDevModeOptions extends Omit<EmailPluginOptions, 'transport'> {
-    outputPath: string;
     devMode: true;
+    /**
+     * @description
+     * The path to which html email files will be saved rather than being sent.
+     */
+    outputPath: string;
+    /**
+     * @description
+     * If set, a "mailbox" server will be started which will serve the contents of the
+     * `outputPath` similar to a web-based email client, available at the route `/mailbox`,
+     * e.g. http://localhost:3000/mailbox.
+     */
+    mailboxPort?: number;
 }
 
 export interface SMTPCredentials {