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>
123 lines
5.8 KiB
JavaScript
123 lines
5.8 KiB
JavaScript
// ── Scoped preferences: "this channel, for this one Team" ──────────────────
|
|
//
|
|
// ENGAGEMENT.md Phase 6, decision 4. `notification_channel_prefs` is keyed
|
|
// (user, stream, channel) and has no scope column; `team_notification_prefs` is
|
|
// keyed (user, Team) and is the preference people actually hold today — someone
|
|
// in six Teams silences one. Migrating the second into the first would mean a
|
|
// live migration of user data, a wire-shape change on two clients, and the loss
|
|
// of the granularity in between. The org lead's decision was to keep the Team
|
|
// table and have the engine consult it; this file is the seam that lets it,
|
|
// without core's engine learning what a Team is.
|
|
//
|
|
// A registrant claims a scope PREFIX — the part of a scope key before the colon,
|
|
// `team` in `team:12` — and answers, for a set of users and one channel, what
|
|
// that scope says their mode is.
|
|
//
|
|
// **Where a scope answers, its answer REPLACES the stream-level preference; it
|
|
// does not intersect with it.** The decision was phrased as "a suppression below
|
|
// the channel preference", and building it showed that reading is the one that
|
|
// cannot ship: `notification_channel_prefs` holds a row only where a user has
|
|
// expressed something, absence means the channel's `defaultMode`, and email's is
|
|
// `off`. Nobody has ever expressed a stream-level opinion about `team.forum.post`
|
|
// — the screen that would let them is Phase 3's and the preference predates it —
|
|
// so intersecting would resolve every existing Team-email subscriber to `off` and
|
|
// silence the entire live pipeline on the deploy that migrated it. That is the
|
|
// G22 failure mode with a different cause. Replacement keeps today's behaviour
|
|
// byte-for-byte: for a Team-scoped event, `team_notification_prefs` is the
|
|
// preference, exactly as it has been since Teams shipped.
|
|
//
|
|
// The cost, stated so nobody has to rediscover it: a user cannot turn Team email
|
|
// off for every Team at once from the channels screen. That control lives on the
|
|
// per-Team screen, which is where it has always lived and where the unsubscribe
|
|
// link points.
|
|
//
|
|
// Nothing here caches. A preference read is one indexed query per (event,
|
|
// channel), against a table the user can change between two events.
|
|
|
|
const log = require('../utils/logger')('engagement')
|
|
|
|
// prefix → provider
|
|
const providers = new Map()
|
|
|
|
const PREFIX_RE = /^[a-z][a-z0-9_-]*$/
|
|
|
|
/**
|
|
* Parse a scope key into its prefix and id. `''` and anything malformed are
|
|
* `null` — an unparseable scope must read as "no scope", never as some other
|
|
* scope's.
|
|
*
|
|
* @returns {{ prefix: string, id: string }|null}
|
|
*/
|
|
function parse(scopeKey) {
|
|
const raw = String(scopeKey || '')
|
|
const at = raw.indexOf(':')
|
|
if (at < 1 || at === raw.length - 1) return null
|
|
const prefix = raw.slice(0, at)
|
|
if (!PREFIX_RE.test(prefix)) return null
|
|
return { prefix, id: raw.slice(at + 1) }
|
|
}
|
|
|
|
/**
|
|
* Register a scope-preference provider.
|
|
*
|
|
* Validate-then-commit, the same discipline the transport and channel registries
|
|
* use: every check runs before the map is touched.
|
|
*
|
|
* @param {object} def
|
|
* @param {string} def.prefix the scope-key prefix this provider owns, e.g. 'team'
|
|
* @param {string} def.label operator-facing, for the send log and admin copy
|
|
* @param {(userIds: number[], channel: string, scopeId: string) => Promise<Map<number, string>>} def.modesFor
|
|
* A mode per user for the users this scope has an opinion about. A user
|
|
* left OUT of the map defers to the stream-level preference; a user in it
|
|
* is answered by the scope. Must not throw — see `resolve`.
|
|
*/
|
|
function registerScopePreference(def) {
|
|
if (!def || typeof def !== 'object') throw new Error('registerScopePreference: definition required')
|
|
const { prefix, label, modesFor } = def
|
|
if (typeof prefix !== 'string' || !PREFIX_RE.test(prefix)) {
|
|
throw new Error(`registerScopePreference: invalid prefix ${JSON.stringify(prefix)}`)
|
|
}
|
|
if (providers.has(prefix)) throw new Error(`registerScopePreference: ${prefix} is already registered`)
|
|
if (typeof label !== 'string' || !label) throw new Error(`registerScopePreference(${prefix}): label required`)
|
|
if (typeof modesFor !== 'function') throw new Error(`registerScopePreference(${prefix}): modesFor required`)
|
|
providers.set(prefix, { prefix, label, modesFor })
|
|
return prefix
|
|
}
|
|
|
|
/**
|
|
* What does this scope say about these users on this channel?
|
|
*
|
|
* @returns {Promise<Map<number, string>>} empty when the scope is absent,
|
|
* unparseable, or owned by nobody — all three of which mean "this event
|
|
* is not scoped as far as preferences are concerned", which is the right
|
|
* answer for a module whose scope provider has been uninstalled.
|
|
*/
|
|
async function resolve(userIds, channel, scopeKey) {
|
|
const parsed = parse(scopeKey)
|
|
if (!parsed || !userIds.length) return new Map()
|
|
const provider = providers.get(parsed.prefix)
|
|
if (!provider) return new Map()
|
|
try {
|
|
const modes = await provider.modesFor(userIds, channel, parsed.id)
|
|
return modes instanceof Map ? modes : new Map()
|
|
} catch (err) {
|
|
// **Fails OPEN, and that is the uncomfortable choice made deliberately.** A
|
|
// provider that throws leaves the stream-level preference in charge, which
|
|
// for every core channel is `off` — so the practical effect of a failure is
|
|
// that nothing is sent, not that everybody is mailed. Failing closed by
|
|
// refusing the whole event would instead drop an IDOC warning because a Team
|
|
// preference query timed out.
|
|
log.error('scope preference lookup failed', { scope: scopeKey, channel, message: err.message })
|
|
return new Map()
|
|
}
|
|
}
|
|
|
|
const has = (prefix) => providers.has(prefix)
|
|
|
|
// Test-only: the registry is module-level state.
|
|
function _reset() {
|
|
providers.clear()
|
|
}
|
|
|
|
module.exports = { registerScopePreference, resolve, parse, has, _reset }
|