feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 11m9s

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:
2026-08-29 20:11:54 -05:00
parent e2dad3104f
commit 065bec7ad8
44 changed files with 2531 additions and 428 deletions

View File

@@ -37,6 +37,11 @@ const configured = (over = {}) => ({
let sent
let transportCfg
// An already-rendered body, which is what `sendNotification` takes: the email
// channel renders the template and this file's job is only the transport and the
// headers (ENGAGEMENT.md Phase 6).
const RENDERED = { subject: 'A subject', html: '<p>body</p>', text: 'body' }
beforeEach(() => {
sent = null
transportCfg = null
@@ -78,10 +83,15 @@ test('unconfigured → password reset returns NOT_CONFIGURED (caller still answe
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
})
test('unconfigured → team notification returns NOT_CONFIGURED and never throws', async () => {
// Unconfigured is RETRYABLE for this one sender, and it is the only one where
// that is the right answer: an operator halfway through typing SMTP credentials
// should find the outbox drains once they finish, not a backlog of rows the
// worker gave up on five minutes in.
test('unconfigured → an engagement send is a retryable failure, never a throw', async () => {
emailConfig.getWithSecret = async () => null
const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] })
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
const r = await mailer.sendNotification({ to: 'a@b.com', rendered: RENDERED })
assert.equal(r.ok, false)
assert.equal(r.retry, true)
})
test('unconfigured → only sendTest throws, because only sendTest has an admin waiting', async () => {
@@ -145,8 +155,8 @@ test('an incomplete credential is unconfigured, not a crash', async () => {
test('a stored transport id that is not registered degrades, it does not throw', async () => {
emailConfig.getWithSecret = async () => configured({ transport: 'mailgun' })
const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] })
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
const r = await mailer.sendNotification({ to: 'a@b.com', rendered: RENDERED })
assert.equal(r.ok, false)
})
// ── failures ────────────────────────────────────────────────────────────────
@@ -180,11 +190,58 @@ test('a rejected sender is diagnosed by name — the failure mode SMTP introduce
assert.match(recorded.statusDetail, /SPF\/DMARC/)
})
test('a team notification failure is swallowed, never thrown', async () => {
test('an engagement send failure is swallowed and classified, never thrown', async () => {
emailConfig.recordStatus = async () => {}
nodemailer.createTransport = () => ({ sendMail: async () => { throw new Error('relay down') } })
emailConfig.getWithSecret = async () => configured()
const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] })
assert.deepEqual(r, { sent: false, reason: 'SEND_FAILED' })
const r = await mailer.sendNotification({ to: 'a@b.com', rendered: RENDERED })
assert.equal(r.ok, false)
// Transient: a relay that is down now may not be in five minutes. The worker's
// flat backoff is what this classification feeds.
assert.equal(r.retry, true)
})
// The other half of the classification, and the one that costs something to get
// wrong in the safe direction: a rejected recipient is not going to be accepted
// on the fifth attempt, and retrying it is four more chances to be seen as a
// sender who ignores bounces.
test('a permanent SMTP refusal is classified terminal, not retried', async () => {
emailConfig.recordStatus = async () => {}
nodemailer.createTransport = () => ({
sendMail: async () => {
const err = new Error('550 No such user')
err.responseCode = 550
throw err
},
})
emailConfig.getWithSecret = async () => configured()
const r = await mailer.sendNotification({ to: 'a@b.com', rendered: RENDERED })
assert.equal(r.ok, false)
assert.equal(r.retry, false)
})
// RFC 8058 one-click is only one-click when BOTH headers are present, and the
// header url has to be the API endpoint rather than the page: a client POSTs to
// it without rendering anything.
test('both List-Unsubscribe headers ride along, and the header carries the API url', async () => {
emailConfig.getWithSecret = async () => configured()
const r = await mailer.sendNotification({
to: 'a@b.com',
rendered: RENDERED,
unsubscribeUrl: 'https://x.test/unsubscribe/tok',
unsubscribeApiUrl: 'https://x.test/api/v1/public/engagement/unsubscribe/tok',
})
assert.equal(r.ok, true)
assert.equal(sent.headers['List-Unsubscribe'], '<https://x.test/api/v1/public/engagement/unsubscribe/tok>')
assert.equal(sent.headers['List-Unsubscribe-Post'], 'List-Unsubscribe=One-Click')
})
test('an engagement send is multipart — the html and the text both go out', async () => {
emailConfig.getWithSecret = async () => configured()
await mailer.sendNotification({ to: 'a@b.com', rendered: RENDERED })
assert.equal(sent.subject, 'A subject')
assert.equal(sent.html, '<p>body</p>')
assert.equal(sent.text, 'body')
})