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

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