Files
website/server/test/emailTemplates.test.js
wtclaude 12ff201ed5
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
Every subject and body moves out of `mailer.js` into `engagement_templates` rows an
operator can edit. A relocation, not a regression: nothing that sends mail today
starts depending on an operator authoring something first.

- `email.*` block family in its own registry, sharing the page family's envelope
  walk and validate-then-sanitize order by binding rather than by copy.
- A server-side renderer producing both parts of a multipart message; the text
  part is byte-identical to the literals this commit deletes.
- Nine seeded templates, six of them wired now; the seeder's `customized = 0`
  guard lives in the UPDATE's own WHERE.
- `renderByKey` falls back to the shipped seed when a row is missing or unusable,
  so no failure of the table can stop a password reset.

Also fixes `check:hosts` reading the template key `auth.email-verify` as the
hostname `auth.email`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 13:07:39 -05:00

469 lines
21 KiB
JavaScript

// Engagement Phase 5a — the `email.*` block family, the renderer, and the shipped
// template set (ENGAGEMENT.md §4.4 / §4.6.1).
//
// The centrepiece is the byte-comparison block: §5a's acceptance criterion is that
// "every one of the five current message types renders byte-comparably from its
// seeded template", so the strings below are the LITERALS this phase deleted from
// `utils/mailer.js`, copied character for character. If a seed's wording changes,
// these fail — which is the point. They are the only thing standing between an
// edit to a block array and a silently reworded password-reset mail.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test } = require('node:test')
const assert = require('node:assert/strict')
const emailBlocks = require('../src/emailBlocks')
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('../src/engagement/templateSeeds')
const templatesDb = require('../src/model/engagement/engagementTemplates.db')
const settings = require('../src/model/settings/settings.model')
const templates = require('../src/engagement/templates')
const SITE = 'Runic Gateway'
const BASE = 'https://shard.example.com'
/** Render one seed the way mailer does, without any of the database. */
function render(key, values = {}, opts = {}) {
const seed = seedByKey(key)
const ctx = emailBlocks.buildContext({
values: { siteName: SITE, siteUrl: BASE, year: '2026', logoUrl: '', ...values },
baseUrl: BASE,
theme: opts.theme || {},
})
const out = emailBlocks.renderBlocks(seed.blocks, ctx)
return {
subject: seed.subject ? ctx.t(seed.subject) : '',
text: out.text,
html: emailBlocks.renderDocument(out.html, ctx),
rows: out.html,
missing: [...ctx.missing],
}
}
// ── The acceptance criterion: byte-comparable bodies ────────────────────────
test('password reset renders byte-identically to the literal it replaced', () => {
const r = render('auth.password-reset', {
resetUrl: 'https://shard.example.com/reset/tok',
username: 'Darrow',
forWhom: ' for the account “Darrow”',
})
assert.equal(r.subject, `Reset your ${SITE} password`)
assert.equal(
r.text,
`We received a request to reset the password for the account “Darrow” at ${SITE}.\n\n` +
'Choose a new password here:\nhttps://shard.example.com/reset/tok\n\n' +
"This link is single-use and expires in about an hour. If you didn't request " +
"this, you can safely ignore this email — your password won't change.",
)
})
test('password reset with no username keeps the other branch byte-identical too', () => {
// The ternary lives at the call site and reaches the template as a value, so
// BOTH of its branches have to survive the move — the empty one is the branch a
// template language with a conditional would most likely get wrong.
const r = render('auth.password-reset', { resetUrl: 'https://x.test/r', forWhom: '' })
assert.match(r.text, /^We received a request to reset the password at Runic Gateway\.\n\n/)
})
test('invite renders byte-identically', () => {
const r = render('auth.invite', {
acceptUrl: 'https://shard.example.com/invite/tok',
roleLabel: ' as moderator',
invitedBy: ' by Aldric',
})
assert.equal(r.subject, `Your ${SITE} invitation`)
assert.equal(
r.text,
`You have been invited by Aldric to join ${SITE} as moderator.\n\n` +
'Accept your invitation and set up your account here:\nhttps://shard.example.com/invite/tok\n\n' +
"This link is single-use and will expire. If you weren't expecting this, you can ignore it.",
)
})
test('email verification renders byte-identically', () => {
const r = render('auth.email-verify', {
verifyUrl: 'https://shard.example.com/verify/tok',
forWhom: ' “Darrow”',
})
assert.equal(r.subject, `Confirm your email address for ${SITE}`)
assert.equal(
r.text,
`The ${SITE} account “Darrow” asked to use this address for contact and account recovery.\n\n` +
'Confirm it here:\nhttps://shard.example.com/verify/tok\n\n' +
'This link is single-use and expires in about a day. Until it is used, nothing changes — ' +
'the account keeps whatever address it had.\n\n' +
'If you did not ask for this, you can ignore this email. Someone may have mistyped their ' +
'own address; no account of yours is affected and this link grants no access to anything.',
)
})
test('contact message renders byte-identically, both fallbacks included', () => {
const named = render('admin.contact-message', {
fromLabel: 'Ann',
fromName: 'Ann',
fromEmail: 'ann@player.com',
message: 'Is the shard open?',
})
assert.equal(named.subject, `${SITE} contact from Ann`)
assert.equal(named.text, 'From: Ann <ann@player.com>\n\nIs the shard open?')
// The two different fallbacks for one missing name are inherited from the
// literal and are asserted so a later tidy-up is a deliberate change.
const anon = render('admin.contact-message', {
fromLabel: 'a visitor',
fromName: 'unknown',
fromEmail: 'no email',
message: 'hi',
})
assert.equal(anon.subject, `${SITE} contact from a visitor`)
assert.equal(anon.text, 'From: unknown <no email>\n\nhi')
})
test('delivery test renders byte-identically', () => {
const r = render('admin.test', { transport: 'smtp' })
assert.equal(r.subject, `${SITE} email test`)
assert.equal(r.text, 'This is a test message confirming smtp email delivery is working.')
})
// ── The registries are siblings, not one namespace ──────────────────────────
test('an email block is not a page block, and a page block is not an email block', () => {
const pageBlocks = require('../src/blocks')
const asPage = pageBlocks.validateBlocks([{ id: 'a', type: 'email.heading', props: { level: 'h1', text: 'x' } }])
assert.equal(asPage.valid, false)
assert.match(asPage.errors.join(' '), /not a registered block type \(email\.heading\)/)
const asEmail = emailBlocks.validateEmailBlocks([{ id: 'a', type: 'heading', props: { level: 'h1', text: 'x' } }])
assert.equal(asEmail.valid, false)
assert.match(asEmail.errors.join(' '), /not a registered block type \(heading\)/)
})
test('the shared walk enforces the same envelope for both families', () => {
const r = emailBlocks.validateEmailBlocks([
{ id: 'a', type: 'email.text', props: { text: 'one' }, smuggled: 1 },
{ id: 'a', type: 'email.text', props: { text: 'two' } },
])
assert.equal(r.valid, false)
assert.match(r.errors.join(' '), /smuggled is not an allowed top-level key/)
assert.match(r.errors.join(' '), /duplicates another block id/)
})
test('a block registered without both renderers is refused at registration', () => {
const { registerEmailBlock } = require('../src/emailBlocks/registry')
assert.throws(
() => registerEmailBlock({ type: 'email.broken', toHtml: () => '' }),
/needs both toHtml and toText/,
)
assert.throws(
() => registerEmailBlock({ type: 'notEmail', toHtml: () => '', toText: () => '' }),
/must be namespaced "email\."/,
)
})
// ── Interpolation and the security posture (§4.6.2) ─────────────────────────
test('an interpolated variable containing markup renders escaped in HTML and raw in text', () => {
const ctx = emailBlocks.buildContext({ values: { message: '<script>alert(1)</script>' }, baseUrl: BASE })
const block = { id: 'm', type: 'email.text', props: { text: '{{message}}' } }
const out = emailBlocks.renderBlocks([block], ctx)
assert.match(out.html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/)
assert.equal(out.html.includes('<script>'), false)
// The text part has no markup to escape into; `&lt;` in an inbox is the bug.
assert.equal(out.text, '<script>alert(1)</script>')
})
test('a variable carrying a javascript: url never becomes an href', () => {
const ctx = emailBlocks.buildContext({ values: { link: 'javascript:alert(1)' }, baseUrl: BASE })
const block = { id: 'b', type: 'email.button', props: { label: 'Press me', url: '{{link}}' } }
const out = emailBlocks.renderBlocks([block], ctx)
assert.equal(out.html.includes('href'), false)
assert.match(out.html, /Press me/) // inert, but not silently vanished
})
test('a literal unsafe url is refused at save, and a tokened one is allowed through', () => {
const bad = emailBlocks.validateEmailBlocks([
{ id: 'b', type: 'email.button', props: { label: 'x', url: 'javascript:alert(1)' } },
])
assert.equal(bad.valid, false)
const tokened = emailBlocks.validateEmailBlocks([
{ id: 'b', type: 'email.button', props: { label: 'x', url: '{{resetUrl}}' } },
])
assert.equal(tokened.valid, true)
})
test('the token grammar is names only — an expression is not a token', () => {
assert.deepEqual(emailBlocks.scanTokens('{{ user }} and {{other}}'), ['user', 'other'])
assert.deepEqual(emailBlocks.scanTokens('{{ user.email }}'), [])
const ctx = emailBlocks.buildContext({ values: { user: { email: 'a@b.c' } }, baseUrl: BASE })
assert.equal(ctx.t('{{ user.email }}'), '{{ user.email }}')
})
// ── "Nothing in, nothing out" — the stand-in for a conditional ──────────────
test('a block whose only content is an absent variable disappears from BOTH parts', () => {
const r = render('notify.digest', {
periodLabel: 'your daily summary',
intro: 'Here is what happened.',
items: [{ heading: 'A thread', url: '/teams/1' }],
// moreNote, scopeUrl and unsubscribeUrl all absent
})
assert.equal(r.text.includes('undefined'), false)
assert.equal(r.text.includes('Unsubscribe'), false)
assert.equal(r.rows.includes('Unsubscribe'), false)
assert.equal(r.text, 'Here is what happened.\n\nA thread\n https://shard.example.com/teams/1')
})
test('the divider contributes to the HTML and nothing to the text', () => {
const ctx = emailBlocks.buildContext({ values: {}, baseUrl: BASE })
const out = emailBlocks.renderBlocks(
[
{ id: 'a', type: 'email.text', props: { text: 'one' } },
{ id: 'r', type: 'email.divider', props: {} },
{ id: 'b', type: 'email.text', props: { text: 'two' } },
],
ctx,
)
assert.equal(out.text, 'one\n\ntwo') // no dashes, and no doubled blank line
assert.match(out.html, /background:#dfe4ea/)
})
test('an absent variable is reported rather than rendered as the word undefined', () => {
const r = render('auth.password-reset', { resetUrl: 'https://x.test/r' })
assert.equal(r.text.includes('undefined'), false)
assert.deepEqual(r.missing.sort(), ['forWhom'])
})
// ── The item list ───────────────────────────────────────────────────────────
test('itemList renders the shape teamNotify already builds, and survives a bad one', () => {
const ctx = emailBlocks.buildContext({
values: {
items: [
{ heading: 'First', excerpt: 'a line', url: '/teams/1?thread=9' },
'Second',
{ excerpt: 'no heading' }, // dropped: an item with nothing to name
null,
],
},
baseUrl: BASE,
})
const out = emailBlocks.renderBlocks([{ id: 'l', type: 'email.itemList', props: { variable: 'items' } }], ctx)
assert.equal(out.text, 'First\n a line\n https://shard.example.com/teams/1?thread=9\n\nSecond')
assert.match(out.html, /First/)
})
test('an empty list renders emptyText, or nothing at all when there is none', () => {
const ctx = emailBlocks.buildContext({ values: { items: [] }, baseUrl: BASE })
const withText = emailBlocks.renderBlocks(
[{ id: 'l', type: 'email.itemList', props: { variable: 'items', emptyText: 'Nothing new.' } }],
ctx,
)
assert.equal(withText.text, 'Nothing new.')
const without = emailBlocks.renderBlocks([{ id: 'l', type: 'email.itemList', props: { variable: 'items' } }], ctx)
assert.equal(without.text, '')
assert.equal(without.html, '')
})
// ── Branding is data (§4.6.1 property 2) ────────────────────────────────────
test('every shipped template validates against the email registry', () => {
for (const seed of SEEDS) {
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
assert.equal(valid, true, `${seed.key}: ${errors.join('; ')}`)
assert.equal(typeof seed.seedVersion, 'number')
assert.equal(['email', 'inapp'].includes(seed.channel), true)
}
// The five transactional bodies the system itself depends on are protected.
const guarded = SEEDS.filter((s) => s.protected).map((s) => s.key)
assert.deepEqual(guarded, [
'auth.password-reset',
'auth.invite',
'auth.email-verify',
'admin.contact-message',
'admin.test',
])
})
test('an invalid shipped template is refused rather than stored', async () => {
const seeded = []
templatesDb.seedOne = async (t) => {
seeded.push(t.key)
return 'inserted'
}
templatesDb.staleCustomized = async () => []
const original = SEEDS[0].blocks
try {
SEEDS[0].blocks = [{ id: 'x', type: 'email.nope', props: {} }]
const r = await templates.seedTemplates()
assert.equal(r.invalid, 1)
assert.equal(seeded.includes(SEEDS[0].key), false)
} finally {
SEEDS[0].blocks = original
}
})
test('no seeded template contains a hex colour or a hostname', () => {
for (const seed of SEEDS) {
const json = JSON.stringify(seed.blocks) + String(seed.subject || '')
assert.equal(/#[0-9a-fA-F]{6}\b/.test(json), false, `${seed.key} contains a hex colour`)
assert.equal(/https?:\/\//.test(json), false, `${seed.key} contains a literal URL`)
}
})
test('the accent comes from the resolved theme, and its text colour is readable over it', () => {
const light = emailBlocks.palette({ accent: '#f4d35e' })
assert.equal(light.accent, '#f4d35e')
assert.equal(light.onAccent, '#151a20')
const dark = emailBlocks.palette({ accent: '#2b3a55' })
assert.equal(dark.onAccent, '#ffffff')
// A stored value that is not a colour degrades to the shipped accent rather
// than reaching an inline style — the forgiving-on-read posture.
assert.equal(emailBlocks.palette({ accent: 'red; }' }).accent, '#7f99bd')
})
test('a relative image url is absolutized, and an unresolvable one is dropped', () => {
const withBase = emailBlocks.buildContext({ values: { logoUrl: '/uploads/logo.png' }, baseUrl: BASE })
const block = { id: 'i', type: 'email.image', props: { url: '{{logoUrl}}', alt: 'Logo' } }
assert.match(emailBlocks.renderBlocks([block], withBase).html, /src="https:\/\/shard\.example\.com\/uploads\/logo\.png"/)
const noBase = emailBlocks.buildContext({ values: { logoUrl: '/uploads/logo.png' }, baseUrl: '' })
const out = emailBlocks.renderBlocks([block], noBase)
assert.equal(out.html, '') // no broken image in someone's inbox
assert.equal(out.text, 'Logo') // the alt still stands in for it
})
// ── The document shell ──────────────────────────────────────────────────────
test('the shell adds structure and no content', () => {
const r = render('admin.test', { transport: 'smtp' })
// Everything the text part says, the HTML says; nothing the HTML says is
// absent from the text. A footer in one and not the other is the failure.
assert.match(r.html, /This is a test message confirming smtp email delivery is working\./)
assert.equal(/unsubscribe/i.test(r.html), false)
assert.equal(/sent by/i.test(r.html), false)
assert.match(r.html, /^<!doctype html>/)
assert.match(r.html, /role="presentation"/)
assert.equal(r.html.includes('<style'), false) // a <style> block is what clients strip
})
// ── The variable contract ───────────────────────────────────────────────────
test('every seeded variable carries an example, and every token is declared', () => {
for (const seed of SEEDS) {
const declared = new Set([...seed.variables.map((v) => v.name), ...AMBIENT_VARIABLES.map((v) => v.name)])
for (const v of seed.variables) {
assert.notEqual(v.example, undefined, `${seed.key}.${v.name} has no example`)
}
const used = new Set()
for (const b of seed.blocks) {
for (const value of Object.values(b.props)) {
if (typeof value === 'string') emailBlocks.scanTokens(value).forEach((t) => used.add(t))
}
}
if (seed.subject) emailBlocks.scanTokens(seed.subject).forEach((t) => used.add(t))
for (const name of used) {
assert.equal(declared.has(name), true, `${seed.key} references undeclared {{${name}}}`)
}
}
})
test('variablesFor answers from the seed for a template with no trigger, plus the ambient set', () => {
const names = templates.variablesFor({ seed_key: 'auth.invite' }).map((v) => v.name)
assert.deepEqual(names, ['acceptUrl', 'roleLabel', 'invitedBy', 'siteName', 'siteUrl', 'logoUrl', 'year'])
// A template that is neither seeded nor tied to a trigger still gets the brand
// values, because those come from the deployment rather than from the message.
assert.deepEqual(templates.variablesFor({}).map((v) => v.name), ['siteName', 'siteUrl', 'logoUrl', 'year'])
})
// ── The seeder and the render entrypoint ────────────────────────────────────
test('the shipped default is used when the row is missing, and when it is unusable', async () => {
settings.getInstanceName = async () => SITE
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
templatesDb.getByKey = async () => null
const missing = await templates.renderByKey('admin.test', { transport: 'smtp' })
assert.equal(missing.text, 'This is a test message confirming smtp email delivery is working.')
// A row whose blocks would not parse hydrates to [] (engagementTemplates.db.js)
// and must fall back too — this is the hand-edited-row case, and the one that
// would otherwise send an empty password reset.
templatesDb.getByKey = async () => ({ subject: 'wrong', blocks: [], text_body: null })
const broken = await templates.renderByKey('admin.test', { transport: 'smtp' })
assert.equal(broken.subject, `${SITE} email test`)
assert.equal(await templates.renderByKey('nope.not-a-key', {}), null)
})
test('an operator edit is rendered instead of the seed, and text_body overrides the generated text', async () => {
settings.getInstanceName = async () => SITE
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
templatesDb.getByKey = async () => ({
subject: 'Edited: {{siteName}}',
blocks: [{ id: 'a', type: 'email.text', props: { text: 'Generated body.' } }],
text_body: 'A hand-written text part for {{siteName}}.',
})
const r = await templates.renderByKey('admin.test', {})
assert.equal(r.subject, `Edited: ${SITE}`)
assert.equal(r.text, `A hand-written text part for ${SITE}.`)
assert.match(r.html, /Generated body\./) // the override replaces the TEXT part only
})
test('a caller cannot override the deployment brand', async () => {
settings.getInstanceName = async () => SITE
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
templatesDb.getByKey = async () => null
const r = await templates.renderByKey('admin.test', { transport: 'smtp', siteName: 'Somewhere Else' })
assert.equal(r.subject, `${SITE} email test`)
})
test('the seeder inserts, then skips, and never touches a customized row', async () => {
const calls = []
const state = new Map() // key → { seedVersion, customized }
templatesDb.seedOne = async (t) => {
calls.push(t.key)
const row = state.get(t.key)
if (!row) {
state.set(t.key, { seedVersion: t.seedVersion, customized: false })
return 'inserted'
}
if (row.customized) return 'skipped'
if (row.seedVersion < t.seedVersion) {
row.seedVersion = t.seedVersion
return 'updated'
}
return 'skipped'
}
templatesDb.staleCustomized = async () => []
const first = await templates.seedTemplates()
assert.equal(first.inserted, SEEDS.length)
assert.equal(calls.length, SEEDS.length)
const second = await templates.seedTemplates()
assert.equal(second.skipped, SEEDS.length)
assert.equal(second.inserted, 0) // re-running the seeder is a no-op
// A version bump reaches an untouched row and stops at a customized one.
state.get('auth.invite').seedVersion = 0
state.get('auth.password-reset').seedVersion = 0
state.get('auth.password-reset').customized = true
const third = await templates.seedTemplates()
assert.equal(third.updated, 1)
assert.equal(state.get('auth.password-reset').seedVersion, 0) // the operator's row, untouched
})
test('a seed that throws does not stop the boot or the other seeds', async () => {
let n = 0
templatesDb.seedOne = async () => {
n += 1
if (n === 2) throw new Error('deadlock')
return 'inserted'
}
templatesDb.staleCustomized = async () => []
const r = await templates.seedTemplates()
assert.equal(r.inserted, SEEDS.length - 1)
})