Files
website/server/test/engagementEmail.test.js
wtclaude 065bec7ad8
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
feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.

Seven decisions settled by the org lead before any code:

  - email only moves; the push tickle and the Discord bridge stay direct calls
  - the EVENT carries its access-checked audience, and `members` resolves to it
  - the four Team rules are seeded DISABLED, with an admin banner and a note
  - team_notification_prefs stays, read by the engine as a scoped preference
  - the payload wins and a structural projection fills the gaps
  - the digest keeps computing at send time; only its state generalizes
  - an unsubscribe token turns off the channel it names, and nothing else

Three defects found while building it:

  - `email.button` never absolutized its href, while image and itemList both
    did. Every rule-driven CTA would have been a dead relative link, because a
    trigger's url variables are validated site-relative by construction.
  - Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
    not to build. An outbox row snapshots the payload and so has none of the
    three properties the digest design exists for, including the security one.
  - the digest's send-log row carried no address_hash while the instant row
    beside it did, which would have made half the mail uncorrelatable in Phase 9.

Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.

Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.

Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:11:54 -05:00

409 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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)
})