feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b) #173

Merged
whitlocktech merged 1 commits from feature/engagement-template-editor into edge 2026-08-29 23:37:42 +00:00
Member

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 §7.1 Q4 promised Phase 5.

Design of record: docs/website/ENGAGEMENT.md §4.6.2, with an As built — 5b section landing in RunicGateway/docs#TBD. Operator guide in RunicGateway/runicgateway.com#TBD.

Scope: three screens, not one

The Phase 5 body names only the editor. Q4's answer and §6.2 both promise "Triggers, Templates and the send log" in Phase 5, so all three shipped. Triggers is a read-only render of two endpoints Phase 2 already serves and cost no server work; the send log is one paged route over a table that has been filling since Phase 4a and whose index was built for it. Leaving either out would have left the nav group half-built and G15 — "no send log, no delivery status, no audit" — open with the rows already on disk.

The decisions, settled with the org lead before any code

What the tree said Decision
Where the preview comes from The client block registry mirrors the server's, but its entry shape carries a React component. Email blocks render on the server A server preview route, framed in a sandboxed iframe. A React preview would be a second renderer for one artifact, drifting from the first Outlook fix onward
status was unenforced draft/published shipped in 5a and nothing read it — an operator who saved a draft kept mailing it renderByKey requires published, else the shipped seed. A draft now means what the word means
Test send vs trigger_id NOT NULL §4.6.2 wants it logged "like any other message", but a transactional template has no trigger A synthetic core.admin.test-send. No schema change, no nullable column
Deleting protected blocked deletion; nothing stopped deleting a template a rule uses 409 naming the rules — Phase 4b's answer for a segment in use
Creating §4.6.2 names duplicate and never mentions a blank create Duplicate only, so every template descends from one that renders

And the correction that changed the most code. §4.6.2 introduces duplicate as how a protected template is customized. The schema comment says the opposite — "Editable, NOT deletable" — and the org lead's ruling is the schema's: a default is edited in place. protected now blocks deletion and nothing else.

Three things the plan did not know

  1. 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 — the one variable mistake a reader of the template cannot see. Blocks now optionally declare variables(props); the editor makes that field a <select> over the trigger's list variables, so the typo is unavailable.
  2. A duplicate that drops seed_key loses its variable palette, so duplicating notify.event would have been refused for the {{title}}/{{intro}} it was copied with — the one action §4.6.2 offers, refusing itself. The copy inherits it; customized = 1 is what the seeder actually reads.
  3. validateEmailBlocks returns { valid, errors }, not an array — and the first version of the model tested it with .length, so block validation never ran at all. Caught by the test asserting a preview refuses an unknown prop.

One out-of-scope fix, approved

The live walk found that Phase 4a validated a rule's template key against /^[a-z0-9][a-z0-9-]{0,63}$/no dot — while every template key has one. No rule could name any template that exists, so §4.6.2's whole duplicate-and-point-a-rule-at-it workflow was unreachable. The org lead approved fixing it here; both models now read one pattern from engagement/templates.

The CSP surface

§4.6.2 called the preview "the one genuinely new CSP surface". Three things hold it: <iframe sandbox="" srcDoc> (no allow-scripts, no allow-same-origin, opaque origin); the HTML crosses as a JSON string, never as a document from this origin; and blocks are validated-then-sanitized on write. client/test/emailTemplates.test.js is the sibling test it asked for — it reads the attribute, not the file, because the first version failed on the comment explaining there is no allow-scripts.

Verified

  • Server 1428 tests green, client 324. 24 new model tests, one per acceptance criterion plus the two the tree made necessary.
  • The registry drift check was verified by breaking it both ways — a client version: 2 against the server's 1, and deleting a registration.
  • Against the running stack: real multipart mail into a mailpit catcher including an unsaved draft (proving "sends what is on screen"); the draft and published arms both ways through the real mailer path; every refusal; address_hash never leaving the server; and the end-to-end duplicate → point a rule → 409 on delete walk that the Phase 4a fix unblocked.
  • Browser pass over all three screens found the namespace slip (api. vs api.admin.) and a pill that wrapped mid-phrase; both fixed.

  • AI-assisted: written with Claude Code (Claude Opus 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL

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 §7.1 Q4 promised Phase 5. Design of record: `docs/website/ENGAGEMENT.md` §4.6.2, with an **As built — 5b** section landing in RunicGateway/docs#TBD. Operator guide in RunicGateway/runicgateway.com#TBD. ## Scope: three screens, not one The Phase 5 body names only the editor. **Q4's answer and §6.2 both promise "Triggers, Templates and the send log" in Phase 5**, so all three shipped. Triggers is a read-only render of two endpoints Phase 2 already serves and cost no server work; the send log is one paged route over a table that has been filling since Phase 4a and whose index was built for it. Leaving either out would have left the nav group half-built and **G15 — "no send log, no delivery status, no audit" — open with the rows already on disk.** ## The decisions, settled with the org lead before any code | | What the tree said | Decision | | --- | --- | --- | | **Where the preview comes from** | The client block registry mirrors the server's, but its entry shape carries a React `component`. Email blocks render on the **server** | A **server preview route**, framed in a sandboxed iframe. A React preview would be a second renderer for one artifact, drifting from the first Outlook fix onward | | **`status` was unenforced** | `draft`/`published` shipped in 5a and **nothing read it** — an operator who saved a draft kept mailing it | `renderByKey` requires `published`, else the shipped seed. A draft now means what the word means | | **Test send vs `trigger_id NOT NULL`** | §4.6.2 wants it logged "like any other message", but a transactional template has no trigger | A synthetic **`core.admin.test-send`**. No schema change, no nullable column | | **Deleting** | `protected` blocked deletion; nothing stopped deleting a template a rule uses | **409 naming the rules** — Phase 4b's answer for a segment in use | | **Creating** | §4.6.2 names duplicate and never mentions a blank create | **Duplicate only**, so every template descends from one that renders | **And the correction that changed the most code.** §4.6.2 introduces duplicate as how a `protected` template is customized. The schema comment says the opposite — *"Editable, NOT deletable"* — and **the org lead's ruling is the schema's: a default is edited in place.** `protected` now blocks deletion and nothing else. ## Three things the plan did not know 1. **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 — the one variable mistake a reader of the template cannot see. Blocks now optionally declare `variables(props)`; the editor makes that field a `<select>` over the trigger's list variables, so the typo is unavailable. 2. **A duplicate that drops `seed_key` loses its variable palette**, so duplicating `notify.event` would have been refused for the `{{title}}`/`{{intro}}` it was copied with — the one action §4.6.2 offers, refusing itself. The copy inherits it; `customized = 1` is what the seeder actually reads. 3. **`validateEmailBlocks` returns `{ valid, errors }`, not an array** — and the first version of the model tested it with `.length`, so **block validation never ran at all**. Caught by the test asserting a preview refuses an unknown prop. ## One out-of-scope fix, approved The live walk found that Phase 4a validated a rule's template key against `/^[a-z0-9][a-z0-9-]{0,63}$/` — **no dot** — while every template key has one. **No rule could name any template that exists**, so §4.6.2's whole duplicate-and-point-a-rule-at-it workflow was unreachable. The org lead approved fixing it here; both models now read one pattern from `engagement/templates`. ## The CSP surface §4.6.2 called the preview "the one genuinely new CSP surface". Three things hold it: `<iframe sandbox="" srcDoc>` (no `allow-scripts`, no `allow-same-origin`, opaque origin); the HTML crosses as a **JSON string**, never as a document from this origin; and blocks are validated-then-sanitized **on write**. `client/test/emailTemplates.test.js` is the sibling test it asked for — it reads the **attribute**, not the file, because the first version failed on the comment explaining there is no `allow-scripts`. ## Verified - **Server 1428 tests green, client 324.** 24 new model tests, one per acceptance criterion plus the two the tree made necessary. - **The registry drift check was verified by breaking it both ways** — a client `version: 2` against the server's `1`, and deleting a registration. - **Against the running stack:** real multipart mail into a mailpit catcher including an **unsaved draft** (proving "sends what is on screen"); the draft and published arms both ways through the real `mailer` path; every refusal; `address_hash` never leaving the server; and the end-to-end **duplicate → point a rule → 409 on delete** walk that the Phase 4a fix unblocked. - Browser pass over all three screens found the namespace slip (`api.` vs `api.admin.`) and a pill that wrapped mid-phrase; both fixed. --- - [x] AI-assisted: written with Claude Code (Claude Opus 5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
wtclaude added 1 commit 2026-08-29 23:15:15 +00:00
feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
3f90070566
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>
whitlocktech merged commit e2dad3104f into edge 2026-08-29 23:37:41 +00:00
whitlocktech deleted branch feature/engagement-template-editor 2026-08-29 23:37:42 +00:00
Sign in to join this conversation.
No description provided.