Added email templating, and user welcome email

This commit is contained in:
Jori Lallo 2017-11-12 15:02:23 -08:00
parent 272cc158ea
commit 348e5f0b20
17 changed files with 463 additions and 14 deletions

View File

@ -13,3 +13,9 @@ URL=http://localhost:3000
DEPLOYMENT=hosted
ENABLE_UPDATES=true
GOOGLE_ANALYTICS_ID=
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_SENDER_EMAIL=

View File

@ -4,10 +4,8 @@
"main": "index.js",
"scripts": {
"clean": "rimraf dist",
"build:webpack":
"NODE_ENV=production webpack --config webpack.config.prod.js",
"build:analyze":
"NODE_ENV=production webpack --config webpack.config.prod.js --json | webpack-bundle-size-analyzer",
"build:webpack": "NODE_ENV=production webpack --config webpack.config.prod.js",
"build:analyze": "NODE_ENV=production webpack --config webpack.config.prod.js --json | webpack-bundle-size-analyzer",
"build": "npm run clean && npm run build:webpack",
"start": "NODE_ENV=production node index.js",
"dev": "NODE_ENV=development nodemon --inspect --watch server index.js",
@ -20,24 +18,39 @@
"sequelize:migrate": "sequelize db:migrate",
"test": "npm run test:app && npm run test:server",
"test:app": "jest",
"test:server":
"jest --config=server/.jestconfig.json --runInBand --forceExit",
"test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit",
"precommit": "lint-staged"
},
"lint-staged": {
"*.js": ["eslint --fix", "git add"]
"*.js": [
"eslint --fix",
"git add"
]
},
"jest": {
"verbose": false,
"roots": ["app"],
"roots": [
"app"
],
"moduleNameMapper": {
"^.*[.](s?css|css)$": "<rootDir>/__mocks__/styleMock.js",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleFileExtensions": ["js", "jsx", "json"],
"moduleDirectories": ["node_modules"],
"modulePaths": ["app"],
"setupFiles": ["<rootDir>/setupJest.js", "<rootDir>/__mocks__/window.js"]
"moduleFileExtensions": [
"js",
"jsx",
"json"
],
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"app"
],
"setupFiles": [
"<rootDir>/setupJest.js",
"<rootDir>/__mocks__/window.js"
]
},
"engines": {
"node": ">= 7.6"
@ -117,8 +130,10 @@
"mobx-react-devtools": "^4.2.11",
"moment": "2.13.0",
"node-dev": "3.1.0",
"nodemailer": "^4.4.0",
"normalize.css": "^7.0.0",
"normalizr": "2.0.1",
"oy-vey": "^0.10.0",
"pg": "^6.1.5",
"pg-hstore": "2.3.2",
"polished": "1.2.1",

View File

@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Mailer #welcome 1`] = `
Object {
"from": "Outline <hello@mail.getoutline.com>",
"html": "
<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Strict//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\\">
<html
dir=\\"ltr\\"
xmlns=\\"http://www.w3.org/1999/xhtml\\"
xmlns:v=\\"urn:schemas-microsoft-com:vml\\"
xmlns:o=\\"urn:schemas-microsoft-com:office:office\\">
<head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=utf-8\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"IE=edge\\" />
<meta name=\\"viewport\\" content=\\"width=device-width\\"/>
<title>Welcome to Outline</title>
<style type=\\"text/css\\">
#__bodyTable__{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;font-size:16px;line-height:1.5}
#__bodyTable__ {
margin: 0;
padding: 0;
width: 100% !important;
}
</style>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
</head>
<body bgcolor=\\"#FFFFFF\\" width=\\"100%\\" style=\\"-webkit-font-smoothing: antialiased; width:100% !important; background:#FFFFFF;-webkit-text-size-adjust:none; margin:0; padding:0; min-width:100%; direction: ltr;\\">
<table bgcolor=\\"#FFFFFF\\" id=\\"__bodyTable__\\" width=\\"100%\\" style=\\"-webkit-font-smoothing: antialiased; width:100% !important; background:#FFFFFF;-webkit-text-size-adjust:none; margin:0; padding:0; min-width:100%\\">
<tr>
<td align=\\"center\\">
<span style=\\"display: none !important; color: #FFFFFF; margin:0; padding:0; font-size:1px; line-height:1px;\\">Outline is a place for your team to build and share knowledge.</span>
<table width=\\"550\\" padding=\\"40\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td align=\\"left\\"><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td width=\\"100%\\" height=\\"40px\\" style=\\"line-height:40px;font-size:1px;mso-line-height-rule:exactly\\">&nbsp;</td></tr></tbody></table><p><strong>Welcome to Outline!</strong></p><p>Outline is a place for your team to build and share knowledge.</p><p>To get started, head to your dashboard and try creating a collection to help document your workflow, create playbooks or help with team onboarding.</p><p>You can also import existing Markdown document by drag and dropping them to your collections</p><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td width=\\"100%\\" height=\\"10px\\" style=\\"line-height:10px;font-size:1px;mso-line-height-rule:exactly\\">&nbsp;</td></tr></tbody></table><p><a href=\\"http://localhost:3000/dashboard\\" style=\\"display:inline-block;padding:10px 20px;color:#FFFFFF;background:#000000;border-radius:4px;font-weight:500;text-decoration:none;cursor:pointer\\">View my dashboard</a></p><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td width=\\"100%\\" height=\\"40px\\" style=\\"line-height:40px;font-size:1px;mso-line-height-rule:exactly\\">&nbsp;</td></tr></tbody></table></td></tr></tbody></table><table width=\\"100%\\" border=\\"0\\" cellSpacing=\\"0\\" cellPadding=\\"0\\"><tbody><tr><td width=\\"75%\\" style=\\"padding:20px 0;border-top:1px solid #e8e8e8;color:#9BA6B2;font-size:14px\\"><a href=\\"http://localhost:3000\\" style=\\"color:#9BA6B2;text-decoration:none\\">Outline</a></td></tr></tbody></table></td></tr></tbody></table>
</td>
</tr>
</table>
</body>
</html>
",
"subject": "Welcome to Outline",
"text": "
Welcome to Outline!
Outline is a place for your team to build and share knowledge.
To get started, head to your dashboard and try creating a collection to help document your workflow, create playbooks or help with team onboarding.
You can also import existing Markdown document by drag and dropping them to your collections
http://localhost:3000/dashboard
",
"to": "user@example.com",
}
`;

View File

@ -10,6 +10,12 @@ afterAll(server.close);
describe.skip('#auth.signup', async () => {
it('should signup a new user', async () => {
const welcomeEmailMock = jest.fn();
jest.doMock('../mailer', () => {
return {
welcome: welcomeEmailMock,
};
});
const res = await server.post('/api/auth.signup', {
body: {
username: 'testuser',
@ -23,6 +29,7 @@ describe.skip('#auth.signup', async () => {
expect(res.status).toEqual(200);
expect(body.ok).toBe(true);
expect(body.data.user).toBeTruthy();
expect(welcomeEmailMock).toBeCalledWith('new.user@example.com');
});
it('should require params', async () => {

View File

@ -0,0 +1,52 @@
// @flow
import React from 'react';
import EmailTemplate from './components/EmailLayout';
import Body from './components/Body';
import Button from './components/Button';
import Footer from './components/Footer';
import EmptySpace from './components/EmptySpace';
export const welcomeEmailText = `
Welcome to Outline!
Outline is a place for your team to build and share knowledge.
To get started, head to your dashboard and try creating a collection to help document your workflow, create playbooks or help with team onboarding.
You can also import existing Markdown document by drag and dropping them to your collections
${process.env.URL}/dashboard
`;
export const WelcomeEmail = () => {
return (
<EmailTemplate>
<Body>
<p>
<strong>Welcome to Outline!</strong>
</p>
<p>Outline is a place for your team to build and share knowledge.</p>
<p>
To get started, head to your dashboard and try creating a collection
to help document your workflow, create playbooks or help with team
onboarding.
</p>
<p>
You can also import existing Markdown document by drag and dropping
them to your collections
</p>
<EmptySpace height={10} />
<p>
<Button href={`${process.env.URL}/dashboard`}>
View my dashboard
</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@ -0,0 +1,25 @@
// @flow
import React from 'react';
import { Table, TBody, TR, TD } from 'oy-vey';
import EmptySpace from './EmptySpace';
type Props = {
children: React$Element<*>,
};
export default ({ children }: Props) => {
return (
<Table width="100%">
<TBody>
<TR>
<TD>
<EmptySpace height={40} />
{children}
<EmptySpace height={40} />
</TD>
</TR>
</TBody>
</Table>
);
};

View File

@ -0,0 +1,17 @@
// @flow
import React from 'react';
export default (props: { href: string, children: React.Element<*> }) => {
const style = {
display: 'inline-block',
padding: '10px 20px',
color: '#FFFFFF',
background: '#000000',
borderRadius: '4px',
fontWeight: 500,
textDecoration: 'none',
cursor: 'pointer',
};
return <a {...props} style={style} />;
};

View File

@ -0,0 +1,26 @@
// @flow
import React from 'react';
import { Table, TBody, TR, TD } from 'oy-vey';
import { fonts } from '../../../shared/styles/constants';
type Props = {
children: React$Element<*>,
};
export default (props: Props) => (
<Table width="550" padding="40">
<TBody>
<TR>
<TD align="left">{props.children}</TD>
</TR>
</TBody>
</Table>
);
export const baseStyles = `
#__bodyTable__ {
font-family: ${fonts.regular};
font-size: 16px;
line-height: 1.5;
}
`;

View File

@ -0,0 +1,29 @@
// @flow
import React from 'react';
import { Table, TBody, TR, TD } from 'oy-vey';
const EmptySpace = ({ height }: { height?: number }) => {
height = height || 16;
const style = {
lineHeight: `${height}px`,
fontSize: '1px',
msoLineHeightRule: 'exactly',
};
return (
<Table width="100%">
<TBody>
<TR>
<TD
width="100%"
height={`${height}px`}
style={style}
dangerouslySetInnerHTML={{ __html: '&nbsp;' }}
/>
</TR>
</TBody>
</Table>
);
};
export default EmptySpace;

View File

@ -0,0 +1,31 @@
// @flow
import React from 'react';
import { Table, TBody, TR, TD } from 'oy-vey';
export default () => {
const style = {
padding: '20px 0',
borderTop: '1px solid #e8e8e8',
color: '#9BA6B2',
fontSize: '14px',
};
const linkStyle = {
color: '#9BA6B2',
textDecoration: 'none',
};
return (
<Table width="100%">
<TBody>
<TR>
<TD width="75%" style={style}>
<a href={process.env.URL} style={linkStyle}>
Outline
</a>
</TD>
</TR>
</TBody>
</Table>
);
};

35
server/emails/index.js Normal file
View File

@ -0,0 +1,35 @@
// @flow
import Koa from 'koa';
import Router from 'koa-router';
import { Mailer } from '../mailer';
const emailPreviews = new Koa();
const router = new Router();
router.get('/:type/:format', async ctx => {
const previewMailer = new Mailer();
let mailerOutput;
previewMailer.transporter = {
sendMail: data => (mailerOutput = data),
};
switch (ctx.params.type) {
case 'welcome':
previewMailer.welcome('user@example.com');
break;
default:
console.log(1);
}
if (!mailerOutput) return;
if (ctx.params.format === 'text') {
ctx.body = mailerOutput.text;
} else {
ctx.body = mailerOutput.html;
}
});
emailPreviews.use(router.routes());
export default emailPreviews;

View File

@ -8,6 +8,7 @@ import bugsnag from 'bugsnag';
import updates from './utils/updates';
import api from './api';
import emails from './emails';
import routes from './routes';
const app = new Koa();
@ -71,6 +72,10 @@ if (process.env.NODE_ENV === 'production' && process.env.BUGSNAG_KEY) {
app.on('error', bugsnag.koaHandler);
}
if (process.env.NODE_ENV === 'development') {
app.use(mount('/emails', emails));
}
app.use(mount('/api', api));
app.use(mount(routes));
@ -85,7 +90,7 @@ app.use(
/**
* Production updates and anonymous analytics.
*
*
* Set ENABLE_UPDATES=false to disable them for your installation
*/
if (

91
server/mailer.js Normal file
View File

@ -0,0 +1,91 @@
// @flow
import React from 'react';
import nodemailer from 'nodemailer';
import Oy from 'oy-vey';
import invariant from 'invariant';
import { baseStyles } from './emails/components/EmailLayout';
import { WelcomeEmail, welcomeEmailText } from './emails/WelcomeEmail';
type SendMailType = {
to: string,
properties?: any,
title: string,
previewText?: string,
text: string,
html: React.Element<*>,
headCSS?: string,
};
/**
* Mailer
*
* Mailer class to contruct and send emails.
*
* To preview emails, add a new preview to `emails/index.js` and visit following
* URLs in development mode:
*
* HTML: http://localhost:3000/email/:email_type/html
* TEXT: http://localhost:3000/email/:email_type/text
*/
class Mailer {
transporter: ?any;
/**
*
*/
sendMail = async (data: SendMailType): ?Promise<*> => {
if (this.transporter) {
const html = Oy.renderTemplate(data.html, {
title: data.title,
headCSS: [baseStyles, data.headCSS].join(' '),
previewText: data.previewText,
});
invariant(this.transporter, 'very sure this.transporter exists');
try {
await this.transporter.sendMail({
from: process.env.SMTP_SENDER_EMAIL,
to: data.to,
subject: data.title,
html: html,
text: data.text,
});
} catch (e) {
Bugsnag.notifyException(e);
}
}
};
welcome = async (to: string) => {
this.sendMail({
to,
title: 'Welcome to Outline',
previewText:
'Outline is a place for your team to build and share knowledge.',
html: <WelcomeEmail />,
text: welcomeEmailText,
});
};
constructor() {
if (process.env.SMTP_HOST) {
let smtpConfig = {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: true,
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
},
};
this.transporter = nodemailer.createTransport(smtpConfig);
}
}
}
const mailer = new Mailer();
export { Mailer };
export default mailer;

19
server/mailer.test.js Normal file
View File

@ -0,0 +1,19 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { Mailer } from './mailer';
describe('Mailer', () => {
let fakeMailer;
let sendMailOutput;
beforeEach(() => {
fakeMailer = new Mailer();
fakeMailer.transporter = {
sendMail: output => (sendMailOutput = output),
};
});
test('#welcome', () => {
fakeMailer.welcome('user@example.com');
expect(sendMailOutput).toMatchSnapshot();
});
});

View File

@ -4,6 +4,7 @@ import bcrypt from 'bcrypt';
import uuid from 'uuid';
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
import { uploadToS3FromUrl } from '../utils/s3';
import mailer from '../mailer';
import JWT from 'jsonwebtoken';
@ -99,5 +100,6 @@ const hashPassword = function hashPassword(model) {
User.beforeCreate(hashPassword);
User.beforeUpdate(hashPassword);
User.beforeCreate(setRandomJwtSecret);
User.afterCreate(user => mailer.welcome(user.email));
export default User;

View File

@ -37,6 +37,7 @@ productionWebpackConfig.plugins.push(
productionWebpackConfig.plugins.push(
new webpack.DefinePlugin({
'process.env': {
URL: JSON.stringify(process.env.URL),
NODE_ENV: JSON.stringify('production'),
GOOGLE_ANALYTICS_ID: JSON.stringify(process.env.GOOGLE_ANALYTICS_ID),
},

View File

@ -1517,6 +1517,12 @@ clean-css@3.4.x:
commander "2.8.x"
source-map "0.4.x"
clean-css@^4.0.12:
version "4.1.9"
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.1.9.tgz#35cee8ae7687a49b98034f70de00c4edd3826301"
dependencies:
source-map "0.5.x"
cli-color@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-1.2.0.tgz#3a5ae74fd76b6267af666e69e2afbbd01def34d1"
@ -6175,6 +6181,10 @@ node-pre-gyp@0.6.36, node-pre-gyp@^0.6.36:
tar "^2.2.1"
tar-pack "^3.4.0"
nodemailer@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.4.0.tgz#571989e524a906fb83b1518f3e4c0d3140db24af"
nodemon@1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.11.0.tgz#226c562bd2a7b13d3d7518b49ad4828a3623d06c"
@ -6492,6 +6502,14 @@ osenv@^0.1.0, osenv@^0.1.4:
version "0.0.5"
resolved "https://registry.yarnpkg.com/over/-/over-0.0.5.tgz#f29852e70fd7e25f360e013a8ec44c82aedb5708"
oy-vey@^0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/oy-vey/-/oy-vey-0.10.0.tgz#16160f837f0ea3d0340adfc2377ba93d1ed9ce76"
dependencies:
clean-css "^4.0.12"
object-assign "^4.1.1"
sanitizer "^0.1.3"
p-cancelable@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
@ -7827,6 +7845,10 @@ sane@~1.6.0:
walker "~1.0.5"
watch "~0.10.0"
sanitizer@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/sanitizer/-/sanitizer-0.1.3.tgz#d4f0af7475d9a7baf2a9e5a611718baa178a39e1"
sax@1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
@ -8177,7 +8199,7 @@ source-map@0.5.6:
version "0.5.6"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412"
source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.0, source-map@~0.5.1, source-map@~0.5.3:
source-map@0.5.x, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.0, source-map@~0.5.1, source-map@~0.5.3:
version "0.5.7"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"