Skip to content

Commit

Permalink
feat(email-plugin): Create dev mode mailbox server
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Apr 15, 2019
1 parent 886cc72 commit e38075f
Show file tree
Hide file tree
Showing 7 changed files with 280 additions and 12 deletions.
1 change: 1 addition & 0 deletions packages/dev-server/dev-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
154 changes: 154 additions & 0 deletions packages/email-plugin/dev-mailbox.html
Original file line number Diff line number Diff line change
@@ -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>
8 changes: 7 additions & 1 deletion packages/email-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand All @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions packages/email-plugin/src/dev-mailbox.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
16 changes: 11 additions & 5 deletions packages/email-plugin/src/email-sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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) {
Expand Down
40 changes: 35 additions & 5 deletions packages/email-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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 => {
Expand Down
13 changes: 12 additions & 1 deletion packages/email-plugin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit e38075f

Please sign in to comment.