// ── 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: '

x

', 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 rule’s template and sends to the user’s 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 link’s token names the email channel and the event’s 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 trigger’s 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 Team’s 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) })