Phase 5a gave templates a table, a renderer and nine seeded rows; nothing could
change one. This is the screen that lets an operator change one without being able
to break the mail the system depends on — plus the two screens Q4 promised Phase 5:
Triggers (read-only, from the registries) and the Send Log, which closes G15.
The shape follows from one fact: a mail body is rendered by the SERVER, so the
preview is too, and framed rather than redrawn in React. A client-side renderer
would be a second implementation of the one artifact that matters, agreeing with
the send path on the day it was written and drifting from the first Outlook fix on.
Settled with the org lead before any code: a shipped default is edited IN PLACE
(`protected` blocks deletion and nothing else, `customized = 1` keeps the edit);
duplicate is the only way to a new template; `renderByKey` now requires
`published`; a test send is logged under a synthetic `core.admin.test-send`; and a
template a rule points at refuses deletion with a 409 naming the rules.
Three things the plan did not know, found by building it:
- The undeclared-variable check cannot be a token scan. `email.itemList.variable`
holds a BARE name, so a digest pointed at `itmes` would have saved clean and
arrived empty. Blocks now declare `variables(props)`; the editor makes that
field a select over the trigger's list variables so the typo is unavailable.
- A duplicate that drops `seed_key` loses its variable palette, so duplicating
`notify.event` would have been refused for the tokens it was copied with — the
one action §4.6.2 offers, refusing itself. The copy inherits it; `customized`
is what the seeder actually reads.
- `validateEmailBlocks` returns `{ valid, errors }`, not an array, and the first
version tested it with `.length` — so block validation never ran at all.
Also fixes a Phase 4a defect the live walk found, with the org lead's approval: a
rule's template key was checked against a pattern with no dot in it, so no rule
could name any template that exists — §4.6.2's whole duplicate-and-point-a-rule-at-it
workflow was unreachable. Both models now read one pattern.
Verified against the running stack: real multipart mail into a mailpit catcher
including an unsaved draft, the draft/published arms both ways through the real
mailer path, every refusal, and the end-to-end duplicate → rule → 409 walk.
Server 1428 tests green, client 324.
Co-Authored-By: Claude <noreply@anthropic.com>
139 lines
6.4 KiB
JavaScript
139 lines
6.4 KiB
JavaScript
// ── 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"
|
|
// variables: (props) => [], // optional; see below
|
|
// }
|
|
//
|
|
// `variables` exists because of ONE block, and the exception is the reason it has
|
|
// to be declared rather than inferred. Every other block references a declared
|
|
// variable the same way a person writes it — as a `{{token}}` inside an authored
|
|
// string — so scanning the string props finds them all. `email.itemList` does not:
|
|
// its `variable` prop holds a BARE NAME (`items`), because the block iterates the
|
|
// value rather than interpolating it. A save-time check that only scanned tokens
|
|
// would pass a template pointing its one repeating block at a variable no trigger
|
|
// declares, and the failure would surface as an empty digest in someone's inbox.
|
|
// A block that reads a variable by any means other than a token says so here.
|
|
//
|
|
// `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`)
|
|
}
|
|
if (def.variables != null && typeof def.variables !== 'function') {
|
|
throw new Error(`registerEmailBlock: ${def.type}.variables 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,
|
|
// Null, not a default `() => []`: `variables.js` distinguishes "this block
|
|
// declares no non-token references" from "this block was never asked", and
|
|
// only the second is worth a comment when a new block type is added.
|
|
variables: def.variables || null,
|
|
// 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,
|
|
}
|