feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
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:
108
server/src/router/v1/public/engagement.controller.js
Normal file
108
server/src/router/v1/public/engagement.controller.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// ── Public engagement surface: one-click unsubscribe ───────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6. This is the generalization of what
|
||||
// `public/teams.controller.js` did for Teams: a token names a CHANNEL and a
|
||||
// SCOPE, and honouring it turns that channel off for that scope.
|
||||
//
|
||||
// **The old path stays forever**, and that is not tidiness debt. A link in a mail
|
||||
// sent before this deploy points at `/public/teams/unsubscribe/:token`, and mail
|
||||
// is not editable after it has been sent; a route that moves is a person who
|
||||
// cannot unsubscribe. `teams.router.js` therefore keeps its two routes and hands
|
||||
// them straight to these handlers, so the two paths cannot drift into meaning
|
||||
// different things.
|
||||
|
||||
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
||||
const prefs = require('../../../model/notificationChannelPrefs/notificationChannelPrefs.model')
|
||||
const unsubscribeToken = require('../../../utils/unsubscribeToken')
|
||||
const scopedPrefs = require('../../../engagement/scopedPrefs')
|
||||
const log = require('../../../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* Apply one verified claim.
|
||||
*
|
||||
* **Scoped claims are written to the scope's own store, not to
|
||||
* `notification_channel_prefs`.** A scoped preference is what the engine reads
|
||||
* for a scoped event (engine.js `effectiveModes`), so writing 'off' anywhere else
|
||||
* would be an unsubscribe that changes a row nothing consults. Today `team` is
|
||||
* the only registered scope, and it is handled here rather than through a
|
||||
* registry write-back for the reason Phase 3 gave for deferring `deliver`: a
|
||||
* second scope is what should design that interface, not the first one.
|
||||
*
|
||||
* An UNSCOPED claim (`scopeKey === ''`) turns the channel off across the board —
|
||||
* which today no mail produces, because every mail this platform sends carries a
|
||||
* scope. It is implemented rather than refused so that the first deployment-wide
|
||||
* mail does not ship with an unsubscribe link that quietly does nothing.
|
||||
*/
|
||||
async function applyClaim(claim) {
|
||||
if (!claim.scopeKey) {
|
||||
await prefs.setAllChannelOff(claim.userId, claim.channel)
|
||||
return
|
||||
}
|
||||
const parsed = scopedPrefs.parse(claim.scopeKey)
|
||||
if (parsed && parsed.prefix === 'team') {
|
||||
if (claim.channel === 'email') {
|
||||
// **Not `mute`, and this is Phase 6's deliberate narrowing.** A v1 token set
|
||||
// `muted = 1`, which silenced that Team's push as well as its email — a link
|
||||
// labelled "stop these emails" quietly stopping notifications on somebody's
|
||||
// phone. A token now names its channel and turns off that channel only.
|
||||
await teamPrefs.setEmailMode(claim.userId, Number(parsed.id), 'off')
|
||||
return
|
||||
}
|
||||
await teamPrefs.mute(claim.userId, Number(parsed.id))
|
||||
return
|
||||
}
|
||||
// A scope whose provider is not registered — a module uninstalled since the
|
||||
// mail went out. Nothing to write, and the caller is still told 200: the mail
|
||||
// that named it cannot be sent again either.
|
||||
log.warn('unsubscribe named an unknown scope', { scope: claim.scopeKey })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /public/engagement/unsubscribe/:token — one-click unsubscribe (RFC 8058).
|
||||
*
|
||||
* **The one write in this tier, and it is unauthenticated on purpose.** A person
|
||||
* reading their mail is not logged into the site, and an unsubscribe that first
|
||||
* demands a login is an unsubscribe most people do not complete. The token is what
|
||||
* stands in for the session, and the capability it carries is deliberately the
|
||||
* narrowest one that does the job: turn ONE channel off for ONE scope. It reads
|
||||
* nothing, cannot turn anything back on, and names no other scope.
|
||||
*
|
||||
* **Always 200, whatever the token was.** A response that distinguished a valid
|
||||
* token from a forged one would turn this into an oracle for which (user, scope)
|
||||
* pairs exist, on an endpoint with no session behind it. The page says "you will
|
||||
* not receive further emails about this" either way, which is true either way.
|
||||
*
|
||||
* Reached two ways with the same effect: a mail client's RFC 8058 one-click POST
|
||||
* (the `List-Unsubscribe-Post` header), and the site's own /unsubscribe page,
|
||||
* which POSTs here after a human clicks the link in the body.
|
||||
*/
|
||||
async function unsubscribe(req, res) {
|
||||
const claim = unsubscribeToken.verify(req.params.token)
|
||||
if (claim) {
|
||||
try {
|
||||
await applyClaim(claim)
|
||||
} catch (err) {
|
||||
// Logged, not surfaced. A failed write here is worth an operator's
|
||||
// attention and is not worth telling an anonymous caller about — and a 500
|
||||
// would make a mail client retry a request it should not repeat.
|
||||
log.error('unsubscribe', err)
|
||||
}
|
||||
}
|
||||
return res.json({ ok: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET on the same path — for a mail client that shows the `List-Unsubscribe` URL
|
||||
* as a link and has no one-click support.
|
||||
*
|
||||
* Redirects to the site's own page rather than acting, because a GET must not
|
||||
* mutate: a link prefetcher or a mail client's link scanner would otherwise
|
||||
* silently unsubscribe people who asked for nothing. The page it lands on does the
|
||||
* POST once a human is looking at it.
|
||||
*/
|
||||
function unsubscribeLanding(req, res) {
|
||||
const base = (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
return res.redirect(302, `${base}/unsubscribe/${encodeURIComponent(req.params.token)}`)
|
||||
}
|
||||
|
||||
module.exports = { unsubscribe, unsubscribeLanding, applyClaim }
|
||||
39
server/src/router/v1/public/engagement.router.js
Normal file
39
server/src/router/v1/public/engagement.router.js
Normal file
@@ -0,0 +1,39 @@
|
||||
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
|
||||
@@ -22,6 +22,7 @@ const wikiRouter = require('./wiki.router')
|
||||
const pagesRouter = require('./pages.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const engagementRouter = require('./engagement.router')
|
||||
const siteRouter = require('./site.router')
|
||||
|
||||
const publicRouter = express.Router()
|
||||
@@ -41,6 +42,12 @@ publicRouter.use('/modules', modulesRouter)
|
||||
// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the
|
||||
// content above it.
|
||||
publicRouter.use('/teams', teamsRouter)
|
||||
// The unauthenticated half of the engagement system: today exactly the
|
||||
// unsubscribe pair. Its own prefix rather than a Teams sub-path, because what a
|
||||
// token names is a channel and a scope and a scope is not always a Team
|
||||
// (ENGAGEMENT.md Phase 6). Never site-mode gated — an unsubscribe has to work
|
||||
// while the site is in maintenance.
|
||||
publicRouter.use('/engagement', engagementRouter)
|
||||
|
||||
// The four singletons that own no path segment of their own: /settings, /status,
|
||||
// /version and /contact. Mounted at the group root, last — safe only because
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
const teams = require('../../../model/teams/teams.model')
|
||||
const teamActivity = require('../../../model/teams/teamActivity.model')
|
||||
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
||||
const unsubscribeToken = require('../../../utils/unsubscribeToken')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
@@ -100,52 +98,18 @@ async function getActivity(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /public/teams/unsubscribe/:token — one-click unsubscribe (TEAMS.md §6.4).
|
||||
*
|
||||
* **The one write in this tier, and it is unauthenticated on purpose.** A person
|
||||
* reading their mail is not logged into the site, and an unsubscribe that first
|
||||
* demands a login is an unsubscribe most people do not complete. The token is what
|
||||
* stands in for the session, and the capability it carries is deliberately the
|
||||
* narrowest one that does the job: set `muted` for ONE (user, Team) pair. It reads
|
||||
* nothing, cannot un-mute, and names no other Team.
|
||||
*
|
||||
* **Always 200, whatever the token was.** A response that distinguished a valid
|
||||
* token from a forged one would turn this into an oracle for which (user, Team)
|
||||
* pairs exist, on an endpoint with no session behind it. The page says "you will
|
||||
* not receive further emails about this team" either way, which is true either way.
|
||||
*
|
||||
* Reached two ways with the same effect: a mail client's RFC 8058 one-click POST
|
||||
* (the `List-Unsubscribe-Post` header), and the site's own /unsubscribe page,
|
||||
* which POSTs here after a human clicks the link in the body.
|
||||
*/
|
||||
async function unsubscribe(req, res) {
|
||||
const claim = unsubscribeToken.verify(req.params.token)
|
||||
if (claim) {
|
||||
try {
|
||||
await teamPrefs.mute(claim.userId, claim.teamId)
|
||||
} catch (err) {
|
||||
// Logged, not surfaced. A failed write here is worth an operator's
|
||||
// attention and is not worth telling an anonymous caller about — and a 500
|
||||
// would make a mail client retry a request it should not repeat.
|
||||
log.error('unsubscribe', err)
|
||||
}
|
||||
}
|
||||
return res.json({ ok: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET on the same path — for a mail client that shows the `List-Unsubscribe` URL
|
||||
* as a link and has no one-click support.
|
||||
*
|
||||
* Redirects to the site's own page rather than acting, because a GET must not
|
||||
* mutate: a link prefetcher or a mail client's link scanner would otherwise
|
||||
* silently mute Teams nobody asked to leave. The page it lands on does the POST
|
||||
* once a human is looking at it.
|
||||
*/
|
||||
function unsubscribeLanding(req, res) {
|
||||
const base = (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
return res.redirect(302, `${base}/unsubscribe/${encodeURIComponent(req.params.token)}`)
|
||||
}
|
||||
// ── One-click unsubscribe: the legacy path ─────────────────────────────────
|
||||
//
|
||||
// The handlers moved to `engagement.controller.js` in ENGAGEMENT.md Phase 6,
|
||||
// because what a token names is a channel and a scope and a scope is not always a
|
||||
// Team. **This path did NOT move**, and cannot: every Team notification sent
|
||||
// before that phase carries `/public/teams/unsubscribe/<token>` 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.
|
||||
//
|
||||
// Re-exported rather than reimplemented, so the two paths cannot drift into
|
||||
// meaning different things. A v1 token arriving here reads as
|
||||
// `{ channel: 'email', scopeKey: 'team:<id>' }` — see unsubscribeToken's header.
|
||||
const { unsubscribe, unsubscribeLanding } = require('./engagement.controller')
|
||||
|
||||
module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity, unsubscribe, unsubscribeLanding }
|
||||
|
||||
@@ -90,13 +90,20 @@ teamsRouter.get(
|
||||
ctrl.getActivity,
|
||||
)
|
||||
|
||||
// ── One-click unsubscribe (TEAMS.md §6.4) ──────────────────────────────────
|
||||
// ── One-click unsubscribe — the LEGACY path (TEAMS.md §6.4) ────────────────
|
||||
//
|
||||
// The canonical pair now lives at `/public/engagement/unsubscribe/:token`
|
||||
// (ENGAGEMENT.md Phase 6). These two stay, permanently, and hand straight to the
|
||||
// same handlers: mail sent before that phase carries this path in its
|
||||
// `List-Unsubscribe` header, and a route that moves is a person who cannot
|
||||
// unsubscribe.
|
||||
//
|
||||
// Declared last, and the shadowing question is worth answering rather than
|
||||
// assuming: these are two segments, so the one-segment '/:slug' cannot take them,
|
||||
// and the two-segment '/:slug/members' and '/:slug/activity' both pin a LITERAL
|
||||
// second segment. Only a token spelled exactly "members" or "activity" could
|
||||
// collide, and a token is `<v>.<uid>.<tid>.<mac>`.
|
||||
// collide, and a token is `<v>.<uid>.<tid>.<mac>` (v1) or
|
||||
// `<v>.<uid>.<channel>.<scope>.<mac>` (v2).
|
||||
//
|
||||
// No `siteMode`, unlike every other route in this file. An unsubscribe has to work
|
||||
// while the site is in maintenance: the mail that carried the link went out before
|
||||
@@ -105,8 +112,8 @@ teamsRouter.get(
|
||||
teamsRouter.post(
|
||||
'/unsubscribe/:token',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'Unsubscribe from one Team’s notification emails'
|
||||
// #swagger.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.'
|
||||
// #swagger.summary = 'Unsubscribe from one Team’s notification emails (legacy path)'
|
||||
// #swagger.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 Team’s 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.'
|
||||
// #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" } } } } */
|
||||
|
||||
Reference in New Issue
Block a user