Skip to content

Commit

Permalink
Format emails to have better content
Browse files Browse the repository at this point in the history
Also generate email screenshots while running tests
  • Loading branch information
mrkvon committed Feb 23, 2024
1 parent 5da0930 commit a305437
Show file tree
Hide file tree
Showing 16 changed files with 722 additions and 47 deletions.
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ PORT=3005

# name of the app that sends the emails - featured in the message subject or body
APP_NAME=sleepy.bike
# provide logo to display on top of emails
# it can be base64 encoded url, or path to an image - keep it small!
# by default the logo doesn't show - it's one transparent pixel
APP_LOGO=
SUPPORT_EMAIL=[email protected]

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

*.pem

# Screenshots of emails from testing
screenshots
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,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 @@ -45,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 Down Expand Up @@ -73,6 +74,7 @@
"koa-helmet": "^7.0.2",
"koa-static": "^5.0.0",
"lodash": "^4.17.21",
"mime": "^3",
"n3": "^1.17.0",
"nodemailer": "^6.9.4",
"parse-link-header": "^2.0.0"
Expand Down
12 changes: 10 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import 'dotenv/config'
import SMTPTransport from 'nodemailer/lib/smtp-transport'
import { getBase64Image } from '../utils'

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

// 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'
export const appName = process.env.APP_NAME ?? 'Sleepy.Bike'

// default is 1 transparent PNG pixel
export const appLogo = getBase64Image(
process.env.APP_LOGO ??
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
)
export const supportEmail = process.env.SUPPORT_EMAIL ?? '[email protected]'

// identity under which the mailer is operating
export const mailerCredentials = {
Expand All @@ -28,7 +36,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 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 ${config.appName} 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
9 changes: 7 additions & 2 deletions src/controllers/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const notification: Middleware<
)

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

await sendMail({
from: {
name: body.actor.name
Expand All @@ -42,8 +44,11 @@ export const notification: Middleware<
name: body.target.name ?? '',
address: email,
},
subject: `${body.actor.name || 'Someone'} wrote you from ${appName}`,
html: await generateHtmlMessage('message', body),
subject,
html: await generateHtmlMessage('message', {
...body,
title: subject,
}),
text: body.object.content,
})
}
Expand Down
43 changes: 39 additions & 4 deletions src/templates/generateMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,51 @@ import * as fs from 'fs-extra'
import Handlebars from 'handlebars'
import juice from 'juice'
import path from 'path'
import * as config from '../config'

export const generateHtmlMessage = async <T>(type: string, data: T) => {
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)
const emailHtml = layoutTemplate({ title: 'asdf', body: compiledContent })
const emailHtmlInlineCss = juice(emailHtml)
const compiledContent = contentTemplate({
...data,
appName,
appLogo,
title,
})
const emailHtml = layoutTemplate({
title,
appName,
appLogo,
supportEmail,
body: compiledContent,
})
const emailHtmlInlineCss = juice(emailHtml, { extraCss: stylesheet })

return emailHtmlInlineCss
}
12 changes: 7 additions & 5 deletions src/templates/layout.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@
<head>
<meta charset='utf-8' />
<title>{{title}}</title>
<style>
/* Email styles go here */ body { font-family: Arial, sans-serif; }
</style>
</head>
<body>
<header><img
alt='logo of {{appName}}'
src={{appLogo}}
height='32'
/></header>
{{{body}}}
<footer>
<p>Contact us at
<a href='mailto:[email protected]'>[email protected]</a></p>
<p>You can contact us at
<a href='mailto:{{supportEmail}}'>{{supportEmail}}</a></p>
</footer>
</body>
</html>
20 changes: 13 additions & 7 deletions src/templates/message.hbs
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
<p>Hello{{#if target.name}}&nbsp;{{/if}}{{target.name}}!</p>
<p>
Hello
{{target.name}}!</p>
<a href={{actor.id}}>{{actor.name}}</a>
sent you a message from
{{appName}}.
<a href={{actor.id}}>{{#if
actor.name
}}{{actor.name}}{{else}}Somebody{{/if}}</a>
sent you a message from
{{appName}}.
</p>

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

<a href='https://sleepy.bike/messages/{{actor.id}}'>Reply on {{appName}}</a>
<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;
}
21 changes: 21 additions & 0 deletions src/templates/verification.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<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>{{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()
}
71 changes: 71 additions & 0 deletions src/test/showcase.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { expect } from 'chai'
import fetch from 'cross-fetch'
import { describe } from 'mocha'
import { baseUrl } from '../config'
import type { GoodBody } from '../controllers/notification'
import { takeScreenshot, verifyEmail } from './helpers'
import {
authenticatedFetch,
authenticatedFetch3,
person,
person3,
} from './testSetup.spec'

const email = '[email protected]'

/**
* Generate body for POST /notification
*/
const getBody = ({
from,
to,
message,
}: {
from: string
to: string
message: string
}): GoodBody => ({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Create',
actor: { type: 'Person', id: from, name: 'PersonFromName' },
object: { type: 'Note', id: 'https://example', content: message },
target: { type: 'Person', id: to, name: 'PersonToName' },
})

describe('Generate screenshots of emails for visual testing', () => {
// empty the maildev inbox
beforeEach(async () => {
await fetch('http://localhost:1080/email/all', { method: 'DELETE' })
})

beforeEach(async () => {
// setup email for receiver
await verifyEmail({
email,
person: person3,
authenticatedFetch: authenticatedFetch3,
})
})

it('[everything ok] should generate screenshots of email verification and notification in ./screenshots/ folder', async () => {
const response = await authenticatedFetch(`${baseUrl}/notification`, {
method: 'post',
headers: { 'content-type': 'application/ld+json' },
body: JSON.stringify(
getBody({ from: person.webId, to: person3.webId, message: 'Hello!' }),
),
})

expect(response.status).to.equal(202)

const emailResponse = await fetch('http://localhost:1080/email')
const emails = await emailResponse.json()

// one verification email and one notification
expect(emails).to.have.length(2)

for (const i in emails) {
await takeScreenshot(emails[i], `email${i}`)
}
})
})
2 changes: 1 addition & 1 deletion src/test/testSetup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ let maildev: InstanceType<typeof MailDev>
before(done => {
maildev = new MailDev({
silent: true, // Set to false if you want to see server logs
disableWeb: true, // Disable the web interface for testing
disableWeb: false, // Disable the web interface for testing
})
maildev.listen(done)
})
Expand Down
Loading

0 comments on commit a305437

Please sign in to comment.