feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6) #174

Merged
whitlocktech merged 1 commits from feature/engagement-email-channel into edge 2026-08-31 06:07:17 +00:00
Member

Engagement Phase 6docs/website/ENGAGEMENT.md. Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being its own thing. One walk now goes forum write → events.emit → rule → outbox → worker → email channel → template → SMTP → mailbox, recorded in engagement_sends like everything else the platform sends.

Docs: RunicGateway/docs#187 · Site: RunicGateway/runicgateway.com#23


The one thing a reviewer must not miss

Team notification emails stop on upgrade until an operator turns a rule on. That is decision 3, taken deliberately: an engagement rule arrives enabled = 0 so no import, restore or upgrade can start mailing on its own, and core seeds the four Team rules under that same invariant rather than carving an exception into it. Three things keep it from being a silent regression:

  • Admin → Engagement → Rules carries a banner for as long as every Team rule is off, saying these used to send automatically and arrived switched off on purpose. It reads the rules rather than a flag, so it disappears the moment one is enabled.
  • The release note names it, alongside the v1-token narrowing below.
  • Push and the Discord bridge are unaffected — only email moved.

Seven decisions, settled by the org lead before any code

Question Decision
Sinks teamNotify has three; which move email only. The tickle and the Discord bridge stay direct — a bridge is a leg, not a per-recipient channel, and push's deliver belongs to Phase 7 with the inbox
Audience how a Team event reaches its members the event carries the set — the same access-checked list recipientIds always computed
Continuity rules default off; core seeds none seed the four DISABLED, plus the banner and the note
Prefs per-Team granularity has nowhere to live keep team_notification_prefs, read as a scoped preference
Templates §4.6.1 property 1 was never implemented payload wins, a structural projection fills gaps
Digest §4.2b says compute-at-send-time; the engine snapshots keep compute-at-send-time; generalize only the state
Unsubscribe a v1 token set muted, silencing push too turn off the channel the token names, and nothing else

Three defects found while building it

  1. email.button never absolutized its href. email.image and email.itemList both call ctx.absolute; the button called ctx.safeHref alone. It had never mattered because every caller passed an absolute URL — but a trigger's url variables are validated site-relative by construction (RELATIVE_URL exists so a variable cannot carry a recipient off-site), so every rule-driven call-to-action would have interpolated to /guilds/x: a dead link in every notification the engine sends. Fixed in both parts.
  2. Phase 4a enqueued digest-mode recipients for a drain Phase 6 decided not to build ("what changes in Phase 6 is who drains it" — nothing does). An outbox row snapshots the payload at emit time and therefore has none of the three properties the compute-at-send-time design exists for, including the security one. The engine now enqueues instant only, and each property has its own named test.
  3. The digest's send-log row carried no address_hash while the instant row beside it did — found by reading engagement_sends on the live rig. Half the deployment's mail would have been uncorrelatable when Phase 9's bounce handling lands.

Why the audience had to come from the event

The engine could not express "the members of the Team this post was in", and structurally could not: a rule names a plain ceiling resolved from core's tables, or a saved segment composing module-declared audiences with constant params. The list here is different for every firing and is the answer to an access question core already knows how to ask. audiences.js said so itself — "core knows no game vocabulary and cannot guess which members were meant."

So the event names it. recipientUserIds on the envelope; a members audience resolves to it. It is a narrowing input, and all three guards are tested: the set is still filtered for users.status = 'active', the ceiling returned is still members so G24 still runs, and the list is bounded at MAX_AUDIENCE at the emit boundary. A rule with any other audience ignores it entirely.

The scoped preference, and the reading that could not ship

Decision 4 was phrased as "a suppression below the channel preference". Intersecting is the one reading that cannot ship, for a reason that is a second instance of the G22 pattern: notification_channel_prefs holds a row only where a user expressed something, absence means the channel default, and email's is off. Nobody has ever expressed a stream-level opinion about a Team trigger — the screen predates them by a year. Intersecting would resolve every existing Team-email subscriber to off, and the deploy that migrated the pipeline would be the deploy that silenced it.

What shipped is replacement: where a scope has an opinion, that opinion is the preference. muted silences every channel; email_mode decides email and says nothing about the others; absence of a row means off for email, because deferring upward would mean somebody who once enabled team.forum.post email starts hearing from every Team on the deployment.

scope_key is not subject_key

subject_key is what a cooldown counts (the trigger's declared subjectKey, teamName here — a display string, fine, it is only compared with itself). scope_key is what a preference and an unsubscribe are keyed on and must be stable: team:12 survives a rename. An unsubscribe token is signed over it and sits in a mailbox for months with no expiry, so signing over a display name orphans every link the first time staff rename a guild.

The unsubscribe route did not move, and never will

The canonical pair is now /api/v1/public/engagement/unsubscribe/:token. /api/v1/public/teams/unsubscribe/:token stays permanently, handing straight to the same handlers: mail sent before this phase carries that path in its List-Unsubscribe header and in its body, mail is not editable once sent, and a route that moves is a person who cannot unsubscribe. Both are in the route manifest.

A v1 token still verifies forever and reads as { channel: 'email', scopeKey: 'team:<id>' }. It now turns off email and no longer mutes push — the live behaviour change decision 7 accepted, because a link labelled "stop these emails" was quietly stopping notifications on somebody's phone.

One thing left alone on purpose

team.forum.post declares its title as threadTitle and team.announcement as title, though both describe a thread in a Team forum. Reconciling them is a variable rename, which §4.3 makes a version bump, and this phase did not take that on its own authority — the seeded announcement rule points at the generic template instead, where the projection gets it right. A test pins the current shape so reconciling it stays a deliberate act.

Verified

  • 1464 server tests, 324 client tests, both green. 35 in a rewritten teamNotifyDispatch.test.js (the mail-body assertions moved down to the channel; what is asserted here is now the envelope), 31 in a new engagementEmail.test.js, plus mailer, engine and block-renderer additions.
  • A live rig — MariaDB + Mailpit + a booted server + a real Team with three members — covering an instant mail with both List-Unsubscribe headers, a digest gathering two posts and stamping engagement_digest_state, the generic notify.event path rendering an announcement with no authoring, a pre-migration v1 token unsubscribing through the old path, and a restart proving the backfill replay-safe. Three of the findings above came from it.
  • Route manifest, guards and swagger regenerated.

AI disclosure

Written with Claude Code (Claude Opus 5). Commits carry the Co-Authored-By trailer.

Engagement **Phase 6** — `docs/website/ENGAGEMENT.md`. Email becomes a `DeliveryChannel` driven by rules, and the Team pipeline stops being its own thing. One walk now goes **forum write → `events.emit` → rule → outbox → worker → email channel → template → SMTP → mailbox**, recorded in `engagement_sends` like everything else the platform sends. Docs: RunicGateway/docs#187 · Site: RunicGateway/runicgateway.com#23 --- ## The one thing a reviewer must not miss **Team notification emails stop on upgrade until an operator turns a rule on.** That is decision 3, taken deliberately: an engagement rule arrives `enabled = 0` so no import, restore or upgrade can start mailing on its own, and core seeds the four Team rules under that same invariant rather than carving an exception into it. Three things keep it from being a silent regression: - **Admin → Engagement → Rules carries a banner** for as long as every Team rule is off, saying these used to send automatically and arrived switched off on purpose. It reads the rules rather than a flag, so it disappears the moment one is enabled. - **The release note names it**, alongside the v1-token narrowing below. - **Push and the Discord bridge are unaffected** — only email moved. ## Seven decisions, settled by the org lead before any code | | Question | Decision | |---|---|---| | Sinks | `teamNotify` has three; which move | **email only.** The tickle and the Discord bridge stay direct — a bridge is a *leg*, not a per-recipient *channel*, and push's `deliver` belongs to Phase 7 with the inbox | | Audience | how a Team event reaches its members | **the event carries the set** — the same access-checked list `recipientIds` always computed | | Continuity | rules default off; core seeds none | **seed the four DISABLED**, plus the banner and the note | | Prefs | per-Team granularity has nowhere to live | **keep `team_notification_prefs`**, read as a scoped preference | | Templates | §4.6.1 property 1 was never implemented | **payload wins, a structural projection fills gaps** | | Digest | §4.2b says compute-at-send-time; the engine snapshots | **keep compute-at-send-time**; generalize only the state | | Unsubscribe | a v1 token set `muted`, silencing push too | **turn off the channel the token names, and nothing else** | ## Three defects found while building it 1. **`email.button` never absolutized its href.** `email.image` and `email.itemList` both call `ctx.absolute`; the button called `ctx.safeHref` alone. It had never mattered because every caller passed an absolute URL — but a trigger's `url` variables are validated **site-relative by construction** (`RELATIVE_URL` exists so a variable cannot carry a recipient off-site), so every rule-driven call-to-action would have interpolated to `/guilds/x`: a dead link in every notification the engine sends. Fixed in both parts. 2. **Phase 4a enqueued digest-mode recipients** for a drain Phase 6 decided not to build ("what changes in Phase 6 is who drains it" — nothing does). An outbox row snapshots the payload at emit time and therefore has **none** of the three properties the compute-at-send-time design exists for, including the security one. The engine now enqueues `instant` only, and each property has its own named test. 3. **The digest's send-log row carried no `address_hash`** while the instant row beside it did — found by reading `engagement_sends` on the live rig. Half the deployment's mail would have been uncorrelatable when Phase 9's bounce handling lands. ## Why the audience had to come from the event The engine could not express *"the members of the Team this post was in"*, and structurally could not: a rule names a plain ceiling resolved from core's tables, or a **saved segment** composing module-declared audiences with **constant** params. The list here is different for every firing and is the answer to an access question core already knows how to ask. `audiences.js` said so itself — *"core knows no game vocabulary and cannot guess which members were meant."* So the **event** names it. `recipientUserIds` on the envelope; a `members` audience resolves to it. It is a **narrowing** input, and all three guards are tested: the set is still filtered for `users.status = 'active'`, the ceiling returned is still `members` so **G24 still runs**, and the list is bounded at `MAX_AUDIENCE` at the emit boundary. A rule with any other audience ignores it entirely. ## The scoped preference, and the reading that could not ship Decision 4 was phrased as *"a suppression below the channel preference"*. Intersecting is the one reading that cannot ship, for a reason that is a second instance of the G22 pattern: `notification_channel_prefs` holds a row only where a user expressed something, absence means the channel default, and email's is `off`. **Nobody has ever expressed a stream-level opinion about a Team trigger** — the screen predates them by a year. Intersecting would resolve every existing Team-email subscriber to `off`, and the deploy that migrated the pipeline would be the deploy that silenced it. What shipped is **replacement**: where a scope has an opinion, that opinion is the preference. `muted` silences every channel; `email_mode` decides email and says nothing about the others; **absence of a row means `off` for email**, because deferring upward would mean somebody who once enabled `team.forum.post` email starts hearing from every Team on the deployment. ## `scope_key` is not `subject_key` `subject_key` is what a **cooldown** counts (the trigger's declared `subjectKey`, `teamName` here — a display string, fine, it is only compared with itself). `scope_key` is what a **preference** and an **unsubscribe** are keyed on and must be stable: `team:12` survives a rename. An unsubscribe token is signed over it and sits in a mailbox for months with no expiry, so signing over a display name orphans every link the first time staff rename a guild. ## The unsubscribe route did not move, and never will The canonical pair is now `/api/v1/public/engagement/unsubscribe/:token`. **`/api/v1/public/teams/unsubscribe/:token` stays permanently**, handing straight to the same handlers: mail sent before this phase carries that path in its `List-Unsubscribe` header and in its body, mail is not editable once sent, and a route that moves is a person who cannot unsubscribe. Both are in the route manifest. A v1 token still verifies forever and reads as `{ channel: 'email', scopeKey: 'team:<id>' }`. It now turns off email and **no longer mutes push** — the live behaviour change decision 7 accepted, because a link labelled "stop these emails" was quietly stopping notifications on somebody's phone. ## One thing left alone on purpose `team.forum.post` declares its title as `threadTitle` and `team.announcement` as `title`, though both describe a thread in a Team forum. Reconciling them is a **variable rename, which §4.3 makes a version bump**, and this phase did not take that on its own authority — the seeded announcement rule points at the generic template instead, where the projection gets it right. A test pins the current shape so reconciling it stays a deliberate act. ## Verified - **1464 server tests**, 324 client tests, both green. 35 in a rewritten `teamNotifyDispatch.test.js` (the mail-body assertions moved down to the channel; what is asserted here is now the envelope), 31 in a new `engagementEmail.test.js`, plus mailer, engine and block-renderer additions. - **A live rig** — MariaDB + Mailpit + a booted server + a real Team with three members — covering an instant mail with both `List-Unsubscribe` headers, a digest gathering two posts and stamping `engagement_digest_state`, the generic `notify.event` path rendering an announcement with no authoring, a **pre-migration v1 token unsubscribing through the old path**, and a restart proving the backfill replay-safe. Three of the findings above came from it. - Route manifest, guards and swagger regenerated. ## AI disclosure Written with Claude Code (Claude Opus 5). Commits carry the `Co-Authored-By` trailer.
wtclaude added 1 commit 2026-08-30 01:13:10 +00:00
feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 11m9s
065bec7ad8
Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.

Seven decisions settled by the org lead before any code:

  - email only moves; the push tickle and the Discord bridge stay direct calls
  - the EVENT carries its access-checked audience, and `members` resolves to it
  - the four Team rules are seeded DISABLED, with an admin banner and a note
  - team_notification_prefs stays, read by the engine as a scoped preference
  - the payload wins and a structural projection fills the gaps
  - the digest keeps computing at send time; only its state generalizes
  - an unsubscribe token turns off the channel it names, and nothing else

Three defects found while building it:

  - `email.button` never absolutized its href, while image and itemList both
    did. Every rule-driven CTA would have been a dead relative link, because a
    trigger's url variables are validated site-relative by construction.
  - Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
    not to build. An outbox row snapshots the payload and so has none of the
    three properties the digest design exists for, including the security one.
  - the digest's send-log row carried no address_hash while the instant row
    beside it did, which would have made half the mail uncorrelatable in Phase 9.

Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.

Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.

Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 5168446c53 into edge 2026-08-31 06:07:17 +00:00
whitlocktech deleted branch feature/engagement-email-channel 2026-08-31 06:07:19 +00:00
Sign in to join this conversation.
No description provided.