|
|
|
|
@@ -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
|
|
|
|
|
|