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:
@@ -56,7 +56,7 @@ const STALE_MS = 15 * 60 * 1000
|
||||
/**
|
||||
* Deliver one claimed row.
|
||||
*
|
||||
* @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string }}
|
||||
* @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string, addressHash?: string }}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
const channel = channels.get(row.channel)
|
||||
@@ -71,9 +71,17 @@ async function deliver(row) {
|
||||
}
|
||||
try {
|
||||
const result = await channel.deliver(row)
|
||||
if (result && result.ok) return { outcome: 'sent', transport: result.transport, detail: result.detail }
|
||||
if (result && result.retry) return { outcome: 'retry', detail: result.detail || 'transient failure' }
|
||||
return { outcome: 'terminal', detail: (result && result.detail) || 'delivery refused' }
|
||||
// `addressHash` rides on every outcome, success or not: a bounce (Phase 9)
|
||||
// arrives with an address and has to find the row it belongs to, and the rows
|
||||
// worth correlating include the ones that already failed once.
|
||||
const hash = (result && result.addressHash) || undefined
|
||||
if (result && result.ok) {
|
||||
return { outcome: 'sent', transport: result.transport, detail: result.detail, addressHash: hash }
|
||||
}
|
||||
if (result && result.retry) {
|
||||
return { outcome: 'retry', detail: result.detail || 'transient failure', addressHash: hash }
|
||||
}
|
||||
return { outcome: 'terminal', detail: (result && result.detail) || 'delivery refused', addressHash: hash }
|
||||
} catch (err) {
|
||||
// A channel shouldn't throw, but if one does it is a transient failure
|
||||
// rather than a crashed tick - announceWorker's posture with its legs.
|
||||
@@ -111,6 +119,7 @@ async function processRow(row, now = new Date(), deliverFn = deliver) {
|
||||
user_id: row.user_id,
|
||||
channel: row.channel,
|
||||
transport: result.transport ?? null,
|
||||
address_hash: result.addressHash ?? null,
|
||||
status,
|
||||
detail: result.detail ?? null,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user