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>
198 lines
8.9 KiB
JavaScript
198 lines
8.9 KiB
JavaScript
// The bridge as a SINK — what actually leaves the site (TEAMS.md §7.2, phase 8).
|
||
//
|
||
// teamIntegration.test.js proves the configuration rules; this proves the wiring
|
||
// that consumes them, which is a different set of mistakes:
|
||
//
|
||
// 1. **the bridge never throws and never blocks its caller.** Every entry point
|
||
// into teamNotify runs after a write has already been answered, so a bot
|
||
// that is down, a config lookup that throws, or a client that rejects must
|
||
// all come back as "no message sent" — an exception here would fail a forum
|
||
// reply that succeeded;
|
||
// 2. **the author is excluded from push and email and NOT from the bridge.**
|
||
// Excluding is a per-recipient idea; a channel has no per-recipient anything,
|
||
// and suppressing the message would silence it for everyone else;
|
||
// 3. **push still runs when the bridge is unconfigured**, which is the ordinary
|
||
// case on every deployment that never turns this on;
|
||
// 4. **a roster event carries a count and never a name.** The count is public;
|
||
// a character name is game-sourced text screened for a page, not a channel;
|
||
// 5. **the excerpt strips markup**, because a forum body is sanitised HTML and
|
||
// an embed description is text.
|
||
const { test, beforeEach, afterEach } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const teamBridge = require('../src/utils/teamBridge')
|
||
const teamIntegration = require('../src/model/teams/teamIntegration.model')
|
||
const botInternalClient = require('../src/utils/botInternalClient')
|
||
const teamNotify = require('../src/utils/teamNotify')
|
||
const notifyDb = require('../src/model/teams/teamNotify.db')
|
||
const forumSettings = require('../src/model/teams/teamForumSettings.model')
|
||
const pushDispatch = require('../src/utils/pushDispatch')
|
||
const mailer = require('../src/utils/mailer')
|
||
|
||
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 TEAM = { id: 3, name: 'Blackthorn’s Legion', slug: 'blackthorns-legion', external_id: 'g-1' }
|
||
|
||
let sent
|
||
let pushed
|
||
|
||
beforeEach(() => {
|
||
sent = []
|
||
pushed = []
|
||
patch(botInternalClient, 'teamNotify', async (payload) => {
|
||
sent.push(payload)
|
||
return { ok: true, status: 200, data: { posted: true } }
|
||
})
|
||
patch(pushDispatch, 'publishToUsers', async (streamId, opts) => { pushed.push({ streamId, ...opts }) })
|
||
patch(notifyDb, 'recipientIds', async () => [11, 12])
|
||
patch(notifyDb, 'emailRecipients', async () => [])
|
||
patch(forumSettings, 'forumsEnabled', async () => true)
|
||
patch(mailer, 'isConfigured', async () => false)
|
||
})
|
||
|
||
afterEach(restore)
|
||
|
||
const bridgeTo = (channelRef, events) =>
|
||
patch(teamIntegration, 'destinationFor', async (teamId, streamId) =>
|
||
(events.includes(streamId) ? { channelRef, membersOnly: teamIntegration.isMembersOnly(streamId), platform: 'discord' } : null))
|
||
|
||
const noBridge = () => patch(teamIntegration, 'destinationFor', async () => null)
|
||
|
||
// ── 1. Never throws, never blocks ──────────────────────────────────────────
|
||
|
||
test('a bot that rejects the call does not stop the notification path', async () => {
|
||
bridgeTo('999', ['team.member.joined'])
|
||
patch(botInternalClient, 'teamNotify', async () => ({ ok: false, status: 503, error: 'bot responded 503' }))
|
||
|
||
const recipients = await teamNotify.memberJoined(TEAM, { count: 2 })
|
||
assert.equal(recipients, 2, 'the push half still reported its recipients')
|
||
assert.equal(pushed.length, 1)
|
||
})
|
||
|
||
test('a config lookup that throws is a bridge that sends nothing, not an exception', async () => {
|
||
patch(teamIntegration, 'destinationFor', async () => { throw new Error('connection lost') })
|
||
assert.equal(await teamBridge.deliver('team.member.joined', TEAM, { body: 'x' }), false)
|
||
})
|
||
|
||
test('a client that throws outright is caught', async () => {
|
||
bridgeTo('999', ['team.member.joined'])
|
||
patch(botInternalClient, 'teamNotify', async () => { throw new Error('socket hang up') })
|
||
assert.equal(await teamBridge.deliver('team.member.joined', TEAM, { body: 'x' }), false)
|
||
})
|
||
|
||
test('a team with no id is refused before anything is looked up', async () => {
|
||
patch(teamIntegration, 'destinationFor', async () => { throw new Error('should not be reached') })
|
||
assert.equal(await teamBridge.deliver('team.member.joined', null, {}), false)
|
||
assert.equal(await teamBridge.deliver('team.member.joined', {}, {}), false)
|
||
})
|
||
|
||
// ── 2. The author exclusion stops at the channel ───────────────────────────
|
||
|
||
test('a forum post excludes its author from push but still bridges to the channel', async () => {
|
||
bridgeTo('999', ['team.forum.post'])
|
||
const seen = []
|
||
patch(notifyDb, 'recipientIds', async (teamId, opts) => { seen.push(opts.exclude); return [12] })
|
||
|
||
const result = await teamNotify.forumPost({
|
||
team: TEAM,
|
||
threadId: 41,
|
||
threadTitle: 'Siege tonight',
|
||
type: 'discussion',
|
||
authorUserId: 11,
|
||
authorName: 'ana',
|
||
bodyHtml: '<p>Meet at the moongate.</p>',
|
||
})
|
||
|
||
assert.deepEqual(seen[0], [11], 'the author is excluded from the recipient set')
|
||
assert.equal(result.bridged, true)
|
||
assert.equal(sent.length, 1)
|
||
assert.equal(sent[0].title, 'Siege tonight')
|
||
assert.equal(sent[0].body, 'Meet at the moongate.')
|
||
})
|
||
|
||
test('an announcement bridges on its own stream, not the discussion one', async () => {
|
||
bridgeTo('999', ['team.announcement'])
|
||
await teamNotify.forumPost({
|
||
team: TEAM, threadId: 7, threadTitle: 'Rules', type: 'announcement', bodyHtml: '<p>Read this.</p>',
|
||
})
|
||
assert.equal(sent.length, 1)
|
||
assert.equal(sent[0].streamId, 'team.announcement')
|
||
|
||
// The same event under the other stream is not carried by this configuration.
|
||
sent.length = 0
|
||
await teamNotify.forumPost({
|
||
team: TEAM, threadId: 8, threadTitle: 'Chat', type: 'discussion', bodyHtml: '<p>Hi.</p>',
|
||
})
|
||
assert.equal(sent.length, 0)
|
||
})
|
||
|
||
test('forums switched off silence the bridge as well as the push', async () => {
|
||
bridgeTo('999', ['team.forum.post'])
|
||
patch(forumSettings, 'forumsEnabled', async () => false)
|
||
const result = await teamNotify.forumPost({
|
||
team: TEAM, threadId: 41, threadTitle: 'Siege', type: 'discussion', bodyHtml: '<p>x</p>',
|
||
})
|
||
assert.deepEqual(result, { push: 0, emitted: false, bridged: false })
|
||
assert.equal(sent.length, 0)
|
||
})
|
||
|
||
// ── 3. The ordinary deployment: nothing configured ─────────────────────────
|
||
|
||
test('an unconfigured bridge is silent and costs the push path nothing', async () => {
|
||
noBridge()
|
||
const recipients = await teamNotify.memberJoined(TEAM, { count: 1 })
|
||
assert.equal(recipients, 2)
|
||
assert.equal(pushed.length, 1)
|
||
assert.equal(sent.length, 0)
|
||
})
|
||
|
||
// ── 4. A roster event says how many, never who ─────────────────────────────
|
||
|
||
test('the roster message carries a count and no member name', async () => {
|
||
bridgeTo('999', ['team.member.joined'])
|
||
await teamNotify.memberJoined(TEAM, { count: 3 })
|
||
assert.equal(sent[0].body, '3 new members joined.')
|
||
assert.equal(sent[0].title, null, 'a roster event has no title to put a name in')
|
||
})
|
||
|
||
test('the count is singular at one, and degrades honestly with no count at all', async () => {
|
||
assert.equal(teamBridge.memberJoinedBody(1), 'A new member joined.')
|
||
assert.equal(teamBridge.memberJoinedBody(4), '4 new members joined.')
|
||
assert.equal(teamBridge.memberJoinedBody(undefined), 'The roster has changed.')
|
||
assert.equal(teamBridge.memberJoinedBody(0), 'The roster has changed.')
|
||
})
|
||
|
||
test('the tickle stays content-free even when the bridge beside it carries a body', async () => {
|
||
bridgeTo('999', ['team.member.joined'])
|
||
await teamNotify.memberJoined(TEAM, { count: 3 })
|
||
assert.deepEqual(pushed[0], { streamId: 'team.member.joined', ref: 'team:3', userIds: [11, 12] })
|
||
})
|
||
|
||
// ── 5. The excerpt ─────────────────────────────────────────────────────────
|
||
|
||
test('the excerpt strips markup, collapses whitespace and decodes entities', async () => {
|
||
assert.equal(teamBridge.excerpt('<p>Meet at\nthe <b>moongate</b> & wait.</p>'), 'Meet at the moongate & wait.')
|
||
})
|
||
|
||
test('the excerpt is bounded, because an embed description that overflows is rejected wholesale', async () => {
|
||
const long = teamBridge.excerpt(`<p>${'x'.repeat(5000)}</p>`)
|
||
assert.equal(long.length, teamBridge.EXCERPT_CHARS)
|
||
assert.ok(long.endsWith('…'))
|
||
})
|
||
|
||
test('the label prefers a staff display-name override, as every other surface does', async () => {
|
||
assert.equal(teamBridge.teamLabel({ name: 'Real', display_name_override: 'Shown' }), 'Shown')
|
||
assert.equal(teamBridge.teamLabel(null), 'a team')
|
||
})
|