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

@@ -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 (`<iframe sandbox>` with no `allow-
`about:blank` origin) so it never executes under the site's origin, and `sanitizeHtml` runs on write as
well as on render. Worth a `test/csp.test.js` sibling asserting the preview frame's attributes.
#### As built — 5a (2026-08-29)
Built as website#TBD. Three decisions were settled by the org lead before any code, each because the
tree contradicted something the plan assumed.
| | Question the survey raised | Decision |
| --- | --- | --- |
| **The HTML part** | **Not one existing mail has one.** All six senders in `mailer.js` set `text:` only, so "renders byte-comparably" is a statement about a *text* body and whether 5a introduces HTML to live mail was an open choice | **Multipart now, text byte-identical.** The text part is byte-for-byte what went out before; the HTML alternative is new. The renderer is therefore exercised by real mail in the phase that builds it rather than shipping dead until 5b |
| **The block registry** | **The server block registry has no renderer of any kind.** Page blocks are drawn by React on the client; `registerBlock` freezes a fixed field set and would silently DROP a `toHtml`/`toText` | **A sibling registry, with the machinery shared by binding.** §4.4's "do not build a second editor" is about the editor, and 5b still drives these through the existing block/prop-panel machinery |
| **The seed scope** | §4.6.1 lists nine seeds but `teamNotify`/`teamDigestWorker` are explicitly Phase 6's to rewrite | **Seed all nine, wire the six transactional.** Phases 6 and 7 open something rather than each shipping seeds of their own |
**And a correction to §4.6.1 itself:** it lists `auth.email-verify` as "*(new — Phase 9)*". Phase 1b
already shipped `mailer.sendEmailVerification`, so it is a **current** message type, not a future one —
six bodies moved, not five, and all six are pinned by the byte-comparison test.
##### The two registries are siblings, and that is an argument rather than a preference
Sharing one Map would have cost three things. Email blocks **render on the server** and so carry
`toHtml`/`toText`, which the page registry's frozen entry shape has nowhere to put. One Map is one
namespace, and the page registry's only server consumer is `pages.model.js` — so `email.heading` in it
means a CMS page containing an email block validates and saves, with nothing on the client able to draw
it. And the entry shapes genuinely differ: `cacheTTL` and `container` mean nothing to a mail body.
What *is* the same rule for both is shared by binding, not by copy. `blocks/validateBlocks.js` and
`sanitizeBlocks.js` became factories over a registry lookup (`makeValidateBlocks` /
`makeSanitizeBlocks`), each exporting the page-bound instance every existing caller already imports, and
`emailBlocks/` binds the same walk to its own registry. The envelope rules, id uniqueness, schema
dispatch and the validate-then-sanitize order therefore cannot drift between the families. There is a
test asserting both directions of the isolation: an `email.heading` fails page validation, and a plain
`heading` fails email validation.
##### The token grammar has no conditional, so the ternaries stayed at the call site
`{{ name }}`, a bare declared variable, and nothing else — no filters, conditionals, loops or dotted
paths. Repetition is a block (`email.itemList` renders a declared *list* variable), which is the one
place a template needs "for each" and it already has a typed, validated home.
That has a visible consequence. `mailer` built ` for the account Darrow` with a ternary, and a
logic-free template cannot. So the ternary stays where a ternary belongs and its **result** arrives as a
variable — `forWhom`, `roleLabel`, `invitedBy`, `moreNote` — each declared with an `example` showing
exactly what it produces, leading space and quotes included. It is not pretty in the editor and it is
the price of not giving operator-authored data a conditional to get wrong. **Both branches of every
ternary are asserted**, because the empty one is what a template language with a conditional would most
likely get wrong.
Where a conditional would otherwise be reached for, "nothing in, nothing out" stands in: a block whose
content interpolates to nothing renders nothing, in **both** parts. `{{moreNote}}` on its own line is a
line the caller can decline to supply.
##### Three properties of the renderer that are load-bearing
- **The shell contributes structure and no content.** No appended footer, no injected logo, no "sent by"
line. An unsubscribe line is a *variable inside the template*, so an operator can move it, reword it,
or see that a transactional mail correctly has none — and, more importantly, the HTML and text parts
say the same things. A footer in one and not the other is a deliverability signal and means the text
reader is told less than the HTML reader.
- **Only the accent comes from the theme.** Every shipped preset is a DARK palette, and §4.6.2 already
names the failure: a light-only template "renders as unreadable dark-on-dark in about a third of
inboxes", because clients invert or force their own background. Deriving a light palette from a dark
one is a guess at six colours; taking the one colour that carries the brand is exact. §4.6.1's
property 2 holds either way — no seeded template contains a hex code, asserted by a test.
- **A URL built from a variable is re-checked after substitution.** A stored `{{resetUrl}}` says nothing
about where it points. Checking only the literal would let a variable carrying `javascript:` become an
href; a substituted value that fails `isSafeUrl` loses its href and renders as inert text rather than
vanishing, because silently dropping it would hide from the reader that the mail meant to offer them
something.
##### A missing row renders the shipped default, which is what makes the whole move safe
`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, on a row hand-edited in the
database. Without it, moving a password-reset body into a table would have made every failure mode of
that table a failure mode of account recovery. `protected = 1` stops the last of those from being
reachable through the API at all.
##### The seed guard is in the SQL, not in a read-then-write
`INSERT IGNORE`, then `UPDATE WHERE seed_key = ? AND customized = 0 AND seed_version < ?`. A check in
JavaScript followed by an UPDATE leaves a window in which a concurrent boot overwrites an edit an
operator made a moment earlier, and a deployment can start two app processes at once. MariaDB's
`ON DUPLICATE KEY UPDATE` cannot carry a WHERE, which is why this is two statements rather than the
upsert §4.6.1 sketches.
Related, and recorded because [Phase 4a](#as-built--4a-2026-08-29) was bitten by the same thing: the
connector defaults **`foundRows: true`**, so `affectedRows` on an UPDATE counts *matched* rows. For the
seeder that is harmless (its WHERE only matches a row that will change); for an operator's save it is
the semantics wanted — re-saving a template unchanged is a success, not a 404. Both are now stated in
the code rather than relied on.
##### Two behaviour changes an operator will notice
1. **Mail is multipart.** A client that prefers HTML now shows a branded body where it used to show
plain text. Nothing a text-only reader sees has changed.
2. **Subjects resolve the deployment's own name.** They interpolate `{{siteName}}`, which is
`settings.getInstanceName()` — the admin-set `site_title` falling back to `BRAND_NAME`, rather than
`BRAND_NAME` alone. On an instance that never set a site title nothing changes; on one that did, the
subject finally says what the site calls itself.
Also inherited rather than introduced, and now visible: `admin.contact-message` declares **both**
`fromLabel` and `fromName` — the same missing name with the two different fallbacks the literal used
('a visitor' in the subject, 'unknown' in the body). Kept exactly, asserted, and now editable by whoever
wants one word.
##### One defect this phase found in a Phase 1 check
`npm run check:hosts` (§3.2 rule 4) read the template key **`auth.email-verify`** as the hostname
`auth.email`. `.email` is a real TLD and the pattern's trailing `\b` matches between `l` and `-`, so any
engagement identifier whose label happens to end in a TLD tripped it — and §4.6.1 names that key. Fixed
with a `(?![-\w])` after the TLD: a real hostname's TLD is its last label, so a following `-` or word
character means the match is a truncation of a longer identifier. Everything a host *is* followed by (a
quote, `/`, `:`, `?`) still matches, and the checker's own suite gained both the identifiers it must now
accept and two real `.email` hosts it must still catch.
##### Verification
- **31 new server tests** (`test/emailTemplates.test.js`) and 3 added to the host-check suite. Full
server suite green: 1388 passing, with the one known Windows CRLF artifact
(`engagement-triggers.json` byte comparison) and `honeypot.test.js`'s 10-second pool-acquire flake
under full-suite parallelism, both of which reproduce on clean `edge`. `routes.manifest` /
`routes.guards` need `modules/uo` moved aside, as ever; 5a adds no routes.
- **The seeder's SQL against a real MariaDB**, because the unit tests stub `seedOne` and therefore prove
the loop rather than the statements — the exact shape of Phase 4a's `foundRows` trap, where a stub
agreed with a broken query. First run 9 inserted; second 9 skipped; after a `seedVersion` bump 8
updated and the customized row skipped, keeping both its words and its old version, and surfaced by
`staleCustomized`. The blocks JSON round-tripped through `MEDIUMTEXT`, and `update()` twice with
identical values returned true both times.
- **Real mail, end to end**, through nodemailer and SMTP into a mailpit catcher: all five senders, the
message arriving as `multipart/alternative; charset=utf-8` with the curly quotes correctly
quoted-printable-encoded, the button rendering with its bare URL beneath it, and the text part
matching the deleted literal.
- **The same seed rows, two deployments.** Run as *UOMysticmoon* with a gold accent and as *Vesper Isle*
with a blue one, changing only stored settings: the subjects and the button colour follow the
deployment, from identical rows. §5a's fourth acceptance criterion, proved rather than argued.
- **A contact message carrying `<script>alert(1)</script> & <img src=x onerror=>`**, sent for real:
escaped in the HTML part, raw in the text part, no live tag in the delivered message.
- One accidental proof worth keeping: an early rig run had a `settings` table missing `updated_at`, and
`ambient()` degraded to the `BRAND_*` env values with a warning and sent the mail anyway. That path
is not otherwise easy to reach.
**Still 5b's:** the editor, the admin Templates screen, the template CRUD routes, the save-time
undeclared-variable refusal (`variablesFor` is in place and is what it will ask), the sandboxed preview
and its CSP test, and the `runicgateway.com` admin docs page §6.0b assigns the pair.
---
### Phase 6 — The email channel on the engine, and the Teams migration