feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
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

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>
This commit is contained in:
2026-08-29 13:07:39 -05:00
parent 1d7961e7a2
commit 12ff201ed5
22 changed files with 2222 additions and 154 deletions

View File

@@ -0,0 +1,26 @@
// Email block registry entrypoint. Requiring this module registers every
// `email.*` block definition exactly once, then re-exports the registry API, the
// renderer and the registry-bound validator/sanitizer. Anything that needs to
// validate or render a mail template's blocks should require THIS module, not
// ./registry or ./render directly, so the definitions are guaranteed loaded.
//
// Same shape as `blocks/index.js`, on purpose — the two families are siblings
// (see ./registry.js for why they are not one registry).
const registry = require('./registry')
const render = require('./render')
const interpolate = require('./interpolate')
// ── Block definitions (self-register on require) ───────────────────────────
require('./types/heading')
require('./types/text')
require('./types/button')
require('./types/divider')
require('./types/image')
require('./types/itemList')
module.exports = {
...registry,
...render,
...interpolate,
}

View File

@@ -0,0 +1,79 @@
// ── Template variable interpolation ────────────────────────────────────────
//
// ENGAGEMENT.md §4.6.2's security posture, as code: "variable interpolation is
// HTML-escaped by default with no raw-HTML variable type in v1. A module supplies
// data; it does not supply markup."
//
// The token grammar is deliberately the smallest thing that works: `{{ name }}`,
// a bare declared variable name, and NOTHING else. No filters, no conditionals,
// no loops, no dotted paths. Three reasons:
//
// - A template is operator-authored data rendered by the server. Every construct
// added here is a construct an operator can get wrong and a construct someone
// has to sandbox.
// - §4.3 makes the trigger declaration the source of truth for what a template
// may reference, and a save-time check names the offending variable. That check
// can only be exact if a token is a name — `{{ user.profile.email }}` is not a
// declared variable, it is an expression over one.
// - Repetition is a BLOCK (`email.itemList`), not a template construct, so the
// one place a template needs "for each" already has a typed, validated home.
//
// A token whose variable has no value at render time becomes the empty string and
// is reported in `missing`. It does not become "undefined", which is the failure
// §4.3's versioning paragraph is about — a renamed variable rendering as the word
// undefined in a person's inbox.
// `{{ name }}` / `{{name}}`. Leading letter, then letters/digits/underscore —
// the same shape §4.3's declarations use.
const TOKEN_RE = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g
/** Escape text for interpolation into HTML. Same table as utils/htmlShell.js. */
function htmlEscape(s) {
return String(s).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]),
)
}
/**
* Every distinct variable name a string references, in first-appearance order.
* This is what the save-time check (Phase 5b) walks to find undeclared variables.
* @param {unknown} str
* @returns {string[]}
*/
function scanTokens(str) {
if (typeof str !== 'string') return []
const found = []
for (const m of str.matchAll(TOKEN_RE)) {
if (!found.includes(m[1])) found.push(m[1])
}
return found
}
/**
* Substitute declared variables into a string.
*
* @param {unknown} str
* @param {Record<string, unknown>} values
* @param {{ escape?: boolean, missing?: Set<string> }} [opts]
* `escape` (default true) HTML-escapes each value — pass false ONLY for the
* plain-text part, where there is no markup to escape into and `&amp;` in a
* person's inbox is a bug. `missing` collects names with no value.
* @returns {string}
*/
function interpolate(str, values, opts = {}) {
if (typeof str !== 'string' || str === '') return ''
const escape = opts.escape !== false
const missing = opts.missing || null
return str.replace(TOKEN_RE, (_match, name) => {
const value = values ? values[name] : undefined
if (value === undefined || value === null) {
if (missing) missing.add(name)
return ''
}
const asString = typeof value === 'string' ? value : String(value)
return escape ? htmlEscape(asString) : asString
})
}
module.exports = { TOKEN_RE, htmlEscape, scanTokens, interpolate }

View File

@@ -0,0 +1,120 @@
// ── The `email.*` block registry ───────────────────────────────────────────
//
// ENGAGEMENT.md §4.4. A sibling of `blocks/registry.js`, not an extension of it,
// settled with the org lead at the start of Phase 5a. Three reasons, in order of
// how much they cost if ignored:
//
// 1. **These blocks render on the SERVER.** Page blocks do not: `blocks/` carries
// `schema` / `sanitize` / `cacheTTL` and the actual drawing happens in React
// (`client/src/blocks/BlockRenderer.jsx`). Mail has no React — a message body
// is a string this process produces — so an email definition carries `toHtml`
// and `toText`. `registerBlock` freezes a fixed field set and would silently
// DROP both.
// 2. **One registry would be one namespace.** `blocks/validateBlocks.js`'s only
// server consumer is `pages.model.js`; registering `email.heading` into that
// Map makes a CMS page containing an email block validate and save, and the
// client renderer has nothing to draw for it.
// 3. The two entry shapes genuinely differ: `cacheTTL` and `container` mean
// nothing to a mail body, and a renderer means nothing to a cached page block.
//
// What IS shared is everything that is the same rule for both, and it is shared by
// binding rather than by copy: `propHelpers`, the envelope/id/nesting walk
// (`makeValidateBlocks`) and the validate-then-sanitize order (`makeSanitizeBlocks`).
// §4.4's "do not build a second editor" is honoured where it is about the editor —
// Phase 5b drives these through the existing block/prop-panel machinery.
//
// A registered definition looks like:
// {
// type: 'email.heading',
// version: 1,
// schema: (props) => [], // error strings ([] = valid)
// sanitize: (props) => props, // optional, run on save AFTER validation
// toHtml: (props, ctx) => '<tr>…', // a table ROW; see render.js for the shell
// toText: (props, ctx) => 'text', // '' means "contributes nothing"
// }
//
// `ctx` is the render context (render.js): resolved brand values, an `interp`
// that substitutes declared variables HTML-escaped, and `interpText` that does
// the same without escaping for the plain-text part.
const registry = new Map()
// Same envelope as a page block — deliberately the same constant list, because
// the shared validator enforces it and the two must not diverge.
const { RESERVED_KEYS } = require('../blocks/registry')
/**
* Register an email block definition. Throws on a missing type, a duplicate, or a
* missing renderer — all three are programmer errors surfaced at boot.
* @param {object} def
* @returns {object} the normalized, frozen definition
*/
function registerEmailBlock(def) {
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
throw new Error('registerEmailBlock: a block definition needs a string `type`')
}
if (!def.type.startsWith('email.')) {
// The prefix is not needed to disambiguate — this is its own Map — but a
// stored blocks array should say what it is when someone reads the row.
throw new Error(`registerEmailBlock: ${def.type} must be namespaced "email."`)
}
if (registry.has(def.type)) {
throw new Error(`registerEmailBlock: block type already registered: ${def.type}`)
}
if (typeof def.toHtml !== 'function' || typeof def.toText !== 'function') {
// §4.4: "Every block type gets a toText(props) alongside its renderer, so a
// text part always exists." A block that can only produce HTML would make a
// published template's text part depend on which blocks it happened to use.
throw new Error(`registerEmailBlock: ${def.type} needs both toHtml and toText`)
}
if (def.schema != null && typeof def.schema !== 'function') {
throw new Error(`registerEmailBlock: ${def.type}.schema must be a function`)
}
if (def.sanitize != null && typeof def.sanitize !== 'function') {
throw new Error(`registerEmailBlock: ${def.type}.sanitize must be a function`)
}
const entry = Object.freeze({
type: def.type,
label: def.label || def.type,
version: Number.isInteger(def.version) ? def.version : 1,
schema: def.schema || null,
sanitize: def.sanitize || null,
toHtml: def.toHtml,
toText: def.toText,
// The shared walk reads these; email has no containers, and saying so here is
// what lets `makeValidateBlocks` be the same function for both families.
container: false,
containerSlots: Object.freeze([]),
})
registry.set(entry.type, entry)
return entry
}
/** @returns {object|null} the definition for `type`, or null if unknown. */
function getEmailBlock(type) {
return registry.get(type) || null
}
/** @returns {boolean} whether `type` is a registered email block. */
function hasEmailBlock(type) {
return registry.has(type)
}
/** @returns {object[]} all registered definitions (registration order). */
function listEmailBlocks() {
return [...registry.values()]
}
/** Drop every registered block. Test-only. */
function _resetRegistry() {
registry.clear()
}
module.exports = {
RESERVED_KEYS,
registerEmailBlock,
getEmailBlock,
hasEmailBlock,
listEmailBlocks,
_resetRegistry,
}

View File

@@ -0,0 +1,196 @@
// ── 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,
}

View File

@@ -0,0 +1,86 @@
// email.button — the call to action, and the one block whose two renderings are
// deliberately NOT the same content.
//
// **`textLead` is why the plain-text part is authored rather than derived.** In
// HTML this is a button reading "Choose a new password"; in plain text a button
// is nothing, and what a reader needs is the sentence that introduces the URL
// ("Choose a new password here:") followed by the URL on its own line. Deriving
// the second from the first produces either a bare URL with no lead-in or the
// button's label used as a sentence. §4.4 calls the text part generated-by-default
// and overridable; this block is the reason the default has to be good enough that
// an operator rarely reaches for the override.
//
// **The href is re-checked AFTER interpolation.** `url` is nearly always a token
// (`{{resetUrl}}`), so nothing about the stored value tells you where it points —
// the value arrives at render time from a module or a caller. A substituted URL
// that is not http/https/same-origin loses its href and renders as inert text
// rather than as a link the reader would have no reason to distrust.
const { registerEmailBlock } = require('../registry')
const { requiredText, optionalText, onlyKeys, isSafeUrl } = require('../../blocks/propHelpers')
const { scanTokens } = require('../interpolate')
const MAX_LABEL = 80
const MAX_URL = 600
const MAX_LEAD = 200
registerEmailBlock({
type: 'email.button',
label: 'Button / link',
version: 1,
schema(props) {
const errors = onlyKeys(props, ['label', 'url', 'textLead'])
const label = requiredText('label', props.label, MAX_LABEL)
if (label) errors.push(label)
const lead = optionalText('textLead', props.textLead, MAX_LEAD)
if (lead) errors.push(lead)
const url = requiredText('url', props.url, MAX_URL)
if (url) {
errors.push(url)
} else if (scanTokens(props.url).length === 0 && !isSafeUrl(props.url)) {
// A literal url is checked here, at save. One built from variables cannot
// be — see the header note; render.js checks the substituted value instead.
errors.push('url must be a relative path, an http(s) URL, or a template variable')
}
return errors
},
toHtml(props, ctx) {
// An EMPTY url and an UNSAFE one are different failures and get different
// answers. Empty means the caller chose not to supply this link at all (an
// unsubscribe line on a transactional mail), so the block disappears from both
// parts. Unsafe means a value arrived that must not become an href — the label
// still renders, inert, because dropping it silently would hide from the
// reader that the mail was meant to offer them something.
if (ctx.t(props.url).trim() === '') return ''
const href = ctx.safeHref(props.url)
const label = ctx.h(props.label)
if (!href) {
return (
`<tr><td style="padding:4px 0 16px 0;font-family:${ctx.palette.fontStack};` +
`font-size:15px;color:${ctx.palette.muted};">${label}</td></tr>`
)
}
// Table-wrapped, inline-styled, with explicit padding on the anchor: the shape
// that survives Outlook, which ignores padding on a <td> containing an <a>.
return (
'<tr><td style="padding:4px 0 20px 0;">' +
'<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>' +
`<td bgcolor="${ctx.palette.accent}" style="border-radius:4px;">` +
`<a href="${ctx.escape(href)}" style="display:inline-block;padding:11px 22px;` +
`font-family:${ctx.palette.fontStack};font-size:15px;font-weight:600;` +
`color:${ctx.palette.onAccent};text-decoration:none;border-radius:4px;">${label}</a>` +
'</td></tr></table>' +
// The bare URL under the button, for the clients that strip anchors and for
// the reader who wants to see where it goes before pressing it.
`<div style="padding-top:10px;font-family:${ctx.palette.fontStack};font-size:12px;` +
`line-height:1.5;color:${ctx.palette.muted};word-break:break-all;">${ctx.escape(href)}</div>` +
'</td></tr>'
)
},
toText(props, ctx) {
const url = ctx.t(props.url).trim()
if (url === '') return '' // see toHtml: no url, no block, in either part
const lead = props.textLead ? ctx.t(props.textLead).trim() : ''
return lead ? `${lead}\n${url}` : url
},
})

View File

@@ -0,0 +1,29 @@
// email.divider — a horizontal rule.
//
// **Its text form is the empty string, not a row of dashes.** A block whose only
// job is visual separation has no plain-text equivalent, and render.js already
// joins blocks with a blank line. Rendering `-----` would put a decoration in the
// text part that the author never wrote and cannot remove without deleting the
// rule from the HTML too. Returning '' is what the "'' means contributes nothing"
// contract in registry.js exists for.
const { registerEmailBlock } = require('../registry')
const { onlyKeys } = require('../../blocks/propHelpers')
registerEmailBlock({
type: 'email.divider',
label: 'Divider',
version: 1,
schema(props) {
return onlyKeys(props, [])
},
toHtml(_props, ctx) {
return (
'<tr><td style="padding:8px 0 20px 0;">' +
`<div style="height:1px;line-height:1px;font-size:0;background:${ctx.palette.rule};">&nbsp;</div>` +
'</td></tr>'
)
},
toText() {
return ''
},
})

View File

@@ -0,0 +1,41 @@
// email.heading — a section heading inside a mail body.
//
// `level` is a SIZE, not a tag hierarchy: mail clients do not build an outline
// from an email and several strip heading tags outright, so this renders a styled
// <div> at one of three sizes rather than h1/h2/h3. Keeping the prop named `level`
// means the prop panel Phase 5b reuses reads the same as the page block's.
const { registerEmailBlock } = require('../registry')
const { oneOf, requiredText, onlyKeys } = require('../../blocks/propHelpers')
const LEVELS = ['h1', 'h2', 'h3']
const MAX_TEXT = 200
const SIZES = { h1: '24px', h2: '19px', h3: '16px' }
registerEmailBlock({
type: 'email.heading',
label: 'Heading',
version: 1,
schema(props) {
const errors = onlyKeys(props, ['level', 'text'])
const level = oneOf('level', LEVELS)(props.level)
if (level) errors.push(level)
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
return errors
},
toHtml(props, ctx) {
// Same "nothing in, nothing out" rule as email.text: a heading that is one
// optional variable disappears rather than leaving its margin behind.
if (ctx.t(props.text).trim() === '') return ''
const size = SIZES[props.level] || SIZES.h2
return (
`<tr><td style="padding:0 0 12px 0;font-family:${ctx.palette.fontStack};` +
`font-size:${size};line-height:1.3;font-weight:700;color:${ctx.palette.heading};">` +
`${ctx.h(props.text)}</td></tr>`
)
},
toText(props, ctx) {
return ctx.t(props.text).trim()
},
})

View File

@@ -0,0 +1,60 @@
// email.image — an inline image.
//
// Two things differ from the page block of the same name, both because the reader
// is in a mail client rather than on the site:
//
// - **The src is absolutized.** `brand_assets` stores `/uploads/…` and every page
// renderer is same-origin, so a relative src has always been correct there. In
// an inbox there is no origin to be relative to; render.js's `absolute()` turns
// it into a URL against APP_BASE_URL / BRAND_URL, and an image that cannot be
// absolutized is DROPPED rather than emitted broken.
// - **`alt` is required.** Most mail clients block remote images by default, so
// for a large share of readers the alt text IS the image. On a web page it is
// an accessibility nicety; here it is the common case.
const { registerEmailBlock } = require('../registry')
const { requiredText, onlyKeys, isSafeUrl } = require('../../blocks/propHelpers')
const { scanTokens } = require('../interpolate')
const MAX_URL = 600
const MAX_ALT = 200
const MAX_WIDTH = 560
registerEmailBlock({
type: 'email.image',
label: 'Image',
version: 1,
schema(props) {
const errors = onlyKeys(props, ['url', 'alt', 'width'])
const alt = requiredText('alt', props.alt, MAX_ALT)
if (alt) errors.push(alt)
const url = requiredText('url', props.url, MAX_URL)
if (url) {
errors.push(url)
} else if (scanTokens(props.url).length === 0 && !isSafeUrl(props.url)) {
errors.push('url must be a relative path, an http(s) URL, or a template variable')
}
if (props.width !== undefined) {
if (!Number.isInteger(props.width) || props.width < 16 || props.width > MAX_WIDTH) {
errors.push(`width must be a whole number between 16 and ${MAX_WIDTH}`)
}
}
return errors
},
toHtml(props, ctx) {
const src = ctx.absolute(ctx.safeHref(props.url))
if (!src) return '' // unresolvable: no broken image in someone's inbox
const width = props.width ? ` width="${props.width}"` : ''
const style = props.width
? `max-width:100%;width:${props.width}px;height:auto;display:block;border:0;`
: 'max-width:100%;height:auto;display:block;border:0;'
return (
`<tr><td style="padding:0 0 16px 0;">` +
`<img src="${ctx.escape(src)}" alt="${ctx.h(props.alt)}"${width} style="${style}" /></td></tr>`
)
},
toText(props, ctx) {
// The alt text alone, with no [image] decoration: it was written to stand in
// for the picture, and in the text part standing in for it is all it does.
return ctx.t(props.alt)
},
})

View File

@@ -0,0 +1,105 @@
// email.itemList — the one repeating block, and the reason the token grammar in
// interpolate.js needs no loop construct.
//
// It renders an ARRAY variable rather than an inline list: the prop is the NAME of
// a declared variable (`items`), and the value arrives at render time. §4.6.1's
// two generic templates — `notify.event` and `notify.digest` — are generic because
// of this block: their variables are structural (`title`, `intro`, `items[]`), so
// a trigger from any module renders through them with no authoring at all.
//
// **The item shape is `{ heading, excerpt?, url? }`, matching what
// `teamNotify`/`teamDigestWorker` already build**, so Phase 6's migration onto the
// engine is a rewiring rather than a reshaping of every producer.
//
// A non-array value, or an empty one, renders `emptyText` if there is one and
// nothing at all otherwise. That is the same fail-soft posture `settingsJson`
// takes: a stored value that is unusable is treated as absent, never as an error —
// a digest whose item query returned nothing must still be a sendable mail.
const { registerEmailBlock } = require('../registry')
const { requiredText, optionalText, onlyKeys } = require('../../blocks/propHelpers')
const MAX_NAME = 64
const MAX_EMPTY = 200
const MAX_ITEMS = 100
const NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/
/** Coerce whatever the caller passed into a bounded array of item objects. */
function itemsOf(value) {
if (!Array.isArray(value)) return []
return value
.slice(0, MAX_ITEMS)
.map((item) => {
if (typeof item === 'string') return { heading: item }
if (!item || typeof item !== 'object') return null
return {
heading: item.heading == null ? '' : String(item.heading),
excerpt: item.excerpt == null ? '' : String(item.excerpt),
url: item.url == null ? '' : String(item.url),
}
})
.filter((item) => item && item.heading !== '')
}
registerEmailBlock({
type: 'email.itemList',
label: 'Item list',
version: 1,
schema(props) {
const errors = onlyKeys(props, ['variable', 'emptyText'])
const variable = requiredText('variable', props.variable, MAX_NAME)
if (variable) {
errors.push(variable)
} else if (!NAME_RE.test(props.variable)) {
errors.push('variable must be the name of a declared list variable')
}
const empty = optionalText('emptyText', props.emptyText, MAX_EMPTY)
if (empty) errors.push(empty)
return errors
},
toHtml(props, ctx) {
const items = itemsOf(ctx.values[props.variable])
if (items.length === 0) {
if (!props.emptyText) return ''
return (
`<tr><td style="padding:0 0 16px 0;font-family:${ctx.palette.fontStack};font-size:14px;` +
`line-height:1.55;color:${ctx.palette.muted};">${ctx.h(props.emptyText)}</td></tr>`
)
}
const rows = items
.map((item) => {
const href = ctx.absolute(ctx.safeHref(item.url))
const heading = ctx.escape(item.heading)
const title = href
? `<a href="${ctx.escape(href)}" style="color:${ctx.palette.accent};text-decoration:none;font-weight:600;">${heading}</a>`
: `<span style="font-weight:600;color:${ctx.palette.heading};">${heading}</span>`
const excerpt = item.excerpt
? `<div style="padding-top:4px;font-size:14px;color:${ctx.palette.muted};">${ctx.escape(item.excerpt)}</div>`
: ''
return (
`<tr><td style="padding:0 0 14px 0;border-left:3px solid ${ctx.palette.rule};padding-left:12px;` +
`font-family:${ctx.palette.fontStack};font-size:15px;line-height:1.5;color:${ctx.palette.text};">` +
`${title}${excerpt}</td></tr>`
)
})
.join('')
return (
'<tr><td style="padding:0 0 8px 0;">' +
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table>` +
'</td></tr>'
)
},
toText(props, ctx) {
const items = itemsOf(ctx.values[props.variable])
if (items.length === 0) return props.emptyText ? ctx.t(props.emptyText) : ''
// Heading flush left, excerpt and url indented two spaces, one blank line
// between items — the shape `mailer.sendTeamNotification` builds today.
return items
.map((item) => {
const lines = [item.heading]
if (item.excerpt) lines.push(` ${item.excerpt}`)
if (item.url) lines.push(` ${ctx.absolute(item.url) || item.url}`)
return lines.join('\n')
})
.join('\n\n')
},
})

View File

@@ -0,0 +1,69 @@
// email.text — a run of plain-text paragraphs.
//
// **There is no rich-text email block, and that is the §4.6.2 posture rather than
// an omission.** The page family has `rich_text` because a page author is trusted
// staff writing into a surface the site's own CSS controls. A mail body is
// different in both halves: the markup an operator writes here is re-rendered by
// thirty mail clients with thirty different subsets of HTML, and the VALUES
// interpolated into it come from modules and from game data. §4.6.2 settles the
// second half — "a module supplies data; it does not supply markup" — and the
// first is why even the operator's own markup earns nothing here: a <div> an
// author typed is a layout bug in Outlook, while `email.heading` / `email.button`
// are shapes this renderer knows how to make survive.
//
// So: blank line separates paragraphs, single newline is a line break, and every
// character is escaped on the way into HTML.
const { registerEmailBlock } = require('../registry')
const { requiredText, onlyKeys } = require('../../blocks/propHelpers')
const MAX_TEXT = 4000
/** Split on blank lines; each paragraph keeps its internal single newlines. */
function paragraphs(s) {
return String(s)
.split(/\n[ \t]*\n/)
.map((p) => p.replace(/^\n+|\n+$/g, ''))
.filter((p) => p !== '')
}
registerEmailBlock({
type: 'email.text',
label: 'Paragraph',
version: 1,
schema(props) {
const errors = onlyKeys(props, ['text', 'muted'])
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
if (props.muted !== undefined && typeof props.muted !== 'boolean') {
errors.push('muted must be a boolean')
}
return errors
},
toHtml(props, ctx) {
const color = props.muted ? ctx.palette.muted : ctx.palette.text
const size = props.muted ? '13px' : '15px'
// Interpolate FIRST, then split: a variable carrying a blank line becomes two
// paragraphs, which is what the contact-message mail needs (a player's typed
// message arrives as one variable and reads as they wrote it).
const body = ctx.h(props.text)
const parts = paragraphs(body)
// A block whose whole content is one optional variable renders NOTHING when
// that variable is absent, rather than an empty paragraph with its margin.
// This is what stands in for a conditional: `{{moreNote}}` on its own line is
// a line the caller can choose not to supply, and the template stays
// logic-free (interpolate.js).
if (parts.length === 0) return ''
const html = parts
.map((p) => `<p style="margin:0 0 12px 0;">${p.replace(/\n/g, '<br />')}</p>`)
.join('')
return (
`<tr><td style="padding:0;font-family:${ctx.palette.fontStack};font-size:${size};` +
`line-height:1.55;color:${color};">${html}</td></tr>`
)
},
toText(props, ctx) {
// Trimmed to match toHtml's "nothing in, nothing out": the two parts must
// agree about whether this block contributed anything at all.
return ctx.t(props.text).replace(/^\s+|\s+$/g, '')
},
})