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:
@@ -22,6 +22,7 @@
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const engine = require('../engagement/engine')
|
||||
const scopedPrefs = require('../engagement/scopedPrefs')
|
||||
const createLogger = require('./logger')
|
||||
|
||||
const log = createLogger('engagement')
|
||||
@@ -39,6 +40,14 @@ const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
|
||||
// to prevent.
|
||||
const DEDUPE_KEY_MAX = 190
|
||||
|
||||
// engagement_outbox.scope_key is VARCHAR(190), same reasoning as above.
|
||||
const SCOPE_KEY_MAX = 190
|
||||
|
||||
// An emitter asserting an audience asserts a BOUNDED one. `MAX_AUDIENCE` (5000)
|
||||
// already caps what the engine will load from a query; this is the matching bound
|
||||
// on a list a caller built itself, and it is the same number for the same reason.
|
||||
const RECIPIENTS_MAX = 5000
|
||||
|
||||
const isProd = () => process.env.NODE_ENV === 'production'
|
||||
|
||||
/** Coerce and check one declared variable. Returns `{ value }` or `{ error }`. */
|
||||
@@ -156,7 +165,7 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
return fail(`"${triggerId}" is kind "${declaration.kind}" and is not emitted directly`)
|
||||
}
|
||||
|
||||
const { subject, data, ownerUserId, dedupeKey, occurredAt } = envelope || {}
|
||||
const { subject, data, ownerUserId, dedupeKey, occurredAt, scopeKey, recipientUserIds } = envelope || {}
|
||||
|
||||
const payload = validatePayload(declaration, data)
|
||||
if (!payload.ok) return fail(`payload for "${triggerId}" is invalid`, payload.errors.join('; '))
|
||||
@@ -181,6 +190,41 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// The scope this event is ABOUT: `team:12`, or absent. Distinct from `subject`,
|
||||
// which is what a cooldown counts — see the engine's enqueue. It is a stable
|
||||
// identifier because an unsubscribe token is signed over it and sits in a
|
||||
// mailbox for months; a display name would orphan the link on the first rename.
|
||||
let resolvedScope = null
|
||||
if (scopeKey !== undefined && scopeKey !== null) {
|
||||
if (typeof scopeKey !== 'string' || !scopedPrefs.parse(scopeKey)) {
|
||||
return fail('scopeKey must be a string of the form "<kind>:<id>"')
|
||||
}
|
||||
if (scopeKey.length > SCOPE_KEY_MAX) return fail(`scopeKey must be at most ${SCOPE_KEY_MAX} characters`)
|
||||
resolvedScope = scopeKey
|
||||
}
|
||||
|
||||
// **The audience this particular firing is about** (Phase 6, decision 2). An
|
||||
// emitter that has already computed an access-checked recipient set — the Team
|
||||
// fan-out is the case that forced it — hands it over here, and a rule whose
|
||||
// audience is `members` resolves to it. It is a NARROWING input, not a
|
||||
// widening one: `audiences.resolveForRule` still filters it through
|
||||
// `users.status`, the ceiling is still `members`, and the G24 check still runs.
|
||||
// A rule with any other audience ignores it entirely.
|
||||
let resolvedRecipients = null
|
||||
if (recipientUserIds !== undefined && recipientUserIds !== null) {
|
||||
if (!Array.isArray(recipientUserIds)) return fail('recipientUserIds must be an array')
|
||||
if (recipientUserIds.length > RECIPIENTS_MAX) {
|
||||
// Bounded here rather than at the query, because the bound is about what an
|
||||
// emitter may assert. `MAX_AUDIENCE` already caps what the engine will load;
|
||||
// this stops a caller building a list that large in the first place.
|
||||
return fail(`recipientUserIds must hold at most ${RECIPIENTS_MAX} ids`)
|
||||
}
|
||||
if (!recipientUserIds.every((n) => Number.isInteger(n) && n > 0)) {
|
||||
return fail('recipientUserIds must be positive integers')
|
||||
}
|
||||
resolvedRecipients = [...new Set(recipientUserIds)]
|
||||
}
|
||||
|
||||
if (dedupeKey !== undefined && dedupeKey !== null) {
|
||||
if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) {
|
||||
return fail(`dedupeKey must be a string of 1-${DEDUPE_KEY_MAX} characters`)
|
||||
@@ -200,6 +244,8 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
version: declaration.version,
|
||||
subject: resolvedSubject,
|
||||
ownerUserId: ownerUserId === undefined ? null : ownerUserId,
|
||||
scopeKey: resolvedScope,
|
||||
recipientUserIds: resolvedRecipients,
|
||||
dedupeKey: dedupeKey === undefined ? null : dedupeKey,
|
||||
occurredAt: at.toISOString(),
|
||||
data: payload.data,
|
||||
@@ -233,4 +279,4 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
return { ok: true, event }
|
||||
}
|
||||
|
||||
module.exports = { emit, validatePayload, RELATIVE_URL, DEDUPE_KEY_MAX }
|
||||
module.exports = { emit, validatePayload, RELATIVE_URL, DEDUPE_KEY_MAX, SCOPE_KEY_MAX, RECIPIENTS_MAX }
|
||||
|
||||
Reference in New Issue
Block a user