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:
@@ -263,16 +263,39 @@ test('listPrefs names a Team by its display-name override when staff set one', a
|
||||
|
||||
// ── 6. The unsubscribe token ───────────────────────────────────────────────
|
||||
|
||||
test('a token verifies for exactly the pair it was signed for', () => {
|
||||
const token = unsubscribeToken.sign(10, 1)
|
||||
assert.deepEqual(unsubscribeToken.verify(token), { userId: 10, teamId: 1 })
|
||||
test('a v2 token verifies for exactly the channel and scope it was signed for', () => {
|
||||
const token = unsubscribeToken.sign(10, 'email', 'team:1')
|
||||
assert.deepEqual(unsubscribeToken.verify(token), {
|
||||
userId: 10, channel: 'email', scopeKey: 'team:1', version: 2,
|
||||
})
|
||||
})
|
||||
|
||||
test('editing the ids in a token invalidates it — the mac covers them', () => {
|
||||
const token = unsubscribeToken.sign(10, 1)
|
||||
const [v, uid, tid, mac] = token.split('.')
|
||||
assert.equal(unsubscribeToken.verify(`${v}.99.${tid}.${mac}`), null)
|
||||
assert.equal(unsubscribeToken.verify(`${v}.${uid}.99.${mac}`), null)
|
||||
// The whole point of keeping v1: a link in a mailbox from before Phase 6 must
|
||||
// still work. It reads as the email channel because an email is the only place a
|
||||
// v1 token can ever have been.
|
||||
test('a v1 token still verifies, and reads as the email channel for that Team', () => {
|
||||
const legacy = unsubscribeToken.signLegacy(10, 1)
|
||||
assert.deepEqual(unsubscribeToken.verify(legacy), {
|
||||
userId: 10, channel: 'email', scopeKey: 'team:1', version: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test('editing the fields in a token invalidates it — the mac covers them', () => {
|
||||
const [v, uid, channel, scope, mac] = unsubscribeToken.sign(10, 'email', 'team:1').split('.')
|
||||
assert.equal(unsubscribeToken.verify(`${v}.99.${channel}.${scope}.${mac}`), null)
|
||||
assert.equal(unsubscribeToken.verify(`${v}.${uid}.${channel}.team:99.${mac}`), null)
|
||||
assert.equal(unsubscribeToken.verify(`${v}.${uid}.push.${scope}.${mac}`), null)
|
||||
|
||||
const [lv, luid, ltid, lmac] = unsubscribeToken.signLegacy(10, 1).split('.')
|
||||
assert.equal(unsubscribeToken.verify(`${lv}.99.${ltid}.${lmac}`), null)
|
||||
assert.equal(unsubscribeToken.verify(`${lv}.${luid}.99.${lmac}`), null)
|
||||
})
|
||||
|
||||
// A channel id may legally contain a dot (`discord.dm`, §3.1's own example) and
|
||||
// the token format's separator is a dot. Refused at signing rather than signed
|
||||
// into something that verifies as a different channel.
|
||||
test('a channel id the format cannot carry is refused at signing, not mangled', () => {
|
||||
assert.throws(() => unsubscribeToken.sign(10, 'discord.dm', 'team:1'), /cannot be carried/)
|
||||
})
|
||||
|
||||
test('a garbage token and a well-formed forgery both verify as null', () => {
|
||||
@@ -283,7 +306,7 @@ test('a garbage token and a well-formed forgery both verify as null', () => {
|
||||
})
|
||||
|
||||
test('a version bump is what invalidates every outstanding link at once', () => {
|
||||
const token = unsubscribeToken.sign(10, 1)
|
||||
const [, uid, tid, mac] = token.split('.')
|
||||
assert.equal(unsubscribeToken.verify(`${unsubscribeToken.VERSION + 1}.${uid}.${tid}.${mac}`), null)
|
||||
const [, uid, channel, scope, mac] = unsubscribeToken.sign(10, 'email', 'team:1').split('.')
|
||||
const next = unsubscribeToken.VERSION + 1
|
||||
assert.equal(unsubscribeToken.verify(`${next}.${uid}.${channel}.${scope}.${mac}`), null)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user