Files
website/server/src/emailBlocks/interpolate.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

80 lines
3.3 KiB
JavaScript

// ── 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 }