Skip to content

Commit 6a59165

Browse files
authored
Format emails (#7)
Send nicer and more meaningful emails. HTML templates with handlebars. Generate screenshots when running tests for visual testing.
1 parent e668da8 commit 6a59165

18 files changed

+888
-38
lines changed

.env.sample

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
# port on which the service will run
22
PORT=3005
33

4+
# name of the app that sends the emails - featured in the message subject or body
5+
APP_NAME=sleepy.bike
6+
# provide logo to display on top of emails
7+
# it can be base64 encoded url, or path to an image - keep it small!
8+
# by default the logo doesn't show - it's one transparent pixel
9+
APP_LOGO=
10+
SUPPORT_EMAIL=[email protected]
11+
412
# server base url, e.g. to construct correct email verification link
513
# this is the base url that end users see
614
BASE_URL=http://localhost:3005

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@ node_modules
66
database.sqlite
77

88
*.pem
9+
10+
# Screenshots of emails from testing
11+
screenshots/*
12+
!screenshots/.keep

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
"version": "0.0.1",
44
"main": "dist/index.js",
55
"scripts": {
6-
"start": "tsc && node dist/index.js",
6+
"start": "yarn build && node dist/index.js",
7+
"build": "rm -rf dist && tsc && yarn copy-hbs",
8+
"copy-hbs": "cp src/templates/*.hbs dist/templates && cp src/templates/*.css dist/templates",
79
"format": "prettier 'src/**/*.ts' '**/*.{md,yml,yaml,json}' --write",
810
"lint": "eslint . --ext .ts",
911
"test": "mocha",
@@ -22,7 +24,7 @@
2224
"@types/koa__cors": "^4.0.1",
2325
"@types/koa__router": "^12.0.0",
2426
"@types/lodash": "^4.14.196",
25-
"@types/maildev": "^0.0.4",
27+
"@types/maildev": "^0.0.7",
2628
"@types/mocha": "^10.0.1",
2729
"@types/n3": "^1.16.0",
2830
"@types/node": "^20.4.2",
@@ -43,6 +45,7 @@
4345
"mocha": "^10.2.0",
4446
"msw": "^2.0.14",
4547
"prettier": "^3.0.0",
48+
"puppeteer": "^22.2.0",
4649
"rdf-namespaces": "^1.11.0",
4750
"sinon": "^15.2.0",
4851
"swagger-autogen": "^2.23.6",
@@ -64,11 +67,14 @@
6467
"css-authn": "^0.0.14",
6568
"dotenv": "^16.0.0",
6669
"fs-extra": "^11.2.0",
70+
"handlebars": "^4.7.8",
6771
"jsonwebtoken": "^9.0.2",
72+
"juice": "^10.0.0",
6873
"koa": "^2.14.2",
6974
"koa-helmet": "^7.0.2",
7075
"koa-static": "^5.0.0",
7176
"lodash": "^4.17.21",
77+
"mime": "^3",
7278
"n3": "^1.17.0",
7379
"nodemailer": "^6.9.4",
7480
"parse-link-header": "^2.0.0"

screenshots/.keep

Whitespace-only changes.

src/config/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
import 'dotenv/config'
22
import SMTPTransport from 'nodemailer/lib/smtp-transport'
3+
import { getBase64Image } from '../utils'
34

45
// the defaults work for tests. you should define your own
56
// either via .env file, or via environment variables directly (depends on your setup)
67

78
// server base url, e.g. to construct correct email verification links
89
export const baseUrl = process.env.BASE_URL ?? 'http://localhost:3005'
910

11+
export const appName = process.env.APP_NAME ?? 'Sleepy.Bike'
12+
13+
// default is 1 transparent PNG pixel
14+
export const appLogo = getBase64Image(
15+
process.env.APP_LOGO ??
16+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
17+
)
18+
export const supportEmail = process.env.SUPPORT_EMAIL ?? '[email protected]'
19+
1020
// identity under which the mailer is operating
1121
export const mailerCredentials = {
1222
email: process.env.MAILER_IDENTITY_EMAIL ?? 'bot@example',
@@ -26,7 +36,7 @@ const stringToBoolean = (value: string | undefined): boolean => {
2636
}
2737
// SMTP transport for nodemailer (setup for sending emails)
2838
export const smtpTransportOptions: SMTPTransport.Options = {
29-
host: process.env.SMTP_TRANSPORT_HOST || undefined,
39+
host: process.env.SMTP_TRANSPORT_HOST || '0.0.0.0',
3040
port: process.env.SMTP_TRANSPORT_PORT
3141
? +process.env.SMTP_TRANSPORT_PORT
3242
: 1025, // default works for maildev
@@ -39,7 +49,8 @@ export const smtpTransportOptions: SMTPTransport.Options = {
3949
}
4050

4151
// email address which will be the sender of the notifications and email verification messages
42-
export const emailSender = process.env.EMAIL_SENDER
52+
export const emailSender =
53+
process.env.EMAIL_SENDER ?? '[email protected]'
4354

4455
export const port: number = +(process.env.PORT ?? 3005)
4556

src/controllers/integration.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import { Middleware } from 'koa'
55
import { pick } from 'lodash'
66
import * as config from '../config'
77
import { sendMail } from '../services/mailerService'
8+
import { generateHtmlMessage } from '../templates/generateMessage'
89
import { findWritableSettings, getBotFetch } from '../utils'
910

10-
export const initializeIntegration: Middleware = async ctx => {
11+
export const initializeIntegration: Middleware<{
12+
user: string
13+
}> = async ctx => {
1114
// we should receive info about webId and email address
1215
const email: string = ctx.request.body.email
1316
const user: string = ctx.state.user
@@ -30,11 +33,20 @@ export const initializeIntegration: Middleware = async ctx => {
3033

3134
const emailVerificationLink = `${config.baseUrl}/verify-email?token=${jwt}`
3235

36+
const subject = `Verify your email for ${config.appName} notifications`
37+
3338
await sendMail({
34-
from: config.emailSender,
39+
from: {
40+
name: `${config.appName} notifications`,
41+
address: config.emailSender,
42+
},
3543
to: email,
36-
subject: 'Verify your email for sleepy.bike notifications',
37-
html: `Please verify your email <a href="${emailVerificationLink}">click here</a>`,
44+
subject,
45+
html: await generateHtmlMessage('verification', {
46+
actor: ctx.state.user,
47+
emailVerificationLink,
48+
title: subject,
49+
}),
3850
text: `Please verify your email ${emailVerificationLink}`,
3951
})
4052
ctx.response.body = 'Success'

src/controllers/notification.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DefaultContext, Middleware } from 'koa'
2-
import { emailSender } from '../config'
2+
import { appName, emailSender } from '../config'
33
import { sendMail } from '../services/mailerService'
4+
import { generateHtmlMessage } from '../templates/generateMessage'
45
import { getVerifiedEmails } from './status'
56

67
export type GoodBody = {
@@ -30,11 +31,24 @@ export const notification: Middleware<
3031
)
3132

3233
for (const email of emails) {
34+
const subject = `${body.actor.name || 'Someone'} wrote you from ${appName}`
35+
3336
await sendMail({
34-
from: emailSender,
35-
to: email,
36-
subject: 'You have a new message from sleepy.bike!', // TODO generalize
37-
html: body.object.content,
37+
from: {
38+
name: body.actor.name
39+
? `${body.actor.name} (via ${appName})`
40+
: `${appName} notifications`,
41+
address: emailSender,
42+
},
43+
to: {
44+
name: body.target.name ?? '',
45+
address: email,
46+
},
47+
subject,
48+
html: await generateHtmlMessage('message', {
49+
...body,
50+
title: subject,
51+
}),
3852
text: body.object.content,
3953
})
4054
}

src/templates/generateMessage.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as fs from 'fs-extra'
2+
import Handlebars from 'handlebars'
3+
import juice from 'juice'
4+
import path from 'path'
5+
import * as config from '../config'
6+
7+
Handlebars.registerHelper('encodeURIComponent', encodeURIComponent)
8+
9+
type LayoutData = {
10+
appName?: string
11+
appLogo?: string
12+
supportEmail?: string
13+
title?: string
14+
}
15+
16+
export const generateHtmlMessage = async <T>(
17+
type: string,
18+
data: T & LayoutData,
19+
) => {
20+
const layout = await fs.readFile(path.join(__dirname, 'layout.hbs'), 'utf8')
21+
const layoutTemplate = Handlebars.compile(layout)
22+
const content = await fs.readFile(path.join(__dirname, `${type}.hbs`), 'utf8')
23+
const contentTemplate = Handlebars.compile<T>(content)
24+
const stylesheet = await fs.readFile(
25+
path.join(__dirname, 'styles.css'),
26+
'utf8',
27+
)
28+
29+
const {
30+
appName = config.appName,
31+
appLogo = config.appLogo,
32+
supportEmail = config.supportEmail,
33+
title = '',
34+
} = data
35+
36+
const compiledContent = contentTemplate({
37+
...data,
38+
appName,
39+
appLogo,
40+
title,
41+
})
42+
const emailHtml = layoutTemplate({
43+
title,
44+
appName,
45+
appLogo,
46+
supportEmail,
47+
body: compiledContent,
48+
})
49+
const emailHtmlInlineCss = juice(emailHtml, { extraCss: stylesheet })
50+
51+
return emailHtmlInlineCss
52+
}

src/templates/layout.hbs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<html>
2+
<head>
3+
<meta charset='utf-8' />
4+
<title>{{title}}</title>
5+
</head>
6+
<body>
7+
<header><img
8+
alt='logo of {{appName}}'
9+
src={{appLogo}}
10+
height='32'
11+
/></header>
12+
{{{body}}}
13+
<footer>
14+
<p>You can contact us at
15+
<a href='mailto:{{supportEmail}}'>{{supportEmail}}</a></p>
16+
</footer>
17+
</body>
18+
</html>

src/templates/message.hbs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<p>Hello{{#if target.name}}&nbsp;{{/if}}{{target.name}}!</p>
2+
<p>
3+
<a href={{actor.id}}>{{#if
4+
actor.name
5+
}}{{actor.name}}{{else}}Somebody{{/if}}</a>
6+
sent you a message from
7+
{{appName}}.
8+
</p>
9+
10+
<blockquote>{{object.content}}</blockquote>
11+
12+
<a
13+
href='https://sleepy.bike/messages/{{encodeURIComponent actor.id}}'
14+
class='action-button'
15+
>Reply on
16+
{{appName}}</a>

0 commit comments

Comments
 (0)