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 (`