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,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,
}