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

@@ -182,6 +182,29 @@ test('a variable carrying a javascript: url never becomes an href', () => {
assert.match(out.html, /Press me/) // inert, but not silently vanished
})
// **A defect until Phase 6, and the phase that put a rule-driven variable in a
// button is the one that could see it.** `email.image` and `email.itemList` both
// absolutize; `email.button` did not. A trigger's `url` variables are validated
// site-RELATIVE by construction (`engagementEmit.RELATIVE_URL`), so every
// rule-driven CTA interpolated to `/guilds/x` — a path a mail client has no
// origin to resolve, i.e. a dead link in every notification the engine sends.
test('a relative url in a button is absolutized, in both parts', () => {
const ctx = emailBlocks.buildContext({ values: { link: '/guilds/silver-anvil?thread=7' }, baseUrl: BASE })
const block = { id: 'b', type: 'email.button', props: { label: 'Read it', url: '{{link}}' } }
const out = emailBlocks.renderBlocks([block], ctx)
assert.equal(out.html.includes(`href="${BASE}/guilds/silver-anvil?thread=7"`), true)
assert.equal(out.text.includes(`${BASE}/guilds/silver-anvil?thread=7`), true)
})
test('an absolute url in a button is left exactly as it is', () => {
const ctx = emailBlocks.buildContext({ values: { link: 'https://elsewhere.test/x' }, baseUrl: BASE })
const out = emailBlocks.renderBlocks(
[{ id: 'b', type: 'email.button', props: { label: 'Go', url: '{{link}}' } }],
ctx,
)
assert.match(out.html, /href="https:\/\/elsewhere\.test\/x"/)
})
test('a literal unsafe url is refused at save, and a tokened one is allowed through', () => {
const bad = emailBlocks.validateEmailBlocks([
{ id: 'b', type: 'email.button', props: { label: 'x', url: 'javascript:alert(1)' } },

View File

@@ -0,0 +1,408 @@
// ── The email channel on the engine (ENGAGEMENT.md Phase 6) ────────────────
//
// The phase's own acceptance criteria, plus the four things building it showed
// were worth pinning:
//
// • a scoped preference is what decides a Team-scoped event, and it REPLACES
// the stream-level one — intersecting would silence every existing subscriber
// • the structural projection fills only what the payload did not
// • a `members` audience resolves to the set the EVENT carried
// • core's four seeded rules exist, are all disabled, and are seeded once
//
// Point the DB at a closed port before requiring anything: the registries reach
// utils/discordAnnounce, which builds the pool at require time.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const channels = require('../src/engagement/channels')
const scopedPrefs = require('../src/engagement/scopedPrefs')
const projection = require('../src/engagement/projection')
const audiences = require('../src/engagement/audiences')
const engine = require('../src/engagement/engine')
const emailChannel = require('../src/engagement/emailChannel')
const coreRules = require('../src/engagement/coreRules')
const templates = require('../src/engagement/templates')
const mailer = require('../src/utils/mailer')
const unsubscribeToken = require('../src/utils/unsubscribeToken')
const engagementEmit = require('../src/utils/engagementEmit')
const rulesDb = require('../src/model/engagement/engagementRules.db')
const recipients = require('../src/model/engagement/engagementRecipients.db')
const settingsDb = require('../src/model/settings/settings.db')
const teamNotifyModel = require('../src/model/teams/teamNotify.model')
const unsubCtrl = require('../src/router/v1/public/engagement.controller')
const db = require('../src/utils/db')
// Requiring this is what registers core's channels, transports and the `team`
// scope provider — the one door (engagement/index.js's header). It does NOT
// register core's TRIGGERS: those come from `registries.registerCore()`, which
// app.js calls, and the split is deliberate — a trigger is a module-facing
// declaration and a channel is an internal sink.
require('../src/engagement')
registries.registerCore()
after(() => db.close())
const saved = new Map()
function patch(mod, name, fn) {
if (!saved.has(mod)) saved.set(mod, new Map())
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
mod[name] = fn
}
function restore() {
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
saved.clear()
}
const TRIGGER = 'team.forum.post'
let world
beforeEach(() => {
world = { mails: [], teamPrefs: new Map(), storedModes: new Map(), inserted: [], settings: new Map() }
patch(mailer, 'sendNotification', async (msg) => {
world.mails.push(msg)
return { ok: true, transport: 'smtp' }
})
patch(templates, 'renderByKey', async (key, values) => ({
subject: `[${key}] ${values.title || ''}`,
html: '<p>x</p>',
text: 'x',
missing: [],
values,
}))
patch(recipients, 'addressFor', async (userId) => ({ address: `u${userId}@example.test` }))
patch(recipients, 'filterActive', async (ids) => ids)
patch(recipients, 'storedModes', async () => world.storedModes)
patch(teamNotifyModel, 'prefsForTeam', async (userIds, teamId) =>
userIds
.map((id) => world.teamPrefs.get(`${id}|${teamId}`))
.filter(Boolean))
patch(rulesDb, 'getById', async () => ({
id: 1,
trigger_id: TRIGGER,
template_keys: { email: 'notify.team-post' },
}))
})
afterEach(restore)
const outboxRow = (over = {}) => ({
id: 1,
rule_id: 1,
trigger_id: TRIGGER,
user_id: 11,
channel: 'email',
subject_key: 'The Silver Hand',
scope_key: 'team:1',
payload: { teamName: 'The Silver Hand', authorName: 'Ten', threadTitle: 'Raid', postUrl: '/g/1?thread=7' },
...over,
})
// ── deliver ────────────────────────────────────────────────────────────────
test('a delivered row renders its rules template and sends to the users address', async () => {
const result = await emailChannel.deliver(outboxRow())
assert.equal(result.ok, true)
assert.equal(world.mails[0].to, 'u11@example.test')
assert.match(world.mails[0].rendered.subject, /^\[notify\.team-post\]/)
})
test('a rule naming no template falls back to the generic one, which is what makes a new trigger mailable', async () => {
patch(rulesDb, 'getById', async () => ({ id: 1, trigger_id: TRIGGER, template_keys: {} }))
await emailChannel.deliver(outboxRow())
assert.match(world.mails[0].rendered.subject, /^\[notify\.event\]/)
})
// Terminal, not retryable. Retrying does not give somebody an address, and a
// banned account will not be un-banned by a five-minute backoff.
test('a user with no deliverable address is a terminal failure, not a retry', async () => {
patch(recipients, 'addressFor', async () => null)
const result = await emailChannel.deliver(outboxRow())
assert.equal(result.ok, false)
assert.equal(result.retry, undefined)
assert.equal(world.mails.length, 0)
})
// A throw would be read by the worker as a transient failure and retried five
// times, so an unrenderable template would become five identical rows in the send
// log instead of one honest terminal one.
test('deliver never throws — a render failure is classified, not propagated', async () => {
patch(templates, 'renderByKey', async () => { throw new Error('blocks are broken') })
const result = await emailChannel.deliver(outboxRow())
assert.equal(result.ok, false)
assert.match(result.detail, /blocks are broken/)
})
test('the send log gets a hash of the address and never the address', async () => {
const result = await emailChannel.deliver(outboxRow())
assert.match(result.addressHash, /^[0-9a-f]{64}$/)
assert.equal(result.addressHash.includes('@'), false)
})
// A bounce arrives with an address, not with a spelling. Two spellings of one
// mailbox must hash to one row or the correlation Phase 9 needs cannot be made.
test('the address hash is case-folded, so a bounce can be correlated', async () => {
const a = await emailChannel.deliver(outboxRow())
patch(recipients, 'addressFor', async () => ({ address: ' U11@Example.Test ' }))
const b = await emailChannel.deliver(outboxRow())
assert.equal(a.addressHash, b.addressHash)
})
// ── The unsubscribe link ───────────────────────────────────────────────────
test('every mail carries both unsubscribe urls, and they are not the same url', async () => {
await emailChannel.deliver(outboxRow())
const { unsubscribeUrl, unsubscribeApiUrl } = world.mails[0]
assert.notEqual(unsubscribeUrl, unsubscribeApiUrl)
// The header one has to be an ENDPOINT — a one-click client POSTs to it without
// rendering anything — and the body one has to be a page a human can read first.
assert.match(unsubscribeApiUrl, /\/api\/v1\/public\/engagement\/unsubscribe\//)
assert.match(unsubscribeUrl, /\/unsubscribe\//)
})
test('the links token names the email channel and the events SCOPE, not its subject', async () => {
await emailChannel.deliver(outboxRow())
const token = world.mails[0].unsubscribeApiUrl.split('/').pop()
assert.deepEqual(unsubscribeToken.verify(token), {
userId: 11, channel: 'email', scopeKey: 'team:1', version: 2,
})
// `subject_key` is the Team's NAME, which a rename changes. Signing over it
// would orphan every link in a mailbox the first time staff renamed a guild.
assert.equal(token.includes('Silver'), false)
})
test('a scope the token format cannot carry costs the link, not the mail', async () => {
const result = await emailChannel.deliver(outboxRow({ scope_key: 'NOT A SCOPE' }))
assert.equal(result.ok, true)
assert.equal(world.mails[0].unsubscribeUrl, null)
})
// ── The projection (§4.6.1 property 1) ─────────────────────────────────────
test('the projection fills only what the payload left out', () => {
const declaration = registries.eventTrigger(TRIGGER)
assert.ok(declaration, 'core registers the four Team triggers')
const values = projection.project(TRIGGER, { teamName: 'X', threadTitle: 'Raid', postUrl: '/g/1' })
assert.equal(values.threadTitle, 'Raid') // untouched
assert.equal(values.title, declaration.label) // supplied
assert.equal(values.intro, declaration.description)
assert.equal(values.actionUrl, '/g/1') // the first declared url with a value
})
// `news.post` and `team.announcement` both declare their own `title`. A
// projection that overwrote it would replace a real headline with a category
// label — on the one variable every generic template puts in the subject line.
test('a payload that declares its own title keeps it', () => {
const values = projection.project('team.announcement', { teamName: 'X', title: 'Siege moved' })
assert.equal(values.title, 'Siege moved')
})
test('an unregistered trigger still renders from its snapshot rather than being refused', () => {
const values = projection.project('gone.away', { title: 'kept' })
assert.equal(values.title, 'kept')
assert.deepEqual(values.items, [])
})
// ── The event-carried audience (decision 2) ────────────────────────────────
test('a members audience resolves to the recipient set the event carried', async () => {
const resolved = await audiences.resolveForRule(
{ audience: 'members', audience_segment_id: null },
{ triggerId: TRIGGER, recipientUserIds: [11, 12] },
)
assert.deepEqual(resolved.userIds, [11, 12])
assert.equal(resolved.ceiling, 'members')
assert.equal(resolved.dormant, false)
})
test('a members audience with no carried set and no segment still reaches nobody', async () => {
const resolved = await audiences.resolveForRule(
{ audience: 'members', audience_segment_id: null },
{ triggerId: TRIGGER },
)
assert.deepEqual(resolved.userIds, [])
assert.match(resolved.reason, /needs a segment/)
})
// The carried set is a NARROWING input. It names who the event is about; it does
// not raise what a rule is allowed to reach.
test('a carried audience is still filtered for account status', async () => {
patch(recipients, 'filterActive', async (ids) => ids.filter((id) => id !== 12))
const resolved = await audiences.resolveForRule(
{ audience: 'members', audience_segment_id: null },
{ triggerId: TRIGGER, recipientUserIds: [11, 12] },
)
assert.deepEqual(resolved.userIds, [11])
})
test('and it is still under the triggers G24 ceiling', () => {
// `members` is what the four Team triggers ceiling at, so a carried set can
// never be given `authenticated` by a rule that names one.
assert.equal(audiences.permitted(TRIGGER, 'members'), true)
assert.equal(audiences.permitted(TRIGGER, 'authenticated'), false)
})
test('the emit contract refuses an audience that is not a list of user ids', () => {
const bad = (recipientUserIds) =>
assert.throws(() => engagementEmit.emit('core', TRIGGER, {
data: { teamName: 'X', authorName: 'A', threadTitle: 'T' },
recipientUserIds,
}))
bad('11')
bad([0])
bad([1.5])
bad(new Array(6000).fill(1).map((_, i) => i + 1))
})
// ── Scoped preferences (decision 4) ────────────────────────────────────────
const teamPref = (userId, teamId, over) => world.teamPrefs.set(`${userId}|${teamId}`, {
user_id: userId, muted: 0, email_mode: 'off', ...over,
})
test('a Team-scoped email event is decided by team_notification_prefs', async () => {
teamPref(11, 1, { email_mode: 'immediate' })
teamPref(12, 1, { email_mode: 'off' })
const eligible = await engine.subscribedTo([11, 12], TRIGGER, 'email', 'team:1')
assert.deepEqual(eligible, [11])
})
// **The heart of decision 4.** `notification_channel_prefs` holds a row only
// where a user expressed something, absence means the channel default, and
// email's is `off`. Nobody has ever expressed a stream-level opinion about a Team
// trigger — the screen that would let them is Phase 3's and the preference
// predates it. So intersecting the two would resolve every existing Team-email
// subscriber to `off` and silence the live pipeline on the migrating deploy.
test('the scoped preference REPLACES the stream one — it does not intersect with it', async () => {
teamPref(11, 1, { email_mode: 'immediate' })
world.storedModes = new Map() // no stream-level row: the state of every real user
assert.equal(channels.defaultMode('email'), 'off')
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [11])
})
test('a per-Team mute silences every channel, not only the one that carries content', async () => {
teamPref(11, 1, { muted: 1, email_mode: 'immediate' })
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [])
world.storedModes = new Map([[11, 'instant']])
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'push', 'team:1'), [])
})
test('a scope says nothing about push, so the stream preference decides it', async () => {
teamPref(11, 1, { email_mode: 'off' })
world.storedModes = new Map([[11, 'instant']])
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'push', 'team:1'), [11])
})
test('an unscoped event is decided by the stream preference alone', async () => {
teamPref(11, 1, { email_mode: 'immediate' })
world.storedModes = new Map()
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', null), [])
})
// Fails OPEN, and the practical effect is that nothing is sent rather than that
// everybody is: the stream-level default is `off`. Failing closed would instead
// drop an unrelated IDOC warning because a Team preference query timed out.
test('a scope provider that throws leaves the stream preference in charge', async () => {
patch(teamNotifyModel, 'prefsForTeam', async () => { throw new Error('db is on fire') })
world.storedModes = new Map([[11, 'instant']])
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [11])
})
test('an unparseable or unclaimed scope is "no scope", never some other scope', async () => {
assert.equal(scopedPrefs.parse('team:1').prefix, 'team')
assert.equal(scopedPrefs.parse('team:'), null)
assert.equal(scopedPrefs.parse(':1'), null)
assert.equal(scopedPrefs.parse(''), null)
assert.equal((await scopedPrefs.resolve([11], 'email', 'nosuch:1')).size, 0)
})
// ── The one-click unsubscribe (decision 7) ─────────────────────────────────
test('a v2 email token turns off that Teams email and leaves its push alone', async () => {
const writes = []
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(['email', ...a]))
patch(teamNotifyModel, 'mute', async (...a) => writes.push(['mute', ...a]))
await unsubCtrl.applyClaim({ userId: 11, channel: 'email', scopeKey: 'team:1' })
assert.deepEqual(writes, [['email', 11, 1, 'off']])
})
// The acceptance criterion: a link from a mail sent BEFORE the migration still
// works. It arrives at the old path, verifies as a v1 token, and turns off the
// email it was labelled as turning off.
test('a pre-migration link still unsubscribes, through the same handler', async () => {
const writes = []
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
const legacy = unsubscribeToken.signLegacy(11, 1)
const res = { json: (body) => { res.body = body } }
await unsubCtrl.unsubscribe({ params: { token: legacy } }, res)
assert.deepEqual(res.body, { ok: true })
assert.deepEqual(writes, [[11, 1, 'off']])
})
// An oracle for which (user, scope) pairs exist would be a real disclosure on an
// endpoint with no session behind it.
test('a forged token is answered exactly like a real one', async () => {
const writes = []
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
const res = { json: (body) => { res.body = body } }
await unsubCtrl.unsubscribe({ params: { token: '2.11.email.team:1.AAAAAAAAAAAAAAAAAAAAAA' } }, res)
assert.deepEqual(res.body, { ok: true })
assert.deepEqual(writes, [])
})
test('a GET on the unsubscribe endpoint mutates nothing and lands on the page', () => {
const writes = []
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
let redirected = null
unsubCtrl.unsubscribeLanding(
{ params: { token: unsubscribeToken.sign(11, 'email', 'team:1') } },
{ redirect: (code, url) => { redirected = { code, url } } },
)
assert.equal(redirected.code, 302)
assert.match(redirected.url, /\/unsubscribe\//)
assert.deepEqual(writes, [], 'a link scanner must not be able to unsubscribe anybody')
})
// ── The seeded rules (decision 3) ──────────────────────────────────────────
test('core seeds a rule for each Team trigger, and every one of them is OFF', async () => {
patch(settingsDb, 'get', async () => null)
patch(settingsDb, 'set', async (k, v) => world.settings.set(k, v))
patch(rulesDb, 'insert', async (rule) => { world.inserted.push(rule); return world.inserted.length })
const summary = await coreRules.seedTeamRules()
assert.equal(summary.inserted, 4)
assert.deepEqual(
world.inserted.map((r) => r.trigger_id).sort(),
['team.announcement', 'team.forum.post', 'team.leadership.changed', 'team.member.joined'],
)
// The invariant the org lead chose to honour rather than carve an exception
// into: nothing is seeded on. Team email resumes when an operator switches one
// on, and the release note says so.
assert.equal(world.inserted.every((r) => r.enabled === 0), true)
// `members`, which is what resolves to the carried recipient set. Anything
// wider would be refused by the trigger's own ceiling anyway.
assert.equal(world.inserted.every((r) => r.audience === 'members'), true)
assert.equal(world.inserted.every((r) => r.channels.includes('email')), true)
})
test('every seeded rule names a template that actually exists', () => {
const seeded = new Set(coreRules.RULES.flatMap((r) => Object.values(r.template_keys)))
const shipped = new Set(require('../src/engagement/templateSeeds').SEEDS.map((s) => s.key))
for (const key of seeded) assert.equal(shipped.has(key), true, `${key} is not a shipped template`)
})
// Seeded once, not ensured: an operator who deletes a rule must not find it back
// after a restart, and one they enabled must not be reset to off.
test('a second boot seeds nothing', async () => {
patch(settingsDb, 'get', async () => '2026-08-29T00:00:00.000Z')
patch(rulesDb, 'insert', async () => { throw new Error('must not insert') })
const summary = await coreRules.seedTeamRules()
assert.equal(summary.inserted, 0)
assert.equal(summary.skipped, 4)
})

View File

@@ -475,10 +475,11 @@ test('two sweepers racing one due row: exactly one claim wins', async () => {
// ── The send log ───────────────────────────────────────────────────────────
test('a row whose channel has no deliver() finishes failed, and the send log says why', async () => {
// Phase 4a delivers nothing: `deliver` arrives with email in Phase 6 and the
// inbox in Phase 7. Recording 'sent' would be a lie in the one table whose
// purpose is answering "did they get it".
addRule()
// `inapp`, because as of Phase 6 `email` DOES deliver. The inbox arrives in
// Phase 7, and until then recording 'sent' would be a lie in the one table
// whose purpose is answering "did they get it".
addRule({ channels: ['inapp'] })
optIn(10, 'uo.house.idoc_warning', 'inapp')
await engine.dispatch(event(), T0)
await worker.tick(later(1000))
@@ -545,14 +546,20 @@ test('absence means the CHANNEL default, and all three of core default off', asy
assert.equal(channels.defaultMode('email'), 'off')
})
test("a 'digest' preference still enqueues — batching is the drain's job, not the enqueue's", async () => {
// **This reverses what Phase 4a asserted here**, and the reversal is Phase 6's
// §4.2b decision rather than a change of mind about queues. A digest is
// re-derived from the source tables at send time — that is what makes a hidden
// post absent from it and a user who lost access unreachable by it — so an outbox
// row for a digest recipient would be a second copy of the content with none of
// those properties. Nothing drains it, so nothing writes it.
test("a 'digest' preference does NOT enqueue — the digest re-derives at send time", async () => {
registries._reset()
registerUoTrigger({ ceiling: 'authenticated', audience: 'authenticated' })
optIn(10, 'uo.house.idoc_warning', 'email', 'digest')
addRule({ audience: 'authenticated' })
await engine.dispatch(event(), T0)
assert.equal(outboxRows().length, 1)
assert.equal(outboxRows().length, 0)
})
// ── The hourly ceiling (§7.1 Q3) ───────────────────────────────────────────

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')
})

View File

@@ -143,7 +143,7 @@ test('forums switched off silence the bridge as well as the push', async () => {
const result = await teamNotify.forumPost({
team: TEAM, threadId: 41, threadTitle: 'Siege', type: 'discussion', bodyHtml: '<p>x</p>',
})
assert.deepEqual(result, { push: 0, emails: 0, bridged: false })
assert.deepEqual(result, { push: 0, emitted: false, bridged: false })
assert.equal(sent.length, 0)
})

View File

@@ -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)
})

View File

@@ -1,17 +1,24 @@
// The Team notification fan-out and the digest worker (TEAMS.md §6.2/§6.4).
// The Team notification fan-out and the digest worker (TEAMS.md §6.2/§6.4,
// migrated onto the engagement engine in ENGAGEMENT.md Phase 6).
//
// The layer above teamNotify.test.js: that one asserts WHO a recipient set
// contains, this one asserts what actually happens to them — which stream fires,
// what a mail carries, and the four ways a notification is correctly suppressed.
// what leaves for the engine, and the ways a notification is correctly suppressed.
//
// The suppressions are the point. A notification feature is mostly refusals, and
// each of these is one that would be invisible until it went wrong in production:
// **What Phase 6 changed in this file, and what it deliberately did not.** The
// immediate email is no longer sent from here: `forumPost` emits an event and the
// engine decides. So the assertions about mail bodies moved down to the email
// channel's own tests, and what is asserted here is the ENVELOPE — the recipient
// set, the scope, the payload — because that is now the whole of this file's
// contract with the rest of the system. Every suppression test survives unchanged
// in intent, and each one is a refusal that would be invisible until it went
// wrong in production:
//
// • forums switched off silences forum notifications, including the digest;
// • no email configured means the sink is absent, not broken;
// • no enabled rule means Team email is off (Phase 6, decision 3);
// • a Team's FIRST roster does not wake 155 phones;
// • a failed send does not stamp `last_digest_at`, so the window is retried
// rather than silently skipped.
// • a failed send does not stamp the digest window, so it is retried rather
// than silently skipped.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
@@ -19,8 +26,13 @@ const notify = require('../src/utils/teamNotify')
const digest = require('../src/utils/teamDigestWorker')
const pushDispatch = require('../src/utils/pushDispatch')
const mailer = require('../src/utils/mailer')
const engagementEmit = require('../src/utils/engagementEmit')
const forumSettings = require('../src/model/teams/teamForumSettings.model')
const notifyModel = require('../src/model/teams/teamNotify.model')
const digestDb = require('../src/model/engagement/engagementDigest.db')
const rulesDb = require('../src/model/engagement/engagementRules.db')
const sendsDb = require('../src/model/engagement/engagementSends.db')
const templates = require('../src/engagement/templates')
const registries = require('../src/modules/registries')
const saved = new Map()
@@ -38,27 +50,61 @@ function restore() {
const TEAM = { id: 1, slug: 'silver-hand', name: 'The Silver Hand', external_id: 'g1', display_name_override: null }
let sent // tickles
let mails // emails
// The rule the digest worker's gate looks for. Enabled and naming the email
// channel, which is exactly what core does NOT seed — see the gate's own test.
const EMAIL_RULE = {
id: 4,
trigger_id: 'team.forum.post',
enabled: 1,
channels: ['email'],
template_keys: { email: 'notify.team-post', digest: 'notify.digest' },
}
let sent // tickles
let emitted // envelopes handed to the engine
let mails // rendered messages handed to the transport
let world
function stub({ forumsEnabled = true, emailConfigured = true, recipients = [10, 11], emailRows = [] } = {}) {
function stub({
forumsEnabled = true,
emailConfigured = true,
recipients = [10, 11],
emailRows = [],
sendOk = true,
} = {}) {
sent = []
emitted = []
mails = []
world = { stamped: [] }
world = { stamped: [], logged: [] }
patch(forumSettings, 'forumsEnabled', async () => forumsEnabled)
patch(mailer, 'isConfigured', async () => emailConfigured)
patch(mailer, 'sendTeamNotification', async (msg) => {
patch(mailer, 'sendNotification', async (msg) => {
mails.push(msg)
return { sent: true }
return sendOk ? { ok: true, transport: 'smtp' } : { ok: false, retry: true, detail: 'relay said no' }
})
patch(pushDispatch, 'publishToUsers', async (streamId, payload) => { sent.push({ streamId, ...payload }) })
patch(engagementEmit, 'emit', (owner, triggerId, envelope) => {
emitted.push({ owner, triggerId, ...envelope })
return { ok: true }
})
patch(notifyModel, 'recipientIds', async (teamId, { exclude = [] } = {}) =>
recipients.filter((id) => !exclude.includes(id)))
patch(notifyModel, 'emailRecipients', async (teamId, { exclude = [] } = {}) =>
emailRows.filter((r) => !exclude.includes(r.user_id)))
patch(notifyModel, 'stampDigest', async (userId, teamId, at) => { world.stamped.push({ userId, teamId, at }) })
patch(digestDb, 'stampsFor', async () => new Map())
patch(digestDb, 'stamp', async (userId, channel, scopeKey, at) => {
world.stamped.push({ userId, channel, scopeKey, at })
})
patch(rulesDb, 'enabledForTrigger', async (triggerId) => (triggerId === EMAIL_RULE.trigger_id ? [EMAIL_RULE] : []))
patch(sendsDb, 'record', async (entry) => { world.logged.push(entry) })
patch(templates, 'renderByKey', async (key, values) => ({
subject: `[${key}] ${values.periodLabel || values.title || ''}`,
html: '<p>rendered</p>',
text: 'rendered',
missing: [],
values,
}))
// No module registered: the default in most tests, so the link-building ones
// have to opt in and the absence is exercised rather than assumed.
patch(registries, 'registeredTeamProvider', () => null)
@@ -73,6 +119,9 @@ test('a discussion reply fires team.forum.post; an announcement fires its own st
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
await notify.forumPost({ team: TEAM, threadId: 8, threadTitle: 'Notice', type: 'announcement', authorUserId: 10 })
assert.deepEqual(sent.map((s) => s.streamId), ['team.forum.post', 'team.announcement'])
// The trigger and the stream are the same id under §7.2's one namespace, so
// the event that leaves for the engine names the same thing the tickle did.
assert.deepEqual(emitted.map((e) => e.triggerId), ['team.forum.post', 'team.announcement'])
})
test('the tickle is content-free and refs the thread, never the body', async () => {
@@ -85,9 +134,12 @@ test('the tickle is content-free and refs the thread, never the body', async ()
assert.equal(JSON.stringify(sent[0]).includes('private text'), false)
})
test('the author is not among the tickled', async () => {
test('the author is not among the tickled, nor among the emitted audience', async () => {
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.deepEqual(sent[0].userIds, [11])
// The same exclusion, on the same set: a forum that emails you your own post is
// the first thing anyone turns off.
assert.deepEqual(emitted[0].recipientUserIds, [11])
})
test('roster events fire one tickle for the run, not one per member', async () => {
@@ -97,6 +149,66 @@ test('roster events fire one tickle for the run, not one per member', async () =
assert.equal(sent.every((s) => s.ref === 'team:1'), true)
})
// The tickle is per run and the EVENT is per person, and that split is the
// declaration's doing: `memberName` is a required single value, so five joiners
// cannot honestly be one event. The rule's cooldown is what stops five mails.
test('a roster sweep emits one event per joiner while tickling once', async () => {
await notify.memberJoined(TEAM, { count: 2, names: ['Darrow', 'Marisol'] })
assert.equal(sent.length, 1)
assert.deepEqual(emitted.map((e) => e.data.memberName), ['Darrow', 'Marisol'])
})
test('a leadership run with only demotions tickles but names no new leader', async () => {
await notify.leadershipChanged(TEAM, { names: [] })
assert.equal(sent.length, 1)
assert.equal(emitted.length, 0)
})
// ── What the envelope carries ──────────────────────────────────────────────
test('the event carries the access-checked audience, the scope and the team name', async () => {
await notify.forumPost({
team: TEAM, threadId: 7, threadTitle: 'Raid', type: 'discussion', authorUserId: 10,
authorName: 'Ten', bodyHtml: '<p>tonight</p>',
})
const event = emitted[0]
assert.equal(event.owner, 'core')
assert.deepEqual(event.recipientUserIds, [11])
// The SCOPE is the id, not the name: an unsubscribe token is signed over it and
// sits in a mailbox for months, and a Team renamed in between must not orphan
// the link. The name rides in the payload, where it is displayed and not keyed.
assert.equal(event.scopeKey, 'team:1')
assert.equal(event.data.teamName, 'The Silver Hand')
assert.equal(event.data.threadTitle, 'Raid')
assert.equal(event.data.excerpt, 'tonight')
})
test('an announcement names its title, a post names its thread title', async () => {
await notify.forumPost({ team: TEAM, threadId: 8, threadTitle: 'Notice', type: 'announcement', authorUserId: 10 })
// The two declarations differ here and a template can only name one of them —
// which is why the seeded announcement rule points at the generic body. Pinned
// so that reconciling the two declarations is a deliberate act with a version
// bump, not a silent rename that empties somebody's subject line.
assert.equal(emitted[0].data.title, 'Notice')
assert.equal(emitted[0].data.threadTitle, undefined)
})
test('the post url on the envelope is site-RELATIVE, as the emit contract requires', async () => {
patch(registries, 'registeredTeamProvider', () => ({ pageUrlTemplate: '/uo/guilds/{externalId}' }))
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
// An absolute one would be refused by `engagementEmit.RELATIVE_URL`, which
// exists so a `url` variable cannot carry a recipient off-site. The renderer
// absolutizes it against the deployment's base at send time.
assert.equal(emitted[0].data.postUrl, '/uo/guilds/g1?thread=7')
})
test('one post is one event however often the path re-runs', async () => {
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.equal(emitted[0].dedupeKey, emitted[1].dedupeKey)
assert.match(emitted[0].dedupeKey, /^team:1:thread:7:/)
})
// ── Suppression ────────────────────────────────────────────────────────────
test('forums switched off silences a forum notification entirely', async () => {
@@ -105,8 +217,9 @@ test('forums switched off silences a forum notification entirely', async () => {
// `bridged` is phase 8's third sink (§7.2). Asserted as part of the shape
// rather than ignored: "forums are off" has to silence every sink, and a test
// that only checked two would not notice a third one still firing.
assert.deepEqual(res, { push: 0, emails: 0, bridged: false })
assert.deepEqual(res, { push: 0, emitted: false, bridged: false })
assert.equal(sent.length, 0)
assert.equal(emitted.length, 0)
})
test('a roster tickle survives forums being off — it is not forum content', async () => {
@@ -115,59 +228,27 @@ test('a roster tickle survives forums being off — it is not forum content', as
assert.equal(sent.length, 1)
})
test('no recipients means no publish call at all', async () => {
test('no recipients means no publish call and no event at all', async () => {
stub({ recipients: [] })
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.equal(sent.length, 0)
// An event with an empty audience would enqueue nothing anyway; not emitting it
// keeps the send log and the emit log free of rows about nobody.
assert.equal(emitted.length, 0)
})
test('a fan-out never throws, whatever the layer below does', async () => {
patch(notifyModel, 'recipientIds', async () => { throw new Error('database is on fire') })
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.deepEqual(res, { push: 0, emails: 0, bridged: false })
assert.deepEqual(res, { push: 0, emitted: false, bridged: false })
assert.equal(await notify.memberJoined(TEAM), 0)
})
// ── Email, the immediate mode ──────────────────────────────────────────────
const IMMEDIATE = [{ user_id: 11, username: 'eleven', email: 'e@example.test', email_mode: 'immediate' }]
test('only the immediate-mode recipients are emailed per event', async () => {
stub({
emailRows: [
...IMMEDIATE,
{ user_id: 12, username: 'twelve', email: 'd@example.test', email_mode: 'digest' },
{ user_id: 13, username: 'thirteen', email: 'o@example.test', email_mode: 'off' },
],
})
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10, bodyHtml: '<p>hello</p>' })
assert.deepEqual(mails.map((m) => m.to), ['e@example.test'])
})
test('an email carries an excerpt and never the whole post', async () => {
stub({ emailRows: IMMEDIATE })
const long = `<p>${'x'.repeat(500)}</p>`
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10, bodyHtml: long })
const body = mails[0].items[0].excerpt
assert.equal(body.length < 250, true)
assert.equal(body.endsWith('…'), true)
})
test('no email configured means no send and no recipient query', async () => {
stub({ emailConfigured: false, emailRows: IMMEDIATE })
test('a refused emit is swallowed — a contract bug must not fail a forum write', async () => {
patch(engagementEmit, 'emit', () => { throw new Error('ctx.events.emit: payload is invalid') })
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.equal(res.emails, 0)
assert.equal(mails.length, 0)
})
test('every email carries an unsubscribe url for that recipient and that Team', async () => {
stub({ emailRows: IMMEDIATE })
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.equal(typeof mails[0].unsubscribeUrl, 'string')
// The header one is an API endpoint (a one-click client POSTs to it without
// rendering anything); the body one is the site's page. They must differ.
assert.notEqual(mails[0].unsubscribeUrl, mails[0].unsubscribeApiUrl)
assert.match(mails[0].unsubscribeApiUrl, /\/api\/v1\/public\/teams\/unsubscribe\//)
assert.equal(res.emitted, false)
assert.equal(res.push, 2 - 1) // the tickle still went out
})
// ── Links, and the module that supplies them ───────────────────────────────
@@ -175,12 +256,16 @@ test('every email carries an unsubscribe url for that recipient and that Team',
test('with no module-supplied template there is no Team link, and nothing breaks', async () => {
assert.equal(notify.teamPageUrl(TEAM), null)
assert.equal(notify.threadUrl(TEAM, 7), null)
assert.equal(notify.teamPagePath(TEAM), null)
})
test('a registered template becomes an absolute link, and a thread deep-links by search param', () => {
patch(registries, 'registeredTeamProvider', () => ({ pageUrlTemplate: '/uo/guilds/{externalId}' }))
assert.match(notify.teamPageUrl(TEAM), /\/uo\/guilds\/g1$/)
assert.match(notify.threadUrl(TEAM, 7), /\/uo\/guilds\/g1\?thread=7$/)
// The path form is what travels on an envelope; the absolute form is what the
// Discord bridge posts, because a Discord client has no notion of this origin.
assert.equal(notify.teamPagePath(TEAM), '/uo/guilds/g1')
})
test('a Team is labelled by its display-name override where staff set one', () => {
@@ -190,15 +275,16 @@ test('a Team is labelled by its display-name override where staff set one', () =
// ── The digest worker ──────────────────────────────────────────────────────
const DIGEST_ROW = { user_id: 11, username: 'eleven', email: 'e@example.test', email_mode: 'digest', last_digest_at: null }
const DIGEST_ROW = { user_id: 11, username: 'eleven', email: 'e@example.test', email_mode: 'digest' }
function stubDigest({ posts = [], teams = [TEAM], rows = [DIGEST_ROW], sendOk = true } = {}) {
function stubDigest({ posts = [], teams = [TEAM], rows = [DIGEST_ROW], sendOk = true, stamps = new Map() } = {}) {
patch(notifyModel, 'teamsWithForumActivitySince', async () => teams)
patch(notifyModel, 'emailRecipients', async () => rows)
patch(notifyModel, 'digestPostsSince', async () => posts)
patch(mailer, 'sendTeamNotification', async (msg) => {
patch(digestDb, 'stampsFor', async () => stamps)
patch(mailer, 'sendNotification', async (msg) => {
mails.push(msg)
return { sent: sendOk }
return sendOk ? { ok: true, transport: 'smtp' } : { ok: false, retry: true, detail: 'relay said no' }
})
}
@@ -212,7 +298,18 @@ test('a digest gathers the Teams new posts into one mail', async () => {
const res = await digest.tick(new Date())
assert.equal(res.sent, 1)
assert.equal(mails.length, 1)
assert.equal(mails[0].items.length, 2)
assert.equal(mails[0].rendered.values.items.length, 2)
assert.equal(mails[0].to, 'e@example.test')
})
// §4.6.1: the digest's body is an operator-editable template, not a literal in
// `mailer`. The rule's `template_keys.digest` chooses it, and it is NOT
// `template_keys.email` — that one is written for a single event and would render
// a day of posts as one missing variable.
test('the digest renders the rules digest template, not its instant one', async () => {
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
await digest.tick(new Date())
assert.match(mails[0].rendered.subject, /^\[notify\.digest\]/)
})
test('a recipient with nothing new gets no mail and no stamp', async () => {
@@ -223,18 +320,26 @@ test('a recipient with nothing new gets no mail and no stamp', async () => {
assert.equal(world.stamped.length, 0, 'stamping here would move the window past unsent posts')
})
test('a failed send leaves last_digest_at alone so the window is retried', async () => {
test('a failed send leaves the digest window alone so it is retried', async () => {
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }], sendOk: false })
const res = await digest.tick(new Date())
assert.equal(res.sent, 0)
assert.equal(world.stamped.length, 0)
// Failed, and recorded as failed. G15's question is "did user X get it?", and
// "no, and here is why" is an answer the send log has to be able to give.
assert.equal(world.logged[0].status, 'failed')
})
test('a successful send stamps exactly that (user, Team)', async () => {
test('a successful send stamps exactly that (user, channel, scope)', async () => {
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
const now = new Date()
await digest.tick(now)
assert.deepEqual(world.stamped, [{ userId: 11, teamId: 1, at: now }])
// The stamp moved out of `team_notification_prefs.last_digest_at` and into
// `engagement_digest_state` (§4.2b), keyed by channel and scope so a second
// digest needs no second column on somebody's preferences row.
assert.deepEqual(world.stamped, [{ userId: 11, channel: 'email', scopeKey: 'team:1', at: now }])
assert.equal(world.logged[0].status, 'sent')
assert.equal(world.logged[0].outbox_id, null, 'a digest has no outbox row, and the null says so')
})
test('only digest-mode recipients are swept', async () => {
@@ -242,14 +347,91 @@ test('only digest-mode recipients are swept', async () => {
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
rows: [
DIGEST_ROW,
{ user_id: 12, username: 'twelve', email: 'i@example.test', email_mode: 'immediate', last_digest_at: null },
{ user_id: 13, username: 'thirteen', email: 'o@example.test', email_mode: 'off', last_digest_at: null },
{ user_id: 12, username: 'twelve', email: 'i@example.test', email_mode: 'immediate' },
{ user_id: 13, username: 'thirteen', email: 'o@example.test', email_mode: 'off' },
],
})
await digest.tick(new Date())
assert.deepEqual(mails.map((m) => m.to), ['e@example.test'])
})
// The acceptance criterion, verbatim: a pre-existing `email_mode='digest'` row
// produces exactly one daily digest with the same window clamping. The stamp is
// the one the schema backfilled out of `team_notification_prefs`.
test('a pre-existing digest subscriber gets one digest, over the window their old stamp defines', async () => {
const now = new Date('2026-08-18T12:00:00Z')
const yesterday = new Date('2026-08-17T12:00:00Z')
let asked = null
stubDigest({
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
stamps: new Map([[11, yesterday]]),
})
patch(notifyModel, 'digestPostsSince', async (teamId, since) => {
asked = since
return [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }]
})
const res = await digest.tick(now)
assert.equal(res.sent, 1)
assert.equal(mails.length, 1)
assert.equal(asked.getTime(), yesterday.getTime())
})
// ── The three compute-at-send-time properties (§4.2b) ──────────────────────
//
// Each gets its own named test, the way the Teams phase-5 work gave the
// leader-can't-see-reports rule its own. They are the reason a digest is NOT
// assembled out of outbox rows, and a refactor that "unified" the two paths
// would break all three at once and pass every other test in this file.
test('property 1 — a two-day outage sends ONE digest, not two days of replay', async () => {
const now = new Date('2026-08-18T12:00:00Z')
stubDigest({
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
stamps: new Map([[11, new Date('2026-08-16T12:00:00Z')]]),
})
const res = await digest.tick(now)
assert.equal(res.sent, 1)
assert.equal(mails.length, 1)
})
test('property 2 — a post hidden after it was written is not in the query, so not in the mail', async () => {
// The moderator hid it, so `digestPostsSince` no longer returns it. There is
// nothing for the worker to remember to remove, which is the property: an
// outbox row snapshotted at publish time would still be carrying the text.
stubDigest({ posts: [] })
const res = await digest.tick(new Date())
assert.equal(res.sent, 0)
assert.equal(mails.length, 0)
})
test('property 3 — a user who lost access between the post and the send is not mailed', async () => {
// `emailRecipients` asks the same two tables the access resolver asks, at SEND
// time. A revoked guest is simply not in the answer. This is the one that would
// have been a security bug: the posts still exist and are still in the window.
stubDigest({
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
rows: [],
})
const res = await digest.tick(new Date())
assert.equal(res.sent, 0)
assert.equal(mails.length, 0)
})
// ── The gate (Phase 6, decision 3) ─────────────────────────────────────────
test('no enabled email rule means no digest — the operator has not turned Team email on', async () => {
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
patch(rulesDb, 'enabledForTrigger', async () => [])
assert.equal((await digest.tick(new Date())).skipped, 'no-enabled-rule')
assert.equal(mails.length, 0)
})
test('a rule enabled on a channel other than email does not turn the digest on', async () => {
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
patch(rulesDb, 'enabledForTrigger', async () => [{ ...EMAIL_RULE, channels: ['inapp'] }])
assert.equal((await digest.tick(new Date())).skipped, 'no-enabled-rule')
})
test('the sweep is skipped whole when forums are off or email is unconfigured', async () => {
stub({ forumsEnabled: false })
assert.equal((await digest.tick(new Date())).skipped, 'forums-disabled')