feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)
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>
This commit is contained in:
@@ -31,8 +31,19 @@
|
||||
// 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.
|
||||
@@ -73,6 +84,9 @@ function registerEmailBlock(def) {
|
||||
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,
|
||||
@@ -81,6 +95,10 @@ function registerEmailBlock(def) {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user