Files
website/server/test/teamNotify.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

313 lines
15 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.

// Team notifications (docs/website/TEAMS.md Part 6, phase 6).
//
// The db layer is stubbed and in-memory tables stand in for `team_members`,
// `team_forum_grants` and `team_notification_prefs`, so these are assertions
// about the RULES rather than about SQL. Five are worth protecting because each
// one is a leak or a nuisance if it is "simplified" away:
//
// 1. a revoked guest and a departed member are NOT recipients — the fan-out
// asks the same two conditions the access resolver asks, and a fan-out that
// forgot one would mail a private forum to somebody who was removed from it;
// 2. a mute subtracts from the recipient set, per Team, and does not touch the
// user's other Teams;
// 3. the author of a post is never a recipient of the notification about it;
// 4. email is opt-IN (`off` unless chosen) while push is opt-OUT, which is the
// one asymmetry in the whole feature and the one most likely to be
// "tidied up" into a single default;
// 5. an unsubscribe token is honoured for exactly the pair it was signed for,
// and forging one is not distinguishable from failing.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const notifyDb = require('../src/model/teams/teamNotify.db')
const notifyModel = require('../src/model/teams/teamNotify.model')
const unsubscribeToken = require('../src/utils/unsubscribeToken')
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()
}
// ── The in-memory stand-in ─────────────────────────────────────────────────
//
// Modelled on the three real tables rather than on the queries, so a rule the SQL
// gets wrong is a rule this gets wrong too. `members` and `grants` carry their
// status columns for exactly that reason: the interesting cases are the rows that
// exist and do not count.
let store
function stub() {
store = {
teams: [
{ id: 1, slug: 'silver-hand', name: 'The Silver Hand', display_name_override: null, status: 'active' },
{ id: 2, slug: 'iron-few', name: 'The Iron Few', display_name_override: null, status: 'active' },
],
members: [
{ team_id: 1, user_id: 10, status: 'active' },
{ team_id: 1, user_id: 11, status: 'active' },
{ team_id: 1, user_id: 12, status: 'departed' }, // left the guild
{ team_id: 1, user_id: null, status: 'active' }, // unlinked character
{ team_id: 2, user_id: 10, status: 'active' },
],
grants: [
{ team_id: 1, user_id: 20, revoked_at: null }, // a forum guest
{ team_id: 1, user_id: 21, revoked_at: '2026-08-01' }, // revoked
],
users: [
{ id: 10, username: 'ten', email: 'ten@example.test', status: 'active' },
{ id: 11, username: 'eleven', email: null, status: 'active' },
{ id: 20, username: 'twenty', email: 'twenty@example.test', status: 'active' },
{ id: 30, username: 'banned', email: 'banned@example.test', status: 'banned' },
],
prefs: [], // { user_id, team_id, muted, email_mode, last_digest_at }
}
const prefFor = (userId, teamId) =>
store.prefs.find((p) => p.user_id === userId && p.team_id === teamId)
// The union the real RECIPIENT_UNION builds, with the same two conditions.
const union = (teamId) => {
const ids = new Set()
for (const m of store.members) {
if (m.team_id === teamId && m.status === 'active' && m.user_id != null) ids.add(m.user_id)
}
for (const g of store.grants) {
if (g.team_id === teamId && g.revoked_at == null) ids.add(g.user_id)
}
return [...ids]
}
patch(notifyDb, 'recipientIds', async (teamId, { exclude = [] } = {}) =>
union(teamId)
.filter((id) => !exclude.includes(id))
.filter((id) => !(prefFor(id, teamId)?.muted)))
patch(notifyDb, 'emailRecipients', async (teamId, { exclude = [] } = {}) =>
union(teamId)
.filter((id) => !exclude.includes(id))
.filter((id) => !(prefFor(id, teamId)?.muted))
.map((id) => ({ id, user: store.users.find((u) => u.id === id) }))
.filter(({ user }) => user && user.email && user.status === 'active')
.map(({ id, user }) => ({
user_id: id,
username: user.username,
email: user.email,
email_mode: prefFor(id, teamId)?.email_mode || 'off',
last_digest_at: prefFor(id, teamId)?.last_digest_at || null,
})))
patch(notifyDb, 'prefsForUser', async (userId) =>
store.teams
.filter((t) => union(t.id).includes(userId) || prefFor(userId, t.id))
.map((t) => ({
team_id: t.id,
slug: t.slug,
name: t.name,
display_name_override: t.display_name_override,
team_status: t.status,
muted: prefFor(userId, t.id)?.muted ? 1 : 0,
email_mode: prefFor(userId, t.id)?.email_mode || 'off',
})))
patch(notifyDb, 'prefFor', async (userId, teamId) => prefFor(userId, teamId))
patch(notifyDb, 'setPref', async (userId, teamId, { muted, emailMode }) => {
let row = prefFor(userId, teamId)
if (!row) {
row = { user_id: userId, team_id: teamId, muted: 0, email_mode: 'off', last_digest_at: null }
store.prefs.push(row)
}
// Only the columns the caller named, exactly as the ON DUPLICATE KEY clause
// does — this is the property the unsubscribe path depends on.
if (muted != null) row.muted = muted ? 1 : 0
if (emailMode != null) row.email_mode = emailMode
})
}
beforeEach(stub)
afterEach(restore)
// ── 1. Access, and the rows that exist but do not count ────────────────────
test('recipients are active linked members plus active grants, and nobody else', async () => {
const ids = await notifyModel.recipientIds(1)
assert.deepEqual(ids.sort((a, b) => a - b), [10, 11, 20])
})
test('a departed member and a revoked guest are not recipients', async () => {
const ids = await notifyModel.recipientIds(1)
assert.equal(ids.includes(12), false, 'a departed member is still in the table and must not be notified')
assert.equal(ids.includes(21), false, 'a revoked grant is still in the ledger and must not be notified')
})
test('an unlinked member contributes no recipient rather than a null one', async () => {
const ids = await notifyModel.recipientIds(1)
assert.equal(ids.includes(null), false)
assert.equal(ids.every((id) => Number.isInteger(id)), true)
})
// ── 2. Mutes ───────────────────────────────────────────────────────────────
test('a mute removes that user from that Team only', async () => {
await notifyModel.mute(10, 1)
assert.deepEqual((await notifyModel.recipientIds(1)).sort((a, b) => a - b), [11, 20])
assert.deepEqual(await notifyModel.recipientIds(2), [10], 'the same user is untouched in another Team')
})
test('unmuting puts them back', async () => {
await notifyModel.mute(10, 1)
await notifyModel.unmute(10, 1)
assert.equal((await notifyModel.recipientIds(1)).includes(10), true)
})
test('a mute does not disturb an email mode the user chose', async () => {
await notifyModel.replacePrefs(10, [{ teamId: 1, muted: false, emailMode: 'immediate' }])
await notifyModel.mute(10, 1)
const pref = await notifyModel.prefFor(10, 1)
assert.equal(pref.muted, true)
assert.equal(pref.emailMode, 'immediate', 'unsubscribing from one email must not silently rewrite the mode')
})
// ── 3. The author ──────────────────────────────────────────────────────────
test('the author of a post is excluded from the notification about it', async () => {
const ids = await notifyModel.recipientIds(1, { exclude: [10] })
assert.equal(ids.includes(10), false)
assert.deepEqual(ids.sort((a, b) => a - b), [11, 20])
})
// ── 4. The two defaults, which point opposite ways ─────────────────────────
test('push is opt-OUT: a user who has never configured anything is a recipient', async () => {
assert.equal(store.prefs.length, 0)
assert.equal((await notifyModel.recipientIds(1)).includes(10), true)
})
test('email is opt-IN: the same user is in no email mode until they pick one', async () => {
const rows = await notifyModel.emailRecipients(1)
assert.equal(rows.every((r) => r.email_mode === 'off'), true)
assert.equal(rows.filter((r) => r.email_mode === 'digest' || r.email_mode === 'immediate').length, 0)
})
test('a user with no email address is a push recipient and not an email one', async () => {
assert.equal((await notifyModel.recipientIds(1)).includes(11), true)
assert.equal((await notifyModel.emailRecipients(1)).some((r) => r.user_id === 11), false)
})
// ── 5. Preferences a caller may write ──────────────────────────────────────
test('replacePrefs ignores a Team the caller is not in', async () => {
// User 20 holds a grant on Team 1 and has nothing at all on Team 2.
const { written } = await notifyModel.replacePrefs(20, [
{ teamId: 1, muted: true, emailMode: 'digest' },
{ teamId: 2, muted: true, emailMode: 'digest' },
])
assert.deepEqual(written, [1])
assert.equal(store.prefs.some((p) => p.user_id === 20 && p.team_id === 2), false)
})
test('replacePrefs really replaces: an omitted Team returns to its defaults', async () => {
await notifyModel.replacePrefs(10, [
{ teamId: 1, muted: true, emailMode: 'immediate' },
{ teamId: 2, muted: true, emailMode: 'digest' },
])
// Now save a set that names only Team 1. Team 2 was not mentioned, so it goes
// back to defaults — otherwise "PUT the whole set" is a lie and `teams: []`
// clears nothing, which is the body the route requires so that clearing
// everything is expressible in the first place.
await notifyModel.replacePrefs(10, [{ teamId: 1, muted: true, emailMode: 'immediate' }])
assert.deepEqual(await notifyModel.prefFor(10, 1), { teamId: 1, muted: true, emailMode: 'immediate' })
assert.deepEqual(await notifyModel.prefFor(10, 2), { teamId: 2, muted: false, emailMode: 'off' })
})
test('an empty set clears every preference the caller holds', async () => {
await notifyModel.replacePrefs(10, [{ teamId: 1, muted: true, emailMode: 'digest' }])
await notifyModel.replacePrefs(10, [])
assert.equal((await notifyModel.prefFor(10, 1)).muted, false)
assert.equal((await notifyModel.recipientIds(1)).includes(10), true)
})
test('the reset does not touch last_digest_at — that is the workers column', async () => {
await notifyModel.replacePrefs(10, [{ teamId: 1, muted: false, emailMode: 'digest' }])
const row = store.prefs.find((p) => p.user_id === 10 && p.team_id === 1)
row.last_digest_at = '2026-08-18T00:00:00Z'
await notifyModel.replacePrefs(10, [])
assert.equal(row.last_digest_at, '2026-08-18T00:00:00Z',
'dropping it would re-open a day-wide window on every visit to the settings screen')
})
test('an unknown email mode falls back to off rather than reaching the column', async () => {
await notifyModel.replacePrefs(10, [{ teamId: 1, muted: false, emailMode: 'hourly' }])
assert.equal((await notifyModel.prefFor(10, 1)).emailMode, 'off')
})
test('listPrefs offers a Team the user has never configured', async () => {
const prefs = await notifyModel.listPrefs(10)
assert.deepEqual(prefs.map((p) => p.teamId).sort(), [1, 2])
assert.equal(prefs.every((p) => p.muted === false && p.emailMode === 'off'), true)
})
test('listPrefs names a Team by its display-name override when staff set one', async () => {
store.teams[0].display_name_override = 'A Renamed Guild'
const prefs = await notifyModel.listPrefs(10)
assert.equal(prefs.find((p) => p.teamId === 1).name, 'A Renamed Guild')
})
// ── 6. The unsubscribe token ───────────────────────────────────────────────
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,
})
})
// 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', () => {
assert.equal(unsubscribeToken.verify('nonsense'), null)
assert.equal(unsubscribeToken.verify(''), null)
assert.equal(unsubscribeToken.verify(null), null)
assert.equal(unsubscribeToken.verify('1.10.1.AAAAAAAAAAAAAAAAAAAAAA'), null)
})
test('a version bump is what invalidates every outstanding link at once', () => {
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)
})