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>
40 lines
2.5 KiB
JavaScript
40 lines
2.5 KiB
JavaScript
const express = require('express')
|
||
|
||
const ctrl = require('./engagement.controller')
|
||
|
||
const engagementRouter = express.Router()
|
||
|
||
// ── One-click unsubscribe (ENGAGEMENT.md Phase 6) ──────────────────────────
|
||
//
|
||
// The canonical home of the unsubscribe pair, generalized off
|
||
// `/public/teams/unsubscribe/:token`. That path still exists and still works —
|
||
// see `teams.router.js` — because links in mail already sent cannot be rewritten.
|
||
//
|
||
// No `siteMode`, unlike almost every other public route. An unsubscribe has to
|
||
// work while the site is in maintenance: the mail that carried the link went out
|
||
// before the site went down, and "we are doing maintenance" is not an answer to
|
||
// "stop emailing me".
|
||
engagementRouter.post(
|
||
'/unsubscribe/:token',
|
||
// #swagger.tags = ['Public · Engagement']
|
||
// #swagger.summary = 'Unsubscribe from one channel for one scope'
|
||
// #swagger.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.'
|
||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||
// #swagger.security = [{}]
|
||
/* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||
ctrl.unsubscribe,
|
||
)
|
||
|
||
engagementRouter.get(
|
||
'/unsubscribe/:token',
|
||
// #swagger.tags = ['Public · Engagement']
|
||
// #swagger.summary = 'Land a human on the unsubscribe page'
|
||
// #swagger.description = 'For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the site’s own confirmation page and changes nothing — a GET must not mutate, or a link scanner would unsubscribe people who asked for nothing.'
|
||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||
// #swagger.security = [{}]
|
||
/* #swagger.responses[302] = { description: 'Redirect to the site’s unsubscribe page' } */
|
||
ctrl.unsubscribeLanding,
|
||
)
|
||
|
||
module.exports = engagementRouter
|