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

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>
This commit is contained in:
2026-08-29 20:11:54 -05:00
parent e2dad3104f
commit 065bec7ad8
44 changed files with 2531 additions and 428 deletions

View File

@@ -13424,6 +13424,67 @@
}
}
},
"/api/v1/public/engagement/unsubscribe/{token}": {
"post": {
"tags": [
"Public · Engagement"
],
"summary": "Unsubscribe from one channel for one scope",
"description": "Honours the tokened link in an engagement email, including RFC 8058 one-click. The token names a delivery channel and a scope; the write turns that channel off for that scope and nothing else. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, scope) pairs exist. Tokens signed before this route existed are still honoured, at this path and at the older /public/teams one.",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The signed token from the email link."
}
],
"responses": {
"200": {
"description": "Acknowledged",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OkFlag"
}
}
}
}
},
"security": [
{}
]
},
"get": {
"tags": [
"Public · Engagement"
],
"summary": "Land a human on the unsubscribe page",
"description": "For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the sites own confirmation page and changes nothing — a GET must not mutate, or a link scanner would unsubscribe people who asked for nothing.",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The signed token from the email link."
}
],
"responses": {
"302": {
"description": "Redirect to the sites unsubscribe page"
}
},
"security": [
{}
]
}
},
"/api/v1/public/modules": {
"get": {
"tags": [
@@ -13811,8 +13872,8 @@
"tags": [
"Public · Teams"
],
"summary": "Unsubscribe from one Teams notification emails",
"description": "Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.",
"summary": "Unsubscribe from one Teams notification emails (legacy path)",
"description": "The pre-Phase-6 path, kept permanently because links in mail already sent point at it. Identical to POST /public/engagement/unsubscribe/{token}. Honours the tokened link including RFC 8058 one-click; a token signed before Phase 6 turns off that Teams email and no longer mutes its push. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.",
"parameters": [
{
"name": "token",