feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6) #174
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/engagement-email-channel"
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 6 —
docs/website/ENGAGEMENT.md. Email becomes aDeliveryChanneldriven 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 inengagement_sendslike 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 = 0so 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:Seven decisions, settled by the org lead before any code
teamNotifyhas three; which movedeliverbelongs to Phase 7 with the inboxrecipientIdsalways computedteam_notification_prefs, read as a scoped preferencemuted, silencing push tooThree defects found while building it
email.buttonnever absolutized its href.email.imageandemail.itemListboth callctx.absolute; the button calledctx.safeHrefalone. It had never mattered because every caller passed an absolute URL — but a trigger'surlvariables are validated site-relative by construction (RELATIVE_URLexists 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.instantonly, and each property has its own named test.address_hashwhile the instant row beside it did — found by readingengagement_sendson 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.jssaid so itself — "core knows no game vocabulary and cannot guess which members were meant."So the event names it.
recipientUserIdson the envelope; amembersaudience resolves to it. It is a narrowing input, and all three guards are tested: the set is still filtered forusers.status = 'active', the ceiling returned is stillmembersso G24 still runs, and the list is bounded atMAX_AUDIENCEat 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_prefsholds a row only where a user expressed something, absence means the channel default, and email's isoff. 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 tooff, 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.
mutedsilences every channel;email_modedecides email and says nothing about the others; absence of a row meansofffor email, because deferring upward would mean somebody who once enabledteam.forum.postemail starts hearing from every Team on the deployment.scope_keyis notsubject_keysubject_keyis what a cooldown counts (the trigger's declaredsubjectKey,teamNamehere — a display string, fine, it is only compared with itself).scope_keyis what a preference and an unsubscribe are keyed on and must be stable:team:12survives 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/:tokenstays permanently, handing straight to the same handlers: mail sent before this phase carries that path in itsList-Unsubscribeheader 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.postdeclares its title asthreadTitleandteam.announcementastitle, 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
teamNotifyDispatch.test.js(the mail-body assertions moved down to the channel; what is asserted here is now the envelope), 31 in a newengagementEmail.test.js, plus mailer, engine and block-renderer additions.List-Unsubscribeheaders, a digest gathering two posts and stampingengagement_digest_state, the genericnotify.eventpath 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.AI disclosure
Written with Claude Code (Claude Opus 5). Commits carry the
Co-Authored-Bytrailer.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>