feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a) #172

Merged
whitlocktech merged 1 commits from feature/engagement-templates into edge 2026-08-29 18:14:47 +00:00
Member

Engagement Phase 5a — storage, blocks, renderer, seeds. Companion docs PR: docs#185.

Every subject and body moves out of mailer.js into engagement_templates rows an operator can edit. §4.6.1's framing holds: this is a relocation, not a regression — nothing that sends mail today starts depending on an operator authoring something first. No editor yet (5b); this slice is provably done when the same mail goes out from a template that used to come from a string literal.

Three decisions settled before any code, each because the tree contradicted the plan

What the survey found Decision
The HTML part Not one existing mail has one. All six senders 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, not a given Multipart now, text byte-identical. The renderer is 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, 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. Six bodies moved, not five, and all six are pinned by the byte-comparison test.

Two registries, and the reasons are not preference

One Map would have cost three things: email blocks render on the server and so carry toHtml/toText, which the frozen page 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/container mean nothing to a mail body).

What is the same rule for both is shared by binding, not by copy. validateBlocks.js and sanitizeBlocks.js became factories over a registry lookup, each still exporting the page-bound instance every existing caller 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. There is a test asserting both directions of the isolation.

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.

The visible consequence: mailer built for the account “Darrow” with a ternary, and a logic-free template cannot. 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. Not pretty in the editor, and 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.

Three renderer properties 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 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 "unreadable dark-on-dark in about a third of inboxes". 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 — dropping it silently would hide from the reader that the mail meant to offer them something.

A missing row renders the shipped default

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 made a moment earlier, and a deployment can start two app processes. 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 was bitten by exactly this: the connector defaults foundRows: true, so affectedRows on an UPDATE counts matched rows. Harmless for the seeder (its WHERE only matches a row that will change) and the right semantics for an operator's save (re-saving unchanged is a success, not a 404) — both now stated in the code rather than relied on.

Two behaviour changes an operator will notice

  1. Mail is multipart/alternative. 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}} = 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.

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 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 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 1388 passing, with the known Windows CRLF artifact (engagement-triggers.json's byte comparison) and honeypot.test.js's 10-second pool-acquire flake under full-suite parallelism — both reproduce on clean edge. routes.manifest / routes.guards need modules/uo moved aside as ever; 5a adds no routes, so there is nothing to regenerate.
  • The seeder's SQL against a real MariaDB, because the unit tests stub seedOne and so 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. Blocks JSON round-tripped through MEDIUMTEXT; 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, 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.

Still 5b's: the editor, the admin Templates screen, 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.


  • AI-assisted: written with Claude Code (Opus)

🤖 Generated with Claude Code

Engagement **Phase 5a** — storage, blocks, renderer, seeds. Companion docs PR: **docs#185**. Every subject and body moves out of `mailer.js` into `engagement_templates` rows an operator can edit. §4.6.1's framing holds: this is a **relocation, not a regression** — nothing that sends mail today starts depending on an operator authoring something first. **No editor yet** (5b); this slice is provably done when the same mail goes out from a template that used to come from a string literal. ## Three decisions settled before any code, each because the tree contradicted the plan | | What the survey found | Decision | |---|---|---| | **The HTML part** | **Not one existing mail has one.** All six senders 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, not a given | **Multipart now, text byte-identical.** The renderer is 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, 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. Six bodies moved, not five, and all six are pinned by the byte-comparison test. ## Two registries, and the reasons are not preference One Map would have cost three things: email blocks **render on the server** and so carry `toHtml`/`toText`, which the frozen page 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`/`container` mean nothing to a mail body). What is the same rule for both is shared **by binding, not by copy**. `validateBlocks.js` and `sanitizeBlocks.js` became factories over a registry lookup, each still exporting the page-bound instance every existing caller 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. There is a test asserting **both** directions of the isolation. ## 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. The visible consequence: `mailer` built ` for the account “Darrow”` with a ternary, and a logic-free template cannot. 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. Not pretty in the editor, and 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. ## Three renderer properties 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 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 "unreadable dark-on-dark in about a third of inboxes". 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 — dropping it silently would hide from the reader that the mail meant to offer them something. ## A missing row renders the shipped default `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 made a moment earlier, and a deployment can start two app processes. 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 was bitten by exactly this**: the connector defaults `foundRows: true`, so `affectedRows` on an UPDATE counts *matched* rows. Harmless for the seeder (its WHERE only matches a row that will change) and the right semantics for an operator's save (re-saving unchanged is a success, not a 404) — both now stated in the code rather than relied on. ## Two behaviour changes an operator will notice 1. **Mail is `multipart/alternative`.** 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}}` = `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. 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 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 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 **1388 passing**, with the known Windows CRLF artifact (`engagement-triggers.json`'s byte comparison) and `honeypot.test.js`'s 10-second pool-acquire flake under full-suite parallelism — both reproduce on clean `edge`. `routes.manifest` / `routes.guards` need `modules/uo` moved aside as ever; **5a adds no routes**, so there is nothing to regenerate. - **The seeder's SQL against a real MariaDB**, because the unit tests stub `seedOne` and so 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`. Blocks JSON round-tripped through `MEDIUMTEXT`; `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, 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. **Still 5b's:** the editor, the admin Templates screen, 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. --- - [x] AI-assisted: written with Claude Code (Opus) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-08-29 18:09:38 +00:00
feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
12ff201ed5
Every subject and body moves out of `mailer.js` into `engagement_templates` rows an
operator can edit. A relocation, not a regression: nothing that sends mail today
starts depending on an operator authoring something first.

- `email.*` block family in its own registry, sharing the page family's envelope
  walk and validate-then-sanitize order by binding rather than by copy.
- A server-side renderer producing both parts of a multipart message; the text
  part is byte-identical to the literals this commit deletes.
- Nine seeded templates, six of them wired now; the seeder's `customized = 0`
  guard lives in the UPDATE's own WHERE.
- `renderByKey` falls back to the shipped seed when a row is missing or unusable,
  so no failure of the table can stop a password reset.

Also fixes `check:hosts` reading the template key `auth.email-verify` as the
hostname `auth.email`.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 42b40fdec2 into edge 2026-08-29 18:14:47 +00:00
whitlocktech deleted branch feature/engagement-templates 2026-08-29 18:14:48 +00:00
Sign in to join this conversation.
No description provided.