docs(website): the seeded template set as built (engagement Phase 5a)

Companion to website#TBD. §6.0b assigns this phase two documents; both land here.

ENGAGEMENT.md gains an "As built — 5a" section: the three decisions the survey
forced, the argument for two registries rather than one, why the ternaries stayed
at the call site, and the two behaviour changes an operator will notice.

BACKEND_DESIGN.md gains the `engagement_templates` table, the two-block-registry
split, the token grammar, and the §7 note that mail is now multipart.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:07:50 -05:00
parent 315a7c7fca
commit aa5c1d63b0
2 changed files with 231 additions and 2 deletions

View File

@@ -619,6 +619,78 @@ a rule's budget and mute it.
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
counts only.
### engagement_templates — the message bodies (engagement phase 5a)
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| `key` | VARCHAR(96) NOT NULL **UNIQUE** | the stable id a rule's `template_keys` map and `mailer` name |
| name | VARCHAR(160) NOT NULL | what the admin list shows |
| trigger_id / trigger_version | VARCHAR(96) NULL / INT NULL | **no foreign key**, for the reason `engagement_rules.trigger_id` has none: a trigger is declared in code. NULL = a reusable template not tied to one trigger, which is what every transactional seed is |
| channel | VARCHAR(32) NOT NULL | one template per channel; a rule names a set |
| subject | VARCHAR(300) NULL | email only, and it interpolates. NULL is how a non-email template says it has none |
| blocks | MEDIUMTEXT NOT NULL | a JSON block array, validated + sanitized on write against the `email.*` registry — never raw operator HTML |
| text_body | MEDIUMTEXT NULL | an authored plain-text part that **replaces** the generated one; NULL = generated from each block's `toText` |
| status | ENUM('draft','published') DEFAULT 'draft' | |
| protected | TINYINT(1) DEFAULT 0 | editable, not deletable — the `pages.protected` flag, for the same reason: the system breaks without a password-reset body |
| seed_key / seed_version / customized | VARCHAR(96) NULL / INT NULL / TINYINT(1) DEFAULT 0 | the "ship a better default without stealing an operator's work" mechanism — see below |
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
`INDEX(trigger_id, channel, status)`, `INDEX(seed_key)`.
**The three seed columns are one mechanism, and the guard lives in SQL.** On boot the seeder runs an
`INSERT IGNORE` per shipped template and, when the row already exists, a single
`UPDATE … WHERE seed_key = ? AND customized = 0 AND seed_version < ?`. A read-then-write would leave a
window in which a concurrent boot overwrites an edit an operator made a moment earlier; putting
`customized = 0` in the UPDATE's own WHERE closes it. (MariaDB's `ON DUPLICATE KEY UPDATE` cannot carry
a WHERE, which is why this is two statements rather than the upsert ENGAGEMENT.md §4.6.1 sketches.) A
customized row whose shipped default has moved on is **surfaced**, never applied.
**A missing or unusable row renders the shipped default rather than nothing.** `renderByKey` falls back
to the in-code seed whenever the row is absent or its `blocks` will not parse — before the first seed
runs, after a restore that dropped the table, or on a row hand-edited in the database. That fallback is
what makes it safe for a password-reset mail to depend on this table at all.
### The two block registries — pages and mail (engagement phase 5a)
`server/src/blocks/` (the CMS page family) and `server/src/emailBlocks/` (`email.heading`, `email.text`,
`email.button`, `email.divider`, `email.image`, `email.itemList`) are **siblings, not one registry**.
Three reasons, in order of what they cost if ignored:
1. **Email blocks render on the server.** A page block carries `schema` / `sanitize` / `cacheTTL` and is
drawn by React in `client/src/blocks/`; a mail body is a string this process produces, so an email
definition carries `toHtml` and `toText`. `registerBlock` freezes a fixed field set and would drop
both silently.
2. **One registry would be one namespace.** The page registry's only server consumer is
`pages.model.js`; putting `email.heading` in that Map makes a CMS page containing an email block
validate and save, with nothing on the client able to draw it.
3. The entry shapes differ — `cacheTTL` and `container` mean nothing to a mail body, a renderer nothing
to a cached page block.
What *is* shared is shared by binding rather than by copy: `propHelpers`, the envelope/id/nesting walk
(`makeValidateBlocks`) and the validate-then-sanitize order (`makeSanitizeBlocks`) are factories the two
registries each bind. ENGAGEMENT.md §4.4's "do not build a second editor" is honoured where it is about
the editor — Phase 5b drives the `email.*` family through the existing block/prop-panel machinery.
### Template variables — the token grammar (engagement phase 5a)
`{{ name }}`, a bare declared variable name, and nothing else: no filters, no conditionals, no loops, no
dotted paths. Repetition is a block (`email.itemList` renders a declared *list* variable), which is why
the grammar needs no loop. Three consequences worth knowing before authoring one:
- **Interpolation is HTML-escaped in the HTML part and raw in the text part.** There is no raw-HTML
variable type (§4.6.2) — a module supplies data, not markup.
- **A URL built from a variable is re-checked after substitution.** A stored `{{resetUrl}}` says nothing
about where it points; a substituted value that is not http(s)/same-origin loses its href and renders
as inert text rather than as a link a reader has no reason to distrust.
- **Presentational conditionals live at the call site**, not in the template. `mailer` computes
` for the account “Darrow”` with a ternary and passes the *result* as a variable, whose declared
`example` shows exactly what it produces.
Four **ambient** variables — `siteName`, `siteUrl`, `logoUrl`, `year` — are available to every template
and are merged **over** whatever a caller passes. A caller supplies the message; the deployment supplies
its identity, and letting a caller override it would mean mail that claims to be from somewhere else.
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
@@ -1400,6 +1472,19 @@ must land an admin on a screen that says "unconfigured", not a 500 that takes th
it. `provider` and `refresh_token_enc` remain as **deprecated, unread columns** under the
additive-only discipline.
**What each sender still owns is its recipient, its headers and its failure contract — not what it
says.** Engagement Phase 5a moved every subject and body out of `mailer.js` into `engagement_templates`
rows (§4.6.1); the file's five senders call one seam, `engagement/templates.renderByKey`, which falls
back to the shipped seed when the row is missing or unusable. Two consequences:
- **Mail is now `multipart/alternative`.** Nothing here had an HTML part before. The **text part is
byte-identical** to what the deleted literals built — pinned by `test/emailTemplates.test.js`, whose
expected strings *are* those literals — and the HTML part is new, table-based and inline-styled.
- **Subjects now resolve the deployment's own name.** They interpolate `{{siteName}}`, which is
`settings.getInstanceName()` — the admin-set `site_title`, falling back to `BRAND_NAME`. On a
deployment that never set a site title nothing changes; on one that did, the subject finally says
what the site calls itself.
**No phone-home.** No transport may ship a default host, port, endpoint or sender
([`ENGAGEMENT.md`](ENGAGEMENT.md) §3.2). A transport with no operator configuration is
`unconfigured` and its channel is off — it never falls back to a destination we chose.