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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user