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>
197 lines
8.6 KiB
JavaScript
197 lines
8.6 KiB
JavaScript
// ── Rendering a block array into a mail body ───────────────────────────────
|
|
//
|
|
// Pure and synchronous: everything that needs a database — the brand values, the
|
|
// resolved theme, the site title — is resolved by `engagement/templates.js` and
|
|
// arrives here as a plain object. That split is what lets the whole renderer be
|
|
// tested without a MariaDB, and it is why the byte-comparison test for the five
|
|
// transactional bodies (§5a acceptance) is a unit test rather than a live send.
|
|
//
|
|
// **The shell contributes structure and NO content.** No appended footer, no
|
|
// injected logo, no "sent by" line. Two reasons, and the second is the load-bearing
|
|
// one:
|
|
//
|
|
// - A person's mail must say what the operator wrote and nothing else. An
|
|
// unsubscribe line is a variable inside the template (§4.6.1 lists
|
|
// `unsubscribeUrl` for exactly the two templates that need one), so an operator
|
|
// can move it, reword it, or see that a transactional mail correctly has none.
|
|
// - **The HTML and text parts must say the same things.** A shell that put a
|
|
// footer only in the HTML would make every message's two parts disagree, which
|
|
// is a deliverability signal and, worse, means the text reader is told less
|
|
// than the HTML reader. Every block produces both halves; nothing else does.
|
|
//
|
|
// The HTML is table-based and inline-styled throughout, which is not a stylistic
|
|
// choice: `<div>` layout and a `<style>` block are the two things mail clients
|
|
// most reliably break.
|
|
|
|
const { htmlEscape, interpolate } = require('./interpolate')
|
|
const { getEmailBlock } = require('./registry')
|
|
const { makeValidateBlocks } = require('../blocks/validateBlocks')
|
|
const { makeSanitizeBlocks } = require('../blocks/sanitizeBlocks')
|
|
const { isSafeUrl } = require('../blocks/propHelpers')
|
|
|
|
// Bound to the email registry — the same walk the page family gets, so the
|
|
// envelope rules, id uniqueness and schema dispatch cannot drift between them.
|
|
const validateEmailBlocks = makeValidateBlocks(getEmailBlock, { maxBlocks: 60 })
|
|
const sanitizeEmailBlocks = makeSanitizeBlocks(getEmailBlock)
|
|
|
|
// A stack every mail client resolves. No webfont: a @font-face in mail is either
|
|
// stripped or silently ignored, and the fallback is what the reader sees anyway.
|
|
const FONT_STACK = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
|
|
|
|
/**
|
|
* The mail palette — a light scaffold plus the deployment's accent.
|
|
*
|
|
* **Only the accent comes from the theme, and that is deliberate.** Every shipped
|
|
* preset (`config/themePresets.js`) is a DARK palette, and mail is not a page: a
|
|
* dark-background body is what §4.6.2 names as rendering "unreadable dark-on-dark
|
|
* in about a third of inboxes", because a good share of clients invert or force a
|
|
* background of their own. Deriving a light palette from a dark one would be a
|
|
* guess at six colours; taking the one colour that carries the brand — the accent,
|
|
* used for the button and for links — is exact. §4.6.1's property 2 holds either
|
|
* way: no seeded template contains a hex code, so one prebuilt image running as
|
|
* any shard mails in that shard's colour.
|
|
*
|
|
* @param {{ accent?: string }} [theme] resolved theme tokens
|
|
*/
|
|
function palette(theme = {}) {
|
|
const accent = isHex(theme.accent) ? theme.accent : '#7f99bd'
|
|
return Object.freeze({
|
|
accent,
|
|
onAccent: readableOn(accent),
|
|
heading: '#151a20',
|
|
text: '#33404d',
|
|
muted: '#6b7885',
|
|
rule: '#dfe4ea',
|
|
page: '#f4f6f8',
|
|
card: '#ffffff',
|
|
fontStack: FONT_STACK,
|
|
})
|
|
}
|
|
|
|
function isHex(v) {
|
|
return typeof v === 'string' && /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(v)
|
|
}
|
|
|
|
/** Black or white text over `hex`, whichever a reader can actually read. */
|
|
function readableOn(hex) {
|
|
let h = hex.slice(1)
|
|
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
|
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
|
|
// Relative luminance (WCAG). 0.45 rather than 0.5: the accents here are mid-tone
|
|
// and white-on-mid reads better than black-on-mid at button weight.
|
|
const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
|
|
const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
|
return L > 0.45 ? '#151a20' : '#ffffff'
|
|
}
|
|
|
|
/**
|
|
* Build the render context every block's `toHtml` / `toText` receives.
|
|
*
|
|
* @param {object} opts
|
|
* @param {Record<string, unknown>} opts.values variable values
|
|
* @param {object} [opts.theme] resolved theme tokens
|
|
* @param {string} [opts.baseUrl] absolute site base, for relative urls
|
|
* @param {Set<string>} [opts.missing] collects unresolved variable names
|
|
*/
|
|
function buildContext({ values = {}, theme = {}, baseUrl = '', missing = new Set() }) {
|
|
const base = String(baseUrl || '').replace(/\/+$/, '')
|
|
const ctx = {
|
|
values,
|
|
missing,
|
|
palette: palette(theme),
|
|
escape: htmlEscape,
|
|
/** Interpolate + HTML-escape — for anything going into markup. */
|
|
h: (s) => interpolate(s, values, { escape: true, missing }),
|
|
/** Interpolate WITHOUT escaping — for the plain-text part only. */
|
|
t: (s) => interpolate(s, values, { escape: false, missing }),
|
|
/**
|
|
* Interpolate a URL and re-check it. Returns the URL or null.
|
|
*
|
|
* A stored `{{resetUrl}}` says nothing about where it points; the value
|
|
* arrives from a caller or a module at render time. Checking only the stored
|
|
* literal would mean a variable carrying `javascript:` becomes an href.
|
|
*/
|
|
safeHref: (s) => {
|
|
const url = interpolate(s, values, { escape: false, missing })
|
|
return url && isSafeUrl(url) ? url : null
|
|
},
|
|
/** Same-origin path → absolute URL; http(s) unchanged; anything else null. */
|
|
absolute: (url) => {
|
|
if (!url) return null
|
|
if (/^https?:\/\//i.test(url)) return url
|
|
if (url.startsWith('/')) return base ? `${base}${url}` : null
|
|
return null
|
|
},
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
/**
|
|
* Render a blocks array into the two body parts.
|
|
*
|
|
* Blocks are joined by a blank line in text and stacked as table rows in HTML.
|
|
* A block whose `toText` returns '' contributes nothing to the text part and does
|
|
* not leave a doubled blank line behind it (`email.divider` is the case).
|
|
*
|
|
* @returns {{ html: string, text: string }} html is the ROWS, not a document
|
|
*/
|
|
function renderBlocks(blocks, ctx) {
|
|
const rows = []
|
|
const paras = []
|
|
for (const block of Array.isArray(blocks) ? blocks : []) {
|
|
if (block && block.visible === false) continue
|
|
const def = block && typeof block.type === 'string' ? getEmailBlock(block.type) : null
|
|
if (!def) continue // unreachable after validation; never emit an unknown block
|
|
const props = block.props && typeof block.props === 'object' ? block.props : {}
|
|
try {
|
|
const html = def.toHtml(props, ctx)
|
|
if (html) rows.push(html)
|
|
const text = def.toText(props, ctx)
|
|
if (text) paras.push(text)
|
|
} catch {
|
|
// One misbehaving block must not cost the whole message. Skipped in both
|
|
// parts together, so the two never disagree about what the mail contains.
|
|
}
|
|
}
|
|
return { html: rows.join(''), text: paras.join('\n\n') }
|
|
}
|
|
|
|
/**
|
|
* Wrap rendered rows in the mail document.
|
|
* @param {string} rowsHtml
|
|
* @param {object} ctx
|
|
* @param {string} [title] the <title>, shown by a few webmail clients
|
|
*/
|
|
function renderDocument(rowsHtml, ctx, title = '') {
|
|
const p = ctx.palette
|
|
return (
|
|
'<!doctype html><html><head><meta charset="utf-8" />' +
|
|
'<meta name="viewport" content="width=device-width,initial-scale=1" />' +
|
|
// Tells a client that inverts colours that this body already handles both,
|
|
// so it leaves the palette alone instead of inverting the card to near-black.
|
|
'<meta name="color-scheme" content="light" />' +
|
|
'<meta name="supported-color-schemes" content="light" />' +
|
|
`<title>${htmlEscape(title)}</title></head>` +
|
|
`<body style="margin:0;padding:0;background:${p.page};">` +
|
|
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${p.page};">` +
|
|
'<tr><td align="center" style="padding:24px 12px;">' +
|
|
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" ` +
|
|
`style="width:100%;max-width:600px;background:${p.card};border:1px solid ${p.rule};border-radius:6px;">` +
|
|
'<tr><td style="padding:28px 28px 16px 28px;">' +
|
|
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">' +
|
|
rowsHtml +
|
|
'</table></td></tr></table></td></tr></table></body></html>'
|
|
)
|
|
}
|
|
|
|
module.exports = {
|
|
FONT_STACK,
|
|
palette,
|
|
readableOn,
|
|
buildContext,
|
|
renderBlocks,
|
|
renderDocument,
|
|
validateEmailBlocks,
|
|
sanitizeEmailBlocks,
|
|
}
|