Files
website/server/test/emailTemplates.test.js
wtclaude 3f90070566
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)
Phase 5a gave templates a table, a renderer and nine seeded rows; nothing could
change one. This is the screen that lets an operator change one without being able
to break the mail the system depends on — plus the two screens Q4 promised Phase 5:
Triggers (read-only, from the registries) and the Send Log, which closes G15.

The shape follows from one fact: a mail body is rendered by the SERVER, so the
preview is too, and framed rather than redrawn in React. A client-side renderer
would be a second implementation of the one artifact that matters, agreeing with
the send path on the day it was written and drifting from the first Outlook fix on.

Settled with the org lead before any code: a shipped default is edited IN PLACE
(`protected` blocks deletion and nothing else, `customized = 1` keeps the edit);
duplicate is the only way to a new template; `renderByKey` now requires
`published`; a test send is logged under a synthetic `core.admin.test-send`; and a
template a rule points at refuses deletion with a 409 naming the rules.

Three things the plan did not know, found by building it:

  - The undeclared-variable check cannot be a token scan. `email.itemList.variable`
    holds a BARE name, so a digest pointed at `itmes` would have saved clean and
    arrived empty. Blocks now declare `variables(props)`; the editor makes that
    field a select over the trigger's list variables so the typo is unavailable.
  - A duplicate that drops `seed_key` loses its variable palette, so duplicating
    `notify.event` would have been refused for the tokens it was copied with — the
    one action §4.6.2 offers, refusing itself. The copy inherits it; `customized`
    is what the seeder actually reads.
  - `validateEmailBlocks` returns `{ valid, errors }`, not an array, and the first
    version tested it with `.length` — so block validation never ran at all.

Also fixes a Phase 4a defect the live walk found, with the org lead's approval: a
rule's template key was checked against a pattern with no dot in it, so no rule
could name any template that exists — §4.6.2's whole duplicate-and-point-a-rule-at-it
workflow was unreachable. Both models now read one pattern.

Verified against the running stack: real multipart mail into a mailpit catcher
including an unsaved draft, the draft/published arms both ways through the real
mailer path, every refusal, and the end-to-end duplicate → rule → 409 walk.
Server 1428 tests green, client 324.

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

493 lines
22 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)
})
// Phase 5b, decision 3. `status` existed from 5a and nothing read it, so an
// operator who saved a template as a draft kept mailing it — the editor offered a
// working state that did not work. A draft now means what the word means.
test('a draft row is not what goes out; the shipped seed is', async () => {
settings.getInstanceName = async () => SITE
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
templatesDb.getByKey = async () => ({
subject: 'Half-written: {{siteName}}',
blocks: [{ id: 'a', type: 'email.text', props: { text: 'Not ready.' } }],
text_body: null,
status: 'draft',
})
const r = await templates.renderByKey('admin.test', { transport: 'smtp' })
assert.equal(r.subject, `${SITE} email test`)
assert.doesNotMatch(r.text, /Not ready\./)
// ...and a seedless draft — a duplicate still being written — has nothing to
// fall back TO, so it renders nothing rather than sending half a message.
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}}.',
// Phase 5b: only a PUBLISHED row is sent. The fixture carried no status until
// then because nothing read the column.
status: 'published',
})
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)
})