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>
80 lines
3.3 KiB
JavaScript
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 `&` 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 }
|