From aa5c1d63b01f5307d7937b1f887aee37a8e9d6b8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 29 Aug 2026 13:07:50 -0500 Subject: [PATCH] docs(website): the seeded template set as built (engagement Phase 5a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- website/BACKEND_DESIGN.md | 85 ++++++++++++++++++++++ website/ENGAGEMENT.md | 148 +++++++++++++++++++++++++++++++++++++- 2 files changed, 231 insertions(+), 2 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 2e66ec3..7603701 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -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. diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index 8af8947..e72e2a0 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -1317,7 +1317,8 @@ change is not complete until `docs/` reflects it" — is the floor; this table i | **3** Channel preferences | `website/BACKEND_DESIGN.md` route table · `android/PLAN.md` §11 | — | | **4a** Engine | `website/ENGAGEMENT.md` (rules/cooldown/outbox as built, and the two §4 defects it corrects) · `BACKEND_DESIGN.md` table inventory | — | | **4b** Rules screen ✅ | `website/BACKEND_DESIGN.md` route table (the twelve routes, incl. the `PATCH …/enabled` argument and the count-only preview) · `website/ENGAGEMENT.md` §5.1a composition UI | Landed with the phase (docs#184) | -| **5a/5b** Templates + editor | `website/ENGAGEMENT.md` §4.6 · a template-authoring section in `BACKEND_DESIGN.md` or its own doc | **`runicgateway.com`**: a new admin docs page for the template editor | +| **5a** Templates ✅ | `website/ENGAGEMENT.md` §4.6 as built · `BACKEND_DESIGN.md` — the `engagement_templates` table, the two block registries, the token grammar, and §7's multipart/subject changes | Landed with the phase | +| **5b** The editor | `website/ENGAGEMENT.md` §4.6.2 as built · `BACKEND_DESIGN.md` route table | **`runicgateway.com`**: a new admin docs page for the template editor | | **6** Email channel + Teams migration | `website/TEAMS.md` §6.3/§6.4 **rewritten** — the Team pipeline it describes no longer exists as its own thing | **`runicgateway.com`**: `administration/teams.mdx` notification section | | **7** In-app channel (core+web) | `website/BACKEND_DESIGN.md` routes + tables · `website/ENGAGEMENT.md` | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel | | **8** In-app (Android) | `android/PLAN.md` | `android-app/README.md` | @@ -2074,7 +2075,7 @@ refuses anything else, and it also refuses an id with no dot in it at all. Two slices, landing in this order **on purpose** — the seeded set has to exist before the editor, so the editor is opening something rather than facing a blank page. -**5a — storage, blocks, renderer, seeds.** `engagement_templates`, the `email.*` block family with +**5a — storage, blocks, renderer, seeds.** ✅ `engagement_templates`, the `email.*` block family with `toText`, the HTML + plain-text renderer, brand-value resolution, and the §4.6.1 seeded set. The five transactional bodies move out of `mailer.js` into seeded rows and `mailer` renders them. **No editor yet** — this slice is provably done when the same mail goes out from a template that used to come from a @@ -2098,6 +2099,149 @@ operator-authored HTML; it must be sandboxed (`