// 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 \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 \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: '' }, baseUrl: BASE }) const block = { id: 'm', type: 'email.text', props: { text: '{{message}}' } } const out = emailBlocks.renderBlocks([block], ctx) assert.match(out.html, /<script>alert\(1\)<\/script>/) assert.equal(out.html.includes('') }) 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, /^/) assert.match(r.html, /role="presentation"/) assert.equal(r.html.includes(' 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) })