feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a) #172
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/engagement-templates"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Engagement Phase 5a — storage, blocks, renderer, seeds. Companion docs PR: docs#185.
Every subject and body moves out of
mailer.jsintoengagement_templatesrows 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
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 givenregisterBlockfreezes a fixed field set and would silently DROP atoHtml/toTextteamNotify/teamDigestWorkerare explicitly Phase 6's to rewriteAnd a correction to §4.6.1 itself: it lists
auth.email-verifyas "(new — Phase 9)". Phase 1b already shippedmailer.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 ispages.model.js, soemail.headingin 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/containermean nothing to a mail body).What is the same rule for both is shared by binding, not by copy.
validateBlocks.jsandsanitizeBlocks.jsbecame factories over a registry lookup, each still exporting the page-bound instance every existing caller imports, andemailBlocks/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.itemListrenders 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:
mailerbuiltfor 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 anexampleshowing 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
{{resetUrl}}says nothing about where it points; checking only the literal would let a variable carryingjavascript:become an href. A substituted value that failsisSafeUrlloses 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
renderByKeyfalls back to the in-code seed whenever the row is absent or itsblockswill 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 = 1stops 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, thenUPDATE … 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'sON DUPLICATE KEY UPDATEcannot 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, soaffectedRowson 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
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.{{siteName}}=settings.getInstanceName()— the admin-setsite_titlefalling back toBRAND_NAME, rather thanBRAND_NAMEalone. 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-messagedeclares bothfromLabelandfromName— 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 keyauth.email-verifyas the hostnameauth.email..emailis a real TLD and the pattern's trailing\bmatches betweenland-, 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.emailhosts it must still catch.Verification
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) andhoneypot.test.js's 10-second pool-acquire flake under full-suite parallelism — both reproduce on cleanedge.routes.manifest/routes.guardsneedmodules/uomoved aside as ever; 5a adds no routes, so there is nothing to regenerate.seedOneand so prove the loop rather than the statements — the exact shape of Phase 4a'sfoundRowstrap, where a stub agreed with a broken query. First run 9 inserted; second 9 skipped; after aseedVersionbump 8 updated and the customized row skipped, keeping both its words and its old version, and surfaced bystaleCustomized. Blocks JSON round-tripped throughMEDIUMTEXT;update()twice with identical values returned true both times.multipart/alternative; charset=utf-8with the curly quotes correctly quoted-printable-encoded, the button rendering with its bare URL beneath it, and the text part matching the deleted literal.<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.settingstable missingupdated_at, andambient()degraded to theBRAND_*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 (
variablesForis in place and is what it will ask), the sandboxed preview and its CSP test.🤖 Generated with Claude Code