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:
@@ -16,11 +16,11 @@
|
||||
// The caller must not send. Falling back to the rule's plain `audience`
|
||||
// column would reach a different population than the one composed (§5.1a
|
||||
// rule 4), which is the failure mode this whole design exists to avoid.
|
||||
// 3. `members` as a PLAIN audience resolves to nobody. It is the ceiling for
|
||||
// "a module-declared list", and without a segment there is no list - core
|
||||
// 3. `members` resolves to nobody unless something NAMED the list: a segment, or
|
||||
// (from Phase 6) an event carrying its own access-checked recipient set. Core
|
||||
// knows no game vocabulary and cannot guess which members were meant. A rule
|
||||
// saved that way is inert and visible as such, rather than quietly falling
|
||||
// back to something wider.
|
||||
// with neither is inert and visible as such, rather than quietly falling back
|
||||
// to something wider.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const channels = require('./channels')
|
||||
@@ -104,13 +104,36 @@ async function resolveForRule(rule, event) {
|
||||
case 'authenticated':
|
||||
case 'everyone':
|
||||
return { userIds: await recipients.active(), ceiling: rule.audience, dormant: false, reason: null }
|
||||
case 'members':
|
||||
case 'members': {
|
||||
// **The event may name its own list, and Phase 6 is why that exists.**
|
||||
// `members` is the ceiling for "a module-declared list", and until this
|
||||
// phase the only way to name one was a segment — an operator-composed tree
|
||||
// over audiences with CONSTANT params. That cannot express "the members of
|
||||
// the Team this particular post was in": the list is different for every
|
||||
// firing, and nothing in a saved segment reads the event.
|
||||
//
|
||||
// So an emitter that has already computed an access-checked recipient set
|
||||
// hands it over on the envelope, and this is where it is used. It is not a
|
||||
// bypass of anything: the set is still filtered through `users.status`
|
||||
// below, and the ceiling returned is still `members`, so the G24 re-check
|
||||
// in the engine still refuses a rule whose trigger has since narrowed.
|
||||
// What it removes is core having to guess a game's membership vocabulary —
|
||||
// the thing this case's original comment said it could not do.
|
||||
if (Array.isArray(event.recipientUserIds) && event.recipientUserIds.length) {
|
||||
return {
|
||||
userIds: await recipients.filterActive(event.recipientUserIds),
|
||||
ceiling: 'members',
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
userIds: [],
|
||||
ceiling: 'members',
|
||||
dormant: false,
|
||||
reason: 'a "members" audience needs a segment naming which list',
|
||||
reason: 'a "members" audience needs a segment naming which list, or an event that carries one',
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Fails closed on an audience name the lattice does not know - the same
|
||||
// posture `ceilings.permits` takes, and for the same reason.
|
||||
|
||||
Reference in New Issue
Block a user