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>
This commit is contained in:
79
server/src/emailBlocks/interpolate.js
Normal file
79
server/src/emailBlocks/interpolate.js
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 }
|
||||
Reference in New Issue
Block a user