docs: the engagement workstream — cutover 1 of 7 (edge → main)
#200
@@ -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.
|
||||
|
||||
@@ -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