Skip to content

Commit

Permalink
Format emails (#7)
Browse files Browse the repository at this point in the history
Send nicer and more meaningful emails.
HTML templates with handlebars.
Generate screenshots when running tests for visual testing.
  • Loading branch information
mrkvon committed Feb 23, 2024
1 parent e668da8 commit 99f3a99
Show file tree
Hide file tree
Showing 20 changed files with 882 additions and 40 deletions.
7 changes: 7 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# port on which the service will run
PORT=3005

# name of the app that sends the emails - featured in the message subject or body
APP_NAME=Sleepy.bike
# provide path to a logo to display on top of emails - keep it small!
# absolute path, or relative path to the base of the project
APP_LOGO=./logo.png
SUPPORT_EMAIL=[email protected]

# server base url, e.g. to construct correct email verification link
# this is the base url that end users see
BASE_URL=http://localhost:3005
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ node_modules
database.sqlite

*.pem

# Screenshots of emails from testing
screenshots/*
!screenshots/.keep
Binary file added logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
"version": "0.0.1",
"main": "dist/index.js",
"scripts": {
"start": "tsc && node dist/index.js",
"start": "yarn build && node dist/index.js",
"build": "rm -rf dist && tsc && yarn copy-hbs",
"copy-hbs": "cp src/templates/*.hbs dist/templates && cp src/templates/*.css dist/templates",
"format": "prettier 'src/**/*.ts' '**/*.{md,yml,yaml,json}' --write",
"lint": "eslint . --ext .ts",
"test": "mocha",
Expand All @@ -22,7 +24,7 @@
"@types/koa__cors": "^4.0.1",
"@types/koa__router": "^12.0.0",
"@types/lodash": "^4.14.196",
"@types/maildev": "^0.0.4",
"@types/maildev": "^0.0.7",
"@types/mocha": "^10.0.1",
"@types/n3": "^1.16.0",
"@types/node": "^20.4.2",
Expand All @@ -43,6 +45,7 @@
"mocha": "^10.2.0",
"msw": "^2.0.14",
"prettier": "^3.0.0",
"puppeteer": "^22.2.0",
"rdf-namespaces": "^1.11.0",
"sinon": "^15.2.0",
"swagger-autogen": "^2.23.6",
Expand All @@ -64,7 +67,9 @@
"css-authn": "^0.0.14",
"dotenv": "^16.0.0",
"fs-extra": "^11.2.0",
"handlebars": "^4.7.8",
"jsonwebtoken": "^9.0.2",
"juice": "^10.0.0",
"koa": "^2.14.2",
"koa-helmet": "^7.0.2",
"koa-static": "^5.0.0",
Expand Down
Empty file added screenshots/.keep
Empty file.
12 changes: 10 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import SMTPTransport from 'nodemailer/lib/smtp-transport'
// server base url, e.g. to construct correct email verification links
export const baseUrl = process.env.BASE_URL ?? 'http://localhost:3005'

export const appName = process.env.APP_NAME ?? 'Sleepy.bike'

// default is sleepy.bike logo
export const appLogo = process.env.APP_LOGO ?? './logo.png'

export const supportEmail = process.env.SUPPORT_EMAIL ?? '[email protected]'

// identity under which the mailer is operating
export const mailerCredentials = {
email: process.env.MAILER_IDENTITY_EMAIL ?? 'bot@example',
Expand All @@ -26,7 +33,7 @@ const stringToBoolean = (value: string | undefined): boolean => {
}
// SMTP transport for nodemailer (setup for sending emails)
export const smtpTransportOptions: SMTPTransport.Options = {
host: process.env.SMTP_TRANSPORT_HOST || undefined,
host: process.env.SMTP_TRANSPORT_HOST || '0.0.0.0',
port: process.env.SMTP_TRANSPORT_PORT
? +process.env.SMTP_TRANSPORT_PORT
: 1025, // default works for maildev
Expand All @@ -39,7 +46,8 @@ export const smtpTransportOptions: SMTPTransport.Options = {
}

// email address which will be the sender of the notifications and email verification messages
export const emailSender = process.env.EMAIL_SENDER
export const emailSender =
process.env.EMAIL_SENDER ?? '[email protected]'

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

Expand Down
20 changes: 16 additions & 4 deletions src/controllers/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { Middleware } from 'koa'
import { pick } from 'lodash'
import * as config from '../config'
import { sendMail } from '../services/mailerService'
import { generateHtmlMessage } from '../templates/generateMessage'
import { findWritableSettings, getBotFetch } from '../utils'

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

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

const subject = `Verify your email for ${config.appName} notifications`

await sendMail({
from: config.emailSender,
from: {
name: `${config.appName} notifications`,
address: config.emailSender,
},
to: email,
subject: 'Verify your email for sleepy.bike notifications',
html: `Please verify your email <a href="${emailVerificationLink}">click here</a>`,
subject,
html: await generateHtmlMessage('verification', {
actor: ctx.state.user,
emailVerificationLink,
title: subject,
}),
text: `Please verify your email ${emailVerificationLink}`,
})
ctx.response.body = 'Success'
Expand Down
24 changes: 19 additions & 5 deletions src/controllers/notification.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DefaultContext, Middleware } from 'koa'
import { emailSender } from '../config'
import { appName, emailSender } from '../config'
import { sendMail } from '../services/mailerService'
import { generateHtmlMessage } from '../templates/generateMessage'
import { getVerifiedEmails } from './status'

export type GoodBody = {
Expand Down Expand Up @@ -30,11 +31,24 @@ export const notification: Middleware<
)

for (const email of emails) {
const subject = `${body.actor.name || 'Someone'} wrote you from ${appName}`

await sendMail({
from: emailSender,
to: email,
subject: 'You have a new message from sleepy.bike!', // TODO generalize
html: body.object.content,
from: {
name: body.actor.name
? `${body.actor.name} (via ${appName})`
: `${appName} notifications`,
address: emailSender,
},
to: {
name: body.target.name ?? '',
address: email,
},
subject,
html: await generateHtmlMessage('message', {
...body,
title: subject,
}),
text: body.object.content,
})
}
Expand Down
15 changes: 13 additions & 2 deletions src/services/mailerService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import * as nodemailer from 'nodemailer'
import Mail from 'nodemailer/lib/mailer'
import { smtpTransportOptions } from '../config'
import * as path from 'path'
import { appLogo, smtpTransportOptions } from '../config'

export const sendMail = async (options: Mail.Options) => {
const smtpTransport = nodemailer.createTransport(smtpTransportOptions)
await smtpTransport.sendMail(options)
await smtpTransport.sendMail({
...options,
attachments: [
{
filename: path.basename(appLogo),
path: appLogo,
cid: '[email protected]',
},
...(options.attachments ?? []),
],
})
}
52 changes: 52 additions & 0 deletions src/templates/generateMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as fs from 'fs-extra'
import Handlebars from 'handlebars'
import juice from 'juice'
import path from 'path'
import * as config from '../config'

Handlebars.registerHelper('encodeURIComponent', encodeURIComponent)

type LayoutData = {
appName?: string
appLogo?: string
supportEmail?: string
title?: string
}

export const generateHtmlMessage = async <T>(
type: string,
data: T & LayoutData,
) => {
const layout = await fs.readFile(path.join(__dirname, 'layout.hbs'), 'utf8')
const layoutTemplate = Handlebars.compile(layout)
const content = await fs.readFile(path.join(__dirname, `${type}.hbs`), 'utf8')
const contentTemplate = Handlebars.compile<T>(content)
const stylesheet = await fs.readFile(
path.join(__dirname, 'styles.css'),
'utf8',
)

const {
appName = config.appName,
appLogo = config.appLogo,
supportEmail = config.supportEmail,
title = '',
} = data

const compiledContent = contentTemplate({
...data,
appName,
appLogo,
title,
})
const emailHtml = layoutTemplate({
title,
appName,
appLogo,
supportEmail,
body: compiledContent,
})
const emailHtmlInlineCss = juice(emailHtml, { extraCss: stylesheet })

return emailHtmlInlineCss
}
18 changes: 18 additions & 0 deletions src/templates/layout.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<html>
<head>
<meta charset='utf-8' />
<title>{{title}}</title>
</head>
<body>
<header><img
alt='logo of {{appName}}'
src='cid:[email protected]'
height='32'
/></header>
{{{body}}}
<footer>
<p>You can contact us at
<a href='mailto:{{supportEmail}}'>{{supportEmail}}</a></p>
</footer>
</body>
</html>
16 changes: 16 additions & 0 deletions src/templates/message.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<p>Hello{{#if target.name}}&nbsp;{{/if}}{{target.name}}!</p>
<p>
<a href={{actor.id}}>{{#if
actor.name
}}{{actor.name}}{{else}}Somebody{{/if}}</a>
sent you a message from
{{appName}}.
</p>

<blockquote>{{object.content}}</blockquote>

<a
href='https://sleepy.bike/messages/{{encodeURIComponent actor.id}}'
class='action-button'
>Reply on
{{appName}}</a>
21 changes: 21 additions & 0 deletions src/templates/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.action-button {
padding: 0.25rem 0.5rem;
text-align: center;
display: inline-block;
border: 2px solid black;
color: black;
text-decoration: none;
}

.action-button:hover {
background-color: #e6e6e6;
}

body {
font-family: Arial, sans-serif;
}

footer {
margin-top: 2rem;
font-size: 0.9rem;
}
23 changes: 23 additions & 0 deletions src/templates/verification.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<p>To verify your email for
{{appName}}
notifications, please click the following link:
</p>

<p>
<a href={{{emailVerificationLink}}} class='action-button'>
Verify my email for
{{appName}}</a>
</p>

<p>If the above link doesn't work, you can try copy-pasting the link directly
into your browser:</p>

<p
style='overflow-wrap:break-word;word-wrap:break-word'
>{{emailVerificationLink}}</p>

<p>This email verification for
{{appName}}
was initialized by Solid identity
{{actor}}. If you've received this message unexpectedly and you believe it to
be an error, please ignore it, or contact us for support.</p>
11 changes: 11 additions & 0 deletions src/test/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect } from 'chai'
import * as cheerio from 'cheerio'
import { createAccount } from 'css-authn/dist/7.x'
import * as puppeteer from 'puppeteer'
import { createSandbox } from 'sinon'
import { v4 as uuidv4 } from 'uuid'
import * as config from '../../config'
Expand Down Expand Up @@ -80,3 +81,13 @@ export const verifyEmail = async ({

return token
}

export const takeScreenshot = async (email: { html: string }, name: string) => {
const browser = await puppeteer.launch()

const page = await browser.newPage()
await page.setContent(email.html)
await page.screenshot({ path: `screenshots/${name}.png` })

await browser.close()
}
2 changes: 1 addition & 1 deletion src/test/notification.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('send notification via /notification', () => {
const emailNotification = sendMailSpy.firstCall.firstArg

expect(emailNotification).to.exist
expect(emailNotification.to).to.equal(email)
expect(emailNotification.to).to.haveOwnProperty('address', email)

// TODO
})
Expand Down
Loading

0 comments on commit 99f3a99

Please sign in to comment.