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:
120
server/src/emailBlocks/registry.js
Normal file
120
server/src/emailBlocks/registry.js
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user