diff --git a/client/src/App.jsx b/client/src/App.jsx
index a63c557..88ff3f7 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -57,6 +57,8 @@ import ResetPassword from './routes/player/ResetPassword.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
+import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
+import Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
export default function App() {
@@ -204,6 +206,10 @@ export default function App() {
} />
} />
} />
+ {/* PUBLIC, and grouped with the other tokened landings above rather
+ than with the portal below: the person following an unsubscribe
+ link is reading their mail, not signed in (TEAMS.md §6.4). */}
+ } />
@@ -218,6 +224,7 @@ export default function App() {
} />
} />
} />
+ } />
{/* Installed modules' player-portal pages, at /player//…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one
diff --git a/client/src/api/client.js b/client/src/api/client.js
index a8ae529..010d9c7 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -187,6 +187,26 @@ export const api = {
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
teamGrantRevoke: (slug, userId) =>
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
+
+ // ----- notifications (TEAMS.md Part 6) -----
+ //
+ // Under /auth/me rather than /player: these are role-agnostic self-service, the
+ // same rule that put the forum under /player rather than behind a staff gate.
+ // The streams catalog and the per-stream subscriptions were built for the app
+ // and had no web consumer at all until phase 6 gave them one.
+ notificationStreams: () => req('/auth/me/notifications/streams'),
+ notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
+ // `streams` is always sent, empty array included — the endpoint requires the
+ // field, so clearing the last subscription must not become an absent key.
+ setNotificationSubscriptions: (streams) =>
+ req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
+ teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
+ setTeamNotificationPrefs: (teams) =>
+ req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
+ // Unauthenticated, and the one write in the public tier: the caller is reading
+ // their mail, not signed in. Always resolves 200 whatever the token was.
+ unsubscribeTeam: (token) =>
+ req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
diff --git a/client/src/main.jsx b/client/src/main.jsx
index 0d26693..4b6cb27 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -6,6 +6,7 @@ import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
import TeamForumPanel from './modules/TeamForumPanel.jsx'
+import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -84,6 +85,12 @@ fillModuleSlot('uo.guild.detail', TeamActivityFeed)
// exactly as before.
fillModuleSlot('uo.guild.forum', TeamForumPanel)
+// And the notification control, in a third place the module declares ABOVE its
+// roster. A third slot rather than a corner of the feed for the same reason there
+// were two: this is an action on the page and the other two are content in it,
+// and only the module can say where each belongs on a page it owns.
+fillModuleSlot('uo.guild.header', TeamNotifyToggle)
+
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
diff --git a/client/src/modules/TeamNotifyToggle.jsx b/client/src/modules/TeamNotifyToggle.jsx
new file mode 100644
index 0000000..e48c8e6
--- /dev/null
+++ b/client/src/modules/TeamNotifyToggle.jsx
@@ -0,0 +1,102 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../api/client.js'
+import { useAuth } from '../contexts/AuthContext.jsx'
+
+// Core's per-Team notification control, rendered into a THIRD slot a module
+// declares (TEAMS.md §6.3, phase 6).
+//
+// **Why this is a slot at all, and why it is the third one.** Teams have no core
+// page — the module that owns the vocabulary owns the page — so a control that
+// acts on one Team has nowhere of core's to live. The feed and the forum go below
+// the module's roster; this goes above it, because muting a guild is an action ON
+// the page rather than more content in it, and that is exactly the placement
+// decision a module cannot make if core stacks everything into one fill.
+//
+// **It renders nothing for a viewer who is not in the Team**, including anonymous
+// ones, and that is a privacy property rather than a tidiness one: whether a
+// notification preference EXISTS for a Team answers "is this person in it", and
+// the guild page is public. The server decides — the preference list only contains
+// Teams the caller may be notified about — and this file never infers membership
+// from anything it can see on the page.
+//
+// **Muting is per-Team and covers all four streams.** The per-stream on/off lives
+// on the account screen, where the catalog does; the thing that could not be
+// expressed before phase 6 is "I am in five Teams and want notifications from
+// one", and that is the only question this control asks.
+
+export default function TeamNotifyToggle({ externalId, moduleId }) {
+ const { user } = useAuth()
+ const [state, setState] = useState({ loading: true, team: null, pref: null })
+ const [busy, setBusy] = useState(false)
+
+ const load = useCallback(async () => {
+ // Anonymous viewers never fetch. The endpoint would 401 harmlessly, but a
+ // guild page rendering a public roster should not put an authenticated
+ // request on the wire for every visitor.
+ if (!user) return setState({ loading: false, team: null, pref: null })
+ try {
+ const team = await api.teamByExternalId(moduleId, externalId)
+ const { teams } = await api.teamNotificationPrefs()
+ const pref = (teams || []).find((t) => t.teamId === team.id) || null
+ setState({ loading: false, team, pref })
+ } catch {
+ // Same rule as the feed and the forum: this is core's content on a page
+ // core does not own, so a failure renders nothing rather than putting an
+ // error box on somebody else's surface.
+ setState({ loading: false, team: null, pref: null })
+ }
+ }, [externalId, moduleId, user])
+
+ useEffect(() => { load() }, [load])
+
+ const { loading, pref } = state
+ if (loading || !pref) return null
+
+ async function toggle() {
+ setBusy(true)
+ // Optimistic, and reconciled from the server's echo rather than assumed: a
+ // PUT that silently dropped the entry (a Team left in another tab) must not
+ // leave the control claiming a state the server does not hold.
+ const next = { ...pref, muted: !pref.muted }
+ setState((s) => ({ ...s, pref: next }))
+ try {
+ const { teams } = await api.setTeamNotificationPrefs([
+ { teamId: pref.teamId, muted: next.muted, emailMode: pref.emailMode },
+ ])
+ const echoed = (teams || []).find((t) => t.teamId === pref.teamId)
+ if (echoed) setState((s) => ({ ...s, pref: echoed }))
+ } catch {
+ setState((s) => ({ ...s, pref }))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+ {pref.muted
+ ? 'You get no notifications about this team.'
+ : 'You get notifications about this team.'}
+
+ {/* The one link off this control, because "mute" is a blunt answer to a
+ question the account screen asks properly — which streams, and whether
+ email is on at all. */}
+ All notification settings
+
+ )
+}
diff --git a/client/src/routes/player/PlayerNotifications.jsx b/client/src/routes/player/PlayerNotifications.jsx
new file mode 100644
index 0000000..ad9ac9b
--- /dev/null
+++ b/client/src/routes/player/PlayerNotifications.jsx
@@ -0,0 +1,268 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Loading, ErrorState } from '../../components/PageState.jsx'
+import { api } from '../../api/client.js'
+
+// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
+//
+// **This screen did not exist before phase 6, and that was the phase's first
+// finding.** §6.3 says the per-Team mute list is "surfaced under the existing
+// notification settings screen" — there was no such screen on the web. The stream
+// catalog and the per-stream subscriptions have been built and shipped since M7,
+// with the Android app as their only consumer; a browser could not see them at
+// all. That is tolerable for push, which needs the app anyway. It is not tolerable
+// for email, whose whole reason for existing (§6.4) is the web-only user who runs
+// neither the app nor Discord — so the sink and the screen to configure it had to
+// arrive together.
+//
+// Three blocks, in the order a user actually reasons about them: what kinds of
+// thing to be told about, then which Teams, then whether any of it should reach a
+// mailbox.
+
+const EMAIL_MODES = [
+ { value: 'off', label: 'No email' },
+ { value: 'digest', label: 'Daily digest' },
+ { value: 'immediate', label: 'Every post' },
+]
+
+// Streams whose scoping lives in this page's second block rather than in the
+// first. Shown as a group so a user does not toggle `team.forum.post` off site-
+// wide when what they meant was "not this one guild".
+const isTeamStream = (id) => String(id).startsWith('team.')
+
+function Section({ title, hint, children }) {
+ return (
+
+
+ You are not in a team, and nobody has given you access to a team forum. There is nothing to
+ configure here yet.
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
Team
+
Notifications
+
Email
+
+
+
+ {rows.map((t) => (
+
+
+ {t.name}
+ {/* An archived Team is still listed when a preference exists for
+ it, so a mute does not silently vanish when a guild disbands
+ and reappear if it re-forms under the same name. */}
+ {t.archived && · archived}
+
+ Choose what you are told about, and how. Nothing here is on by default except team
+ notifications to the app, which you can mute per team below.
+
+
+
+
+ )
+}
diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx
index 12e9153..35c3312 100644
--- a/client/src/routes/player/PlayerPortalLayout.jsx
+++ b/client/src/routes/player/PlayerPortalLayout.jsx
@@ -35,6 +35,7 @@ function Icon({ children, size = 16 }) {
}
const IconGear = () =>
const IconShield = () =>
+const IconBell = () =>
// Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
@@ -47,6 +48,7 @@ const IconShield = () =>
+
+ {state === 'working' &&
One moment…
}
+ {state === 'done' && (
+ <>
+
+ You will not receive further notification emails about this team.
+
+
+ This muted the team rather than switching off your account’s email, so your other
+ teams are unaffected. You can turn it back on any time under{' '}
+ notification settings.
+
+ >
+ )}
+ {state === 'failed' && (
+
+ We could not reach the site to record that. Please try the link again, or change the
+ setting yourself under notification settings.
+
+ )}
+
+ )
+}
diff --git a/client/test/teamNotify.test.js b/client/test/teamNotify.test.js
new file mode 100644
index 0000000..fb39a0a
--- /dev/null
+++ b/client/test/teamNotify.test.js
@@ -0,0 +1,87 @@
+import { test, beforeEach, afterEach } from 'node:test'
+import assert from 'node:assert/strict'
+import { api } from '../src/api/client.js'
+
+// The client half of Team notifications (docs/website/TEAMS.md Part 6, phase 6).
+//
+// There is no DOM in this runner, so what is asserted here is the WIRE — which is
+// where this feature's client-side mistakes actually live. Two of them have
+// already been made once in this repo and are recorded rather than re-derived:
+//
+// 1. **A PUT-the-whole-set body must always carry its array**, empty included.
+// `docs/android/PLAN.md` §11: a DTO field with a default is dropped by
+// kotlinx when it equals that default, so "clear the last entry" arrives as a
+// body with no array at all and 400s. The web client has no such
+// serialisation quirk, but it shares the endpoint's contract, and a test that
+// pins the shape here is what keeps the two clients honest about the same
+// rule.
+// 2. **The unsubscribe call is a POST**, not the GET the link in the mail was.
+// A GET that mutated would be triggered by every mail-client link scanner.
+
+let calls
+const realFetch = global.fetch
+
+function reply(body = {}) {
+ return {
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ text: async () => JSON.stringify(body),
+ }
+}
+
+beforeEach(() => {
+ calls = []
+ global.fetch = async (url, opts = {}) => {
+ calls.push({ url, opts })
+ return reply({ teams: [], streams: [], ok: true })
+ }
+})
+
+afterEach(() => { global.fetch = realFetch })
+
+const body = (i = 0) => JSON.parse(calls[i].opts.body)
+
+test('the per-Team preference endpoints sit under /auth/me, not /player', async () => {
+ await api.teamNotificationPrefs()
+ // Role-agnostic self-service, the same rule that put the Team forum under
+ // /player rather than behind a staff gate: staff are a superset of players and
+ // manage their own notifications like anyone else.
+ assert.match(calls[0].url, /\/auth\/me\/notifications\/teams$/)
+ assert.equal(calls[0].opts.method ?? 'GET', 'GET')
+})
+
+test('saving preferences PUTs the whole set under a `teams` key', async () => {
+ await api.setTeamNotificationPrefs([{ teamId: 3, muted: true, emailMode: 'digest' }])
+ assert.equal(calls[0].opts.method, 'PUT')
+ assert.deepEqual(body(), { teams: [{ teamId: 3, muted: true, emailMode: 'digest' }] })
+})
+
+test('clearing every preference still sends the array, never an absent key', async () => {
+ await api.setTeamNotificationPrefs([])
+ assert.deepEqual(body(), { teams: [] })
+ assert.equal('teams' in body(), true)
+})
+
+test('the same rule holds for the stream subscriptions beside them', async () => {
+ await api.setNotificationSubscriptions([])
+ assert.deepEqual(body(), { streams: [] })
+})
+
+test('unsubscribe is a POST to the public tier, with the token encoded into the path', async () => {
+ await api.unsubscribeTeam('1.7.3.abcDEF')
+ assert.equal(calls[0].opts.method, 'POST')
+ assert.match(calls[0].url, /\/public\/teams\/unsubscribe\/1\.7\.3\.abcDEF$/)
+})
+
+test('a token with url-unsafe characters is encoded rather than pasted in', async () => {
+ await api.unsubscribeTeam('a/b c')
+ assert.match(calls[0].url, /unsubscribe\/a%2Fb%20c$/)
+})
+
+test('the streams catalog and subscriptions are separate reads', async () => {
+ await api.notificationStreams()
+ await api.notificationSubscriptions()
+ assert.match(calls[0].url, /\/notifications\/streams$/)
+ assert.match(calls[1].url, /\/notifications\/subscriptions$/)
+})
diff --git a/server/db/schema.sql b/server/db/schema.sql
index b28e013..a1ba538 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -1266,6 +1266,45 @@ CREATE TABLE IF NOT EXISTS team_activity (
INDEX idx_team_activity_feed (team_id, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- Per-Team notification preference (TEAMS.md §6.3/§6.4, phase 6). OPT-OUT, not
+-- opt-in: a user in a single Team must never have to configure anything, so the
+-- absence of a row is the default and every column here is a deviation from it.
+--
+-- Team scoping lives HERE and in the recipient computation, never in a stream id.
+-- The push catalog is a static registration validated at boot against a namespaced
+-- pattern; it cannot express one stream per Team, and stream ids are stored in
+-- notification_subscriptions rows that would then need garbage-collecting every
+-- time a Team archived. Four fixed streams plus this table is the same feature
+-- with nothing to collect.
+--
+-- `last_digest_at` is the digest's ONLY state. There is no queue of pending items:
+-- the worker asks what arrived after this timestamp and re-runs the access
+-- resolver, so a deployment that was down for a day sends one correct digest
+-- rather than replaying a backlog, and a user who lost forum access between the
+-- post and the send is not emailed content they can no longer read.
+CREATE TABLE IF NOT EXISTS team_notification_prefs (
+ user_id INT NOT NULL,
+ team_id INT NOT NULL,
+ muted TINYINT(1) NOT NULL DEFAULT 0,
+ -- 'off', and NOT the design-of-record's 'digest'. Digest-by-default would mean
+ -- every member of every Team starts receiving daily mail the moment an operator
+ -- connects Gmail, which is a decision about other people's inboxes made on their
+ -- behalf. Email is therefore the one sink here that is opt-IN; the mute is still
+ -- opt-out, because a mute silences something the user already asked for.
+ --
+ -- It also keeps this column honest as a deviation-from-default: a row written to
+ -- set `muted` alone leaves email exactly where it was.
+ email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off',
+ last_digest_at DATETIME NULL,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, team_id),
+ CONSTRAINT fk_tnp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_tnp_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
+ -- The digest worker's driving query is "rows in digest mode, oldest send first",
+ -- which is a scan of this index rather than of every preference ever written.
+ INDEX idx_tnp_digest (email_mode, last_digest_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
diff --git a/server/routes.guards.json b/server/routes.guards.json
index 2d8b6dc..343ac05 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -1387,6 +1387,26 @@
"validate"
]
},
+ {
+ "method": "GET",
+ "path": "/api/v1/auth/me/notifications/teams",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/auth/me/notifications/teams",
+ "handlers": 6,
+ "gates": [
+ "noindex",
+ "requireAuth",
+ "middleware",
+ "validate"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/auth/me/sessions",
@@ -1944,6 +1964,18 @@
"siteMode"
]
},
+ {
+ "method": "GET",
+ "path": "/api/v1/public/teams/unsubscribe/:token",
+ "handlers": 1,
+ "gates": []
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/public/teams/unsubscribe/:token",
+ "handlers": 1,
+ "gates": []
+ },
{
"method": "GET",
"path": "/api/v1/public/version",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index b6be23f..30bf058 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -549,6 +549,14 @@
"method": "PUT",
"path": "/api/v1/auth/me/notifications/subscriptions"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/auth/me/notifications/teams"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/auth/me/notifications/teams"
+ },
{
"method": "GET",
"path": "/api/v1/auth/me/sessions"
@@ -789,6 +797,14 @@
"method": "GET",
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/public/teams/unsubscribe/:token"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/public/teams/unsubscribe/:token"
+ },
{
"method": "GET",
"path": "/api/v1/public/version"
diff --git a/server/src/config/coreStreams.js b/server/src/config/coreStreams.js
index 5c839e1..20e3bc1 100644
--- a/server/src/config/coreStreams.js
+++ b/server/src/config/coreStreams.js
@@ -2,13 +2,18 @@
//
// What is left of config/notificationStreams.js once the shard-derived catalog
// moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is
-// core, the CATALOG is content). Exactly one stream is core's: `news.post` is
-// produced by the website's own posts path, not by any game feed.
+// core, the CATALOG is content). `news.post` is produced by the website's own
+// posts path, not by any game feed, and the four `team.*` streams by core's own
+// Team sync and forum.
//
// Registered through modules/registries.js like any module's, and read back
// through it — nothing imports this file to get "the catalog", because the
// catalog is core's plus every module's.
//
+// Phase 6 added the four Team streams below. They are core's for the same reason
+// the Team tables are: a module supplies who is in a Team, but who may be told
+// about it is the access resolver's answer, and that is core's (TEAMS.md Part 6).
+//
// The payload that ever leaves the server is a CONTENT-FREE tickle
// ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content
// over the authenticated API (docs/android/PLAN.md §11).
@@ -21,6 +26,55 @@ const STREAMS = [
personal: false,
requiresLinkedAccount: false,
},
+ // ── Teams (TEAMS.md §6.2, phase 6) ───────────────────────────────────────
+ //
+ // FOUR streams, and not one per Team. The catalog is a static registration
+ // validated at boot; it has no way to express an unbounded runtime-created set,
+ // and a stream id per Team would leave rows in notification_subscriptions to
+ // collect every time a Team archived. Which Team an event came from lives in
+ // the RECIPIENT SET (utils/teamNotify.js) and in the `ref`, never in the id.
+ //
+ // `requiresLinkedAccount: false` on all four is deliberate and reads oddly.
+ // These are game-sourced events, so the instinct is to demand a linked game
+ // account — but a forum-granted user with no game identity at all is exactly
+ // the population §2.5 path 3 exists for, and they are a legitimate recipient of
+ // `team.forum.post`. The flag would refuse them a toggle they have every right
+ // to. What enforces who gets what is the recipient computation, which asks the
+ // access resolver; the stream flag is not a second, weaker copy of that rule.
+ //
+ // `personal: false` for the same reason it is false on news.post: these are not
+ // owner-keyed events about one account's own property. `publishToUsers` is a
+ // third fan-out shape alongside "everyone subscribed" and "this one owner", and
+ // the catalog has no flag for it because the flag would say nothing a caller
+ // does not already know by choosing the function.
+ {
+ id: 'team.member.joined',
+ label: 'Team — new member',
+ description: 'Someone joined a Team you belong to.',
+ personal: false,
+ requiresLinkedAccount: false,
+ },
+ {
+ id: 'team.leadership.changed',
+ label: 'Team — leadership change',
+ description: 'Leadership changed in a Team you belong to.',
+ personal: false,
+ requiresLinkedAccount: false,
+ },
+ {
+ id: 'team.forum.post',
+ label: 'Team — new forum post',
+ description: 'A new thread or reply in a Team forum you can read.',
+ personal: false,
+ requiresLinkedAccount: false,
+ },
+ {
+ id: 'team.announcement',
+ label: 'Team — announcements',
+ description: 'A leader posted an announcement in a Team you can read.',
+ personal: false,
+ requiresLinkedAccount: false,
+ },
]
module.exports = { STREAMS }
diff --git a/server/src/model/pushDevices/pushDevices.db.js b/server/src/model/pushDevices/pushDevices.db.js
index 04b8492..0b17e7f 100644
--- a/server/src/model/pushDevices/pushDevices.db.js
+++ b/server/src/model/pushDevices/pushDevices.db.js
@@ -48,4 +48,44 @@ const endpointsForUserStream = (userId, streamId) =>
[userId, streamId],
)
-module.exports = { upsert, getByUserEndpoint, listByUser, remove, endpointsForStream, endpointsForUserStream }
+// `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
+// integer, so a null slipping into a caller's list would become user id 0 and
+// ride into an IN clause. No row has id 0, so it is harmless today — which is
+// exactly why it would never be noticed.
+const isUserId = (n) => Number.isInteger(n) && n > 0
+
+// Endpoints of a COMPUTED SET of users' devices, each still gated on that user's
+// own subscription (TEAMS.md §6.2's third fan-out shape).
+//
+// The set is the whole Team-scoping mechanism: the four `team.*` streams are
+// global, and which Team an event belongs to is expressed by who is in `userIds`
+// rather than by a stream id per Team. The caller has already resolved access and
+// subtracted mutes; this function's only remaining job is to honour each
+// recipient's own opt-in, which is why the JOIN is here and not left to the
+// caller — a fan-out that skipped it would deliver to a user who had turned the
+// stream off.
+//
+// Returns [] for an empty set rather than building `IN ()`, which is a syntax
+// error in MariaDB. That case is common, not exceptional: most Team events have
+// no subscribed recipients on a deployment with no app installed at all.
+async function endpointsForUsersStream(userIds, streamId) {
+ const ids = [...new Set((userIds || []).map(Number).filter(isUserId))]
+ if (ids.length === 0) return []
+ return query(
+ `SELECT d.endpoint, d.transport
+ FROM push_devices d
+ JOIN notification_subscriptions s ON s.user_id = d.user_id
+ WHERE s.stream_id = ? AND d.user_id IN (${ids.map(() => '?').join(',')})`,
+ [streamId, ...ids],
+ )
+}
+
+module.exports = {
+ upsert,
+ getByUserEndpoint,
+ listByUser,
+ remove,
+ endpointsForStream,
+ endpointsForUserStream,
+ endpointsForUsersStream,
+}
diff --git a/server/src/model/pushDevices/pushDevices.model.js b/server/src/model/pushDevices/pushDevices.model.js
index f42125a..0e94dbe 100644
--- a/server/src/model/pushDevices/pushDevices.model.js
+++ b/server/src/model/pushDevices/pushDevices.model.js
@@ -28,5 +28,13 @@ const remove = async (id, userId) => (await db.remove(id, userId)) > 0
// Fan-out helpers: raw { endpoint, transport } rows (not toSafe-shaped).
const endpointsForStream = (streamId) => db.endpointsForStream(streamId)
const endpointsForUserStream = (userId, streamId) => db.endpointsForUserStream(userId, streamId)
+const endpointsForUsersStream = (userIds, streamId) => db.endpointsForUsersStream(userIds, streamId)
-module.exports = { register, listForUser, remove, endpointsForStream, endpointsForUserStream }
+module.exports = {
+ register,
+ listForUser,
+ remove,
+ endpointsForStream,
+ endpointsForUserStream,
+ endpointsForUsersStream,
+}
diff --git a/server/src/model/teams/teamForum.model.js b/server/src/model/teams/teamForum.model.js
index a5d8506..daa9692 100644
--- a/server/src/model/teams/teamForum.model.js
+++ b/server/src/model/teams/teamForum.model.js
@@ -217,7 +217,17 @@ async function createThread({ team, actor, type, title, body }) {
authorUsername: actor.username,
bodyHtml: cleaned,
})
- return { ok: true, threadId, postId }
+ // `notify` is what the CONTROLLER needs to fan a notification out, and it is a
+ // separate key rather than more fields on the result because the controller
+ // spreads the result straight into the response body — a notification's excerpt
+ // is not part of the API's answer to "did my post save".
+ //
+ // The notification itself is fired from the controller and not from here, on
+ // this file's own rule (see the header): everything in it takes an
+ // already-resolved access decision and reads no membership table. The fan-out
+ // reads both, so importing it here would make the forum model transitively
+ // depend on exactly what it exists not to touch.
+ return { ok: true, threadId, postId, notify: { threadId, title, type, bodyHtml: cleaned } }
}
/**
@@ -256,7 +266,11 @@ async function createPost({ team, threadId, actor, body }) {
authorUsername: actor.username,
bodyHtml: cleaned,
})
- return { ok: true, threadId, postId }
+ // The thread's OWN title and type, not the reply's — a reply has neither, and
+ // what a recipient needs to know is which conversation moved. `type` is always
+ // 'discussion' here (an announcement takes no replies) and is carried anyway so
+ // the controller has one shape to hand the fan-out from both routes.
+ return { ok: true, threadId, postId, notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned } }
}
/**
diff --git a/server/src/model/teams/teamNotify.db.js b/server/src/model/teams/teamNotify.db.js
new file mode 100644
index 0000000..7beb8ee
--- /dev/null
+++ b/server/src/model/teams/teamNotify.db.js
@@ -0,0 +1,223 @@
+// SQL for Team notification recipients and per-Team preferences (TEAMS.md Part 6).
+//
+// **The recipient set is the whole of Team scoping.** The four `team.*` streams
+// are global and carry no Team in their id; who an event reaches is decided here.
+// That is §6.2's design and it is not an optimisation — the push catalog is a
+// static registration validated at boot, so a stream per Team is unexpressible,
+// and stream ids live in `notification_subscriptions` rows that a per-Team id
+// would leave behind every time a Team archived.
+//
+// **One recipient query serves all four streams**, because the two populations in
+// §6.2's table are the same set written twice: "active members with a user_id,
+// plus active forum grants" IS "everyone with resolved forum access", by the
+// definition of teamAccess.forumAccess() (membership OR grant). What differs
+// between the streams is only who is subtracted — the author of the post that
+// caused it — and that is a caller's argument, not a second query.
+//
+// **Mutes are subtracted in SQL, not in the caller.** A recipient list that came
+// back complete and was filtered afterwards would be one refactor away from being
+// used unfiltered; there is no function here that returns an unmuted set.
+
+const { query } = require('../../utils/db')
+
+// The union, as a derived table both recipient functions build on. Written once
+// so that "who is in a Team for notification purposes" has exactly one definition.
+//
+// `status = 'active'` on the membership half and `revoked_at IS NULL` on the
+// grant half are the same two conditions the access resolver uses; a departed
+// member and a revoked guest are both people who could still be read a private
+// forum by a query that forgot one.
+const RECIPIENT_UNION = `
+ SELECT user_id FROM team_members
+ WHERE team_id = ? AND status = 'active' AND user_id IS NOT NULL
+ UNION
+ SELECT user_id FROM team_forum_grants
+ WHERE team_id = ? AND revoked_at IS NULL`
+
+// `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
+// integer, so a null slipping into a caller's list would become user id 0 and
+// ride into an IN clause. No row has id 0, so it is harmless today — which is
+// exactly why it would never be noticed.
+const isUserId = (n) => Number.isInteger(n) && n > 0
+
+/**
+ * Every user id that may be notified about `teamId`, mutes already removed.
+ *
+ * `exclude` is the author of the thing that happened. Passed rather than removed
+ * afterwards for the reason in the header, and taken as a list because a caller
+ * with nobody to exclude should not have to invent a sentinel.
+ */
+async function recipientIds(teamId, { exclude = [] } = {}) {
+ const skip = [...new Set(exclude.map(Number).filter(isUserId))]
+ const notMe = skip.length ? `AND r.user_id NOT IN (${skip.map(() => '?').join(',')})` : ''
+ const rows = await query(
+ `SELECT DISTINCT r.user_id
+ FROM (${RECIPIENT_UNION}) r
+ LEFT JOIN team_notification_prefs p ON p.user_id = r.user_id AND p.team_id = ?
+ WHERE COALESCE(p.muted, 0) = 0 ${notMe}`,
+ [teamId, teamId, teamId, ...skip],
+ )
+ return rows.map((r) => Number(r.user_id))
+}
+
+/**
+ * The same set, narrowed to those reachable by EMAIL and carrying each one's mode.
+ *
+ * A separate query rather than a join onto `recipientIds` because email has two
+ * conditions push does not: an address to send to, and an account still allowed to
+ * have one. A banned or disabled account keeps its forum grant in the ledger —
+ * revoking it is a separate staff decision — but must not keep receiving the
+ * Team's private discussion in its inbox.
+ *
+ * `email_mode` is COALESCEd to the column default rather than read as NULL — and
+ * that default is `'off'`, so this query returns the whole set with most of it
+ * marked as not wanting mail. Filtering to a mode is the CALLER's job, because
+ * `immediate` and `digest` are consumed by two different senders.
+ */
+async function emailRecipients(teamId, { exclude = [] } = {}) {
+ const skip = [...new Set(exclude.map(Number).filter(isUserId))]
+ const notMe = skip.length ? `AND u.id NOT IN (${skip.map(() => '?').join(',')})` : ''
+ return query(
+ `SELECT u.id AS user_id, u.username, u.email,
+ COALESCE(p.email_mode, 'off') AS email_mode,
+ p.last_digest_at
+ FROM (${RECIPIENT_UNION}) r
+ JOIN users u ON u.id = r.user_id
+ LEFT JOIN team_notification_prefs p ON p.user_id = u.id AND p.team_id = ?
+ WHERE COALESCE(p.muted, 0) = 0
+ AND u.email IS NOT NULL AND u.email <> ''
+ AND u.status = 'active' ${notMe}
+ GROUP BY u.id, u.username, u.email, p.email_mode, p.last_digest_at`,
+ [teamId, teamId, teamId, ...skip],
+ )
+}
+
+// ── Preferences ────────────────────────────────────────────────────────────
+
+/**
+ * One row per Team this user may be notified about, whether or not a preference
+ * has ever been written for it — the account screen has to offer a Team the user
+ * has never touched, and a list built from the prefs table alone would be empty
+ * for exactly the users who have configured nothing.
+ *
+ * Archived Teams appear only when a preference row exists for them, so a mute the
+ * user set does not vanish from the screen the moment a guild disbands, while a
+ * disbanded guild nobody configured does not linger on it forever.
+ */
+async function prefsForUser(userId) {
+ return query(
+ `SELECT t.id AS team_id, t.slug, t.name, t.display_name_override, t.status AS team_status,
+ COALESCE(p.muted, 0) AS muted,
+ COALESCE(p.email_mode, 'off') AS email_mode
+ FROM teams t
+ LEFT JOIN team_notification_prefs p ON p.team_id = t.id AND p.user_id = ?
+ WHERE (
+ EXISTS (SELECT 1 FROM team_members m
+ WHERE m.team_id = t.id AND m.user_id = ? AND m.status = 'active')
+ OR EXISTS (SELECT 1 FROM team_forum_grants g
+ WHERE g.team_id = t.id AND g.user_id = ? AND g.revoked_at IS NULL)
+ OR p.user_id IS NOT NULL
+ )
+ ORDER BY t.status, t.name`,
+ [userId, userId, userId],
+ )
+}
+
+/** One Team's preference for one user, or undefined. Read by the mute toggle. */
+async function prefFor(userId, teamId) {
+ const rows = await query(
+ `SELECT team_id, muted, email_mode, last_digest_at
+ FROM team_notification_prefs WHERE user_id = ? AND team_id = ?`,
+ [userId, teamId],
+ )
+ return rows[0]
+}
+
+/**
+ * Write one preference.
+ *
+ * An upsert that touches ONLY the columns it was given: the one-click unsubscribe
+ * writes `muted` and must not reset an `email_mode` the user chose, and the
+ * settings screen writes both. `last_digest_at` is never written here — it is the
+ * worker's column, and a preference change must not look like a delivery.
+ */
+async function setPref(userId, teamId, { muted, emailMode }) {
+ const sets = ['updated_at = CURRENT_TIMESTAMP']
+ if (muted != null) sets.push('muted = VALUES(muted)')
+ if (emailMode != null) sets.push('email_mode = VALUES(email_mode)')
+ await query(
+ `INSERT INTO team_notification_prefs (user_id, team_id, muted, email_mode)
+ VALUES (?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE ${sets.join(', ')}`,
+ [userId, teamId, muted ? 1 : 0, emailMode || 'off'],
+ )
+}
+
+/** Stamp a digest as delivered. The worker's column, and its only writer. */
+async function stampDigest(userId, teamId, at) {
+ await query(
+ `INSERT INTO team_notification_prefs (user_id, team_id, last_digest_at)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE last_digest_at = VALUES(last_digest_at)`,
+ [userId, teamId, at],
+ )
+}
+
+/**
+ * Active Teams that have had forum activity since `since` — the digest worker's
+ * driving query.
+ *
+ * Driven from ACTIVITY rather than from the prefs table, which is what makes the
+ * worker's cost proportional to what was WRITTEN rather than to how many people
+ * once opened a settings screen. A Team nobody posted in costs one row of this
+ * query and no recipient computation at all.
+ */
+async function teamsWithForumActivitySince(since) {
+ return query(
+ `SELECT DISTINCT t.id, t.slug, t.name, t.display_name_override
+ FROM teams t
+ JOIN team_forum_threads th ON th.team_id = t.id
+ JOIN team_forum_posts po ON po.thread_id = th.id
+ WHERE t.status = 'active'
+ AND po.created_at > ?
+ AND po.status = 'visible'
+ AND th.status = 'visible'`,
+ [since],
+ )
+}
+
+/**
+ * The posts one digest covers: visible posts in visible threads, newer than the
+ * recipient's own `since`.
+ *
+ * Re-read at send time rather than accumulated at publish time. A queue of pending
+ * items would have to be garbage-collected, would replay a backlog after an outage,
+ * and — the reason that actually matters — could email a body a moderator hid in
+ * between. This query cannot: a hidden post is simply not in it.
+ */
+async function digestPostsSince(teamId, since, limit = 20) {
+ return query(
+ `SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_username,
+ th.title, th.type
+ FROM team_forum_posts po
+ JOIN team_forum_threads th ON th.id = po.thread_id
+ WHERE th.team_id = ?
+ AND po.created_at > ?
+ AND po.status = 'visible'
+ AND th.status = 'visible'
+ ORDER BY po.created_at
+ LIMIT ?`,
+ [teamId, since, Number(limit)],
+ )
+}
+
+module.exports = {
+ recipientIds,
+ emailRecipients,
+ prefsForUser,
+ prefFor,
+ setPref,
+ stampDigest,
+ teamsWithForumActivitySince,
+ digestPostsSince,
+}
diff --git a/server/src/model/teams/teamNotify.model.js b/server/src/model/teams/teamNotify.model.js
new file mode 100644
index 0000000..a085642
--- /dev/null
+++ b/server/src/model/teams/teamNotify.model.js
@@ -0,0 +1,146 @@
+// Per-Team notification preferences, and the recipient sets built from them
+// (TEAMS.md §6.2–§6.4, phase 6).
+//
+// **The absence of a row is the default, and the two sinks default OPPOSITE ways.**
+// Push is opt-out: a user in one Team must never have to configure anything to be
+// tickled about it, and the per-Team mute is how they stop. Email is opt-IN
+// (`email_mode` defaults to `'off'`, deviating from §6.4 on the org lead's call):
+// turning on Gmail in the admin panel must not start sending daily mail to every
+// member of every Team on the deployment.
+//
+// Both are read the same way — COALESCE to the column default, never treat a
+// missing row as "unknown" — so the asymmetry lives in ONE place, the schema, and
+// not in a condition anybody has to remember.
+//
+// **This file never decides who may READ a Team.** It asks the same two tables
+// teamAccess.forumAccess() asks, in one query, because a fan-out cannot afford a
+// round trip per recipient — but it asks them for the same answer. If the access
+// rule ever changes, both must; the SQL in teamNotify.db.js says so at the union
+// it builds on, and the test that matters is the one asserting a revoked guest
+// receives nothing.
+
+const db = require('./teamNotify.db')
+
+// Stored as an ENUM, restated here because a value arriving from a request body
+// must be checked against something in JavaScript before it reaches the column —
+// a bad value would otherwise be a 500 from the driver rather than a 400 from us.
+const EMAIL_MODES = ['off', 'digest', 'immediate']
+
+const isEmailMode = (v) => EMAIL_MODES.includes(v)
+
+function publicPref(row) {
+ return {
+ teamId: Number(row.team_id),
+ slug: row.slug,
+ // The same `display_name_override || name` rule every other Team surface
+ // uses (§2.8.3). A notification screen showing the raw name would show a name
+ // staff have deliberately replaced everywhere else.
+ name: row.display_name_override || row.name,
+ archived: row.team_status === 'archived',
+ muted: Boolean(Number(row.muted)),
+ emailMode: row.email_mode,
+ }
+}
+
+/** Every Team this user could be notified about, with its current preference. */
+async function listPrefs(userId) {
+ return (await db.prefsForUser(userId)).map(publicPref)
+}
+
+/** One Team's preference for one user, defaults applied. Never null. */
+async function prefFor(userId, teamId) {
+ const row = await db.prefFor(userId, teamId)
+ return {
+ teamId: Number(teamId),
+ muted: Boolean(row && Number(row.muted)),
+ emailMode: (row && row.email_mode) || 'off',
+ }
+}
+
+/**
+ * Replace this user's whole set of Team preferences.
+ *
+ * PUT-the-whole-set, matching the existing subscription endpoint, and the
+ * Android gotcha carried forward from `docs/android/PLAN.md` §11 applies to the
+ * ROUTE rather than to this function: the array is required even when empty.
+ *
+ * **A preference may only be written for a Team the caller is actually in.** The
+ * ids are checked against `listPrefs`, not trusted from the body — otherwise any
+ * authenticated user could write a row naming any Team, which is a (small) write
+ * primitive into a table keyed by someone else's private membership. Unknown ids
+ * are dropped rather than 400'd: a Team the user left between loading the screen
+ * and saving it is an ordinary race, not a client bug.
+ */
+async function replacePrefs(userId, entries) {
+ const allowed = new Map((await listPrefs(userId)).map((p) => [p.teamId, p]))
+ const written = []
+ for (const entry of entries) {
+ const teamId = Number(entry && entry.teamId)
+ if (!allowed.has(teamId)) continue
+ const emailMode = isEmailMode(entry.emailMode) ? entry.emailMode : 'off'
+ // eslint-disable-next-line no-await-in-loop
+ await db.setPref(userId, teamId, { muted: Boolean(entry.muted), emailMode })
+ written.push(teamId)
+ }
+
+ // A Team the caller COULD have named and did not is returned to its defaults.
+ //
+ // Without this, "replace the whole set" was a lie the endpoint told: omitting an
+ // entry left the old preference standing, which made `teams: []` — the body the
+ // route requires precisely so that clearing everything is expressible — clear
+ // nothing at all.
+ //
+ // Reset rather than deleted, and the difference is `last_digest_at`. That column
+ // is the digest worker's state, not a preference; dropping the row with it would
+ // make every visit to the settings screen re-open a day-wide digest window and
+ // mail somebody a summary they already read.
+ for (const teamId of allowed.keys()) {
+ if (written.includes(teamId)) continue
+ // eslint-disable-next-line no-await-in-loop
+ await db.setPref(userId, teamId, { muted: false, emailMode: 'off' })
+ }
+
+ return { written, prefs: await listPrefs(userId) }
+}
+
+/**
+ * Mute one Team for one user — the one-click unsubscribe's only effect.
+ *
+ * Deliberately narrow. The unsubscribe link is reached without a session, so what
+ * it can do is what an attacker holding a leaked link can do: silence one Team's
+ * notifications for one account, visibly and reversibly on the account screen.
+ * It writes no other column, and there is no "unsubscribe from everything".
+ */
+async function mute(userId, teamId) {
+ await db.setPref(userId, teamId, { muted: true })
+}
+
+/** Un-mute, for the toggle's other half. */
+async function unmute(userId, teamId) {
+ await db.setPref(userId, teamId, { muted: false })
+}
+
+module.exports = {
+ EMAIL_MODES,
+ isEmailMode,
+ listPrefs,
+ prefFor,
+ replacePrefs,
+ mute,
+ unmute,
+ // Recipient sets, passed through so callers depend on the model rather than on
+ // the SQL. The fan-out in utils/teamNotify.js and the digest worker are the only
+ // callers.
+ //
+ // Wrapped rather than re-exported (`recipientIds: db.recipientIds`), which is
+ // the obvious shorter form and is wrong: that captures the function OBJECT at
+ // require time, so the layer below can never be substituted afterwards — which
+ // makes the db layer untestable in isolation and, more to the point, means the
+ // model is not really the seam it claims to be. These resolve `db.x` at call
+ // time, so the boundary is real.
+ recipientIds: (teamId, opts) => db.recipientIds(teamId, opts),
+ emailRecipients: (teamId, opts) => db.emailRecipients(teamId, opts),
+ stampDigest: (userId, teamId, at) => db.stampDigest(userId, teamId, at),
+ teamsWithForumActivitySince: (since) => db.teamsWithForumActivitySince(since),
+ digestPostsSince: (teamId, since, limit) => db.digestPostsSince(teamId, since, limit),
+}
diff --git a/server/src/model/teams/teamSync.model.js b/server/src/model/teams/teamSync.model.js
index 067f41f..395dbaa 100644
--- a/server/src/model/teams/teamSync.model.js
+++ b/server/src/model/teams/teamSync.model.js
@@ -33,6 +33,7 @@ const teamsDb = require('./teams.db')
const teamProvider = require('./teamProvider')
const moderation = require('./teamModeration.model')
const activity = require('./teamActivity.model')
+const teamNotify = require('../../utils/teamNotify')
const { slugify, uniqueSlug } = require('./teamSlug')
const settings = require('../settings/settings.model')
const log = require('../../utils/logger')('teams')
@@ -191,6 +192,34 @@ async function logRosterActivity(team, { joined, left, promoted, demoted }) {
}
}
+/**
+ * The push half of the same roster run (TEAMS.md §6.2, phase 6).
+ *
+ * **At most one tickle per stream per run, not one per member.** A tickle is
+ * content-free — it says "something happened in this Team" and the app pulls the
+ * rest — so five people joining in one sweep is five identical notifications and
+ * one piece of information. The feed above is per-member because it is a record;
+ * this is per-run because it is a nudge.
+ *
+ * **Suppressed on a Team's FIRST roster, exactly as the feed is**, and this is the
+ * half where it matters more: importing a 155-member guild would otherwise wake
+ * every one of their phones. `roster_synced_at IS NULL` is the same condition, read
+ * from the same row before the same stamp moves.
+ *
+ * Never throws — the fan-out swallows its own failures, and this adds the guard
+ * for anything the surrounding read could raise. A roster sync is the source of
+ * truth; a notification about it is not.
+ */
+async function notifyRoster(team, { joined, promoted, demoted }) {
+ if (!team.roster_synced_at) return
+ try {
+ if (joined.length > 0) await teamNotify.memberJoined(team)
+ if (promoted.length > 0 || demoted.length > 0) await teamNotify.leadershipChanged(team)
+ } catch (err) {
+ log.warn('roster notification not sent', { teamId: team.id, message: err.message })
+ }
+}
+
/**
* Sync one Team's roster and leadership. Gates 3 and 4 live here.
*
@@ -285,8 +314,10 @@ async function syncRoster(team) {
}
await teamsDb.recount(team.id)
- // Read before `markRosterSynced` moves the stamp this decision turns on.
+ // Both read before `markRosterSynced` moves the stamp their first-roster
+ // suppression turns on.
await logRosterActivity(team, { joined, left: left.filter(Boolean), promoted, demoted })
+ await notifyRoster(team, { joined, promoted, demoted })
await teamsDb.markRosterSynced(team.id)
return true
}
diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js
index e0fa24c..21a77a7 100644
--- a/server/src/modules/registries.js
+++ b/server/src/modules/registries.js
@@ -255,6 +255,10 @@ function checkLegShape(entry) {
// implementing it means core fails CLOSED when the call cannot be made, so this
// is a member to add deliberately rather than by habit.
//
+// `pageUrlTemplate` is the fifth, also OPTIONAL, and is data rather than a method
+// — see its own comment below. A module that omits it costs its deployment
+// clickable links in Team notification email and nothing else.
+//
// The copy is explicit rather than a spread: this object is what core calls, so
// anything not named here is not part of the contract and must not survive
// registration. A method that silently rode along would look implemented from the
@@ -274,9 +278,43 @@ function checkTeamProviderShape(entry) {
}
out.projectRoster = provider.projectRoster
}
+ if (provider.pageUrlTemplate !== undefined) {
+ out.pageUrlTemplate = checkPageUrlTemplate(provider.pageUrlTemplate)
+ }
return out
}
+// `pageUrlTemplate` is the fifth member and OPTIONAL (TEAMS.md §6.4, phase 6).
+//
+// **Why a module has to supply this at all.** Teams are a contract primitive with
+// no core surface: core owns the tables and the access rules, and the MODULE owns
+// the page, because core does not own the word for a Team. That is settled and
+// right — but it leaves core unable to write a link to one, and a notification
+// email that cannot link to the thread it is about is most of the way to useless.
+// So the module that owns the page says where it is.
+//
+// **A template, not a callback.** Core substitutes `{externalId}` and `{slug}`
+// into a relative path and does nothing else with it. A function would be a
+// module hook on the mail path — one more thing that can hang or throw between a
+// forum reply and the mail about it — to produce a string that never varies.
+//
+// Validated hard, because the output goes into an email as a link. Relative only:
+// a template naming its own host would let a module redirect the site's outbound
+// mail somewhere else, and there is no reason for one to.
+// One leading slash, and the second character may not be another. `//evil.test/x`
+// passes an "is it rooted" check and is a PROTOCOL-RELATIVE url — core prefixing
+// its own base makes it harmless today, but a template is a string that ends up
+// in an href sooner or later, and this is a character class rather than a
+// judgement call about who concatenates it.
+const PAGE_URL_TEMPLATE = /^\/(?!\/)[A-Za-z0-9\-._~/{}]*$/
+
+function checkPageUrlTemplate(value) {
+ if (typeof value !== 'string' || !PAGE_URL_TEMPLATE.test(value)) {
+ throw new Error(`registerTeamProvider: pageUrlTemplate must be a relative path, got "${value}"`)
+ }
+ return value
+}
+
/**
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
* required. A registration with neither is a subscription that can never fire,
diff --git a/server/src/router/v1/auth/notifications.controller.js b/server/src/router/v1/auth/notifications.controller.js
index a05e19d..f2b4889 100644
--- a/server/src/router/v1/auth/notifications.controller.js
+++ b/server/src/router/v1/auth/notifications.controller.js
@@ -6,6 +6,7 @@
const pushDevices = require('../../../model/pushDevices/pushDevices.model')
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
const registries = require('../../../modules/registries')
+const teamPrefs = require('../../../model/teams/teamNotify.model')
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
const log = require('../../../utils/logger')('notifications')
@@ -78,6 +79,39 @@ async function putSubscriptions(req, res) {
}
}
+// GET /auth/me/notifications/teams — this user's per-Team preferences, one row
+// per Team they could be notified about whether or not they have ever set one.
+//
+// Not gated on `teams_forums_enabled`: two of the four streams (member joined,
+// leadership changed) have nothing to do with the forum, so a deployment with
+// forums switched off still has preferences worth showing.
+async function getTeamPrefs(req, res) {
+ try {
+ return res.json({ teams: await teamPrefs.listPrefs(req.user.id) })
+ } catch (err) {
+ log.error('getTeamPrefs', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// PUT /auth/me/notifications/teams — replace the caller's whole preference set.
+//
+// PUT-the-whole-set, matching the subscriptions endpoint beside it, and the
+// `teams` array is REQUIRED even when empty — the Android gotcha in
+// docs/android/PLAN.md §11: a DTO field with a default is dropped by kotlinx when
+// it equals that default, so clearing the last entry would arrive as a body with
+// no array at all and 400. Entries naming a Team the caller is not in are dropped
+// by the model rather than refused here (an ordinary race, not a client bug).
+async function putTeamPrefs(req, res) {
+ try {
+ const { prefs } = await teamPrefs.replacePrefs(req.user.id, req.body.teams)
+ return res.json({ teams: prefs })
+ } catch (err) {
+ log.error('putTeamPrefs', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
module.exports = {
registerDevice,
listDevices,
@@ -85,4 +119,6 @@ module.exports = {
getStreams,
getSubscriptions,
putSubscriptions,
+ getTeamPrefs,
+ putTeamPrefs,
}
diff --git a/server/src/router/v1/auth/notifications.routes.js b/server/src/router/v1/auth/notifications.routes.js
index 579777f..d3b6731 100644
--- a/server/src/router/v1/auth/notifications.routes.js
+++ b/server/src/router/v1/auth/notifications.routes.js
@@ -13,6 +13,7 @@ const notif = require('./notifications.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
+const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model')
const notifRouter = express.Router()
@@ -97,4 +98,39 @@ notifRouter.put(
notif.putSubscriptions,
)
+// ── Per-Team preferences (TEAMS.md §6.3, phase 6) ──────────────────────────
+//
+// The granularity per-stream opt-in cannot express: "I am in five Teams and want
+// notifications from one". Opt-OUT for push (no row means notified) and opt-IN
+// for email, so a user who never opens this screen is in the state the schema
+// documents rather than in one this router has to describe.
+notifRouter.get(
+ '/notifications/teams',
+ // #swagger.tags = ['Auth · Me']
+ // #swagger.summary = 'Get the current user’s per-Team notification preferences'
+ // #swagger.description = 'One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they have a stored preference for. Defaults are applied server-side: `muted` false, `emailMode` "off".'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Per-Team preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ notif.getTeamPrefs,
+)
+
+notifRouter.put(
+ '/notifications/teams',
+ // #swagger.tags = ['Auth · Me']
+ // #swagger.summary = 'Replace the current user’s per-Team notification preferences'
+ // #swagger.description = 'Replaces the whole set. The `teams` array is required even when empty. Entries naming a Team the caller has no access to are ignored; the stored set is echoed back.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
+ /* #swagger.responses[200] = { description: 'Updated preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
+ /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
+ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ body('teams').isArray(),
+ body('teams.*.teamId').isInt({ min: 1 }),
+ body('teams.*.muted').optional().isBoolean(),
+ body('teams.*.emailMode').optional().isIn(EMAIL_MODES),
+ validate,
+ notif.putTeamPrefs,
+)
+
module.exports = notifRouter
diff --git a/server/src/router/v1/player/teamForum.controller.js b/server/src/router/v1/player/teamForum.controller.js
index 204c248..ac3250a 100644
--- a/server/src/router/v1/player/teamForum.controller.js
+++ b/server/src/router/v1/player/teamForum.controller.js
@@ -26,6 +26,7 @@ const forumSettings = require('../../../model/teams/teamForumSettings.model')
const uploads = require('../../../model/teams/teamForumUploads.model')
const reports = require('../../../model/reports/contentReports.model')
const activity = require('../../../model/activity/activity.model')
+const teamNotify = require('../../../utils/teamNotify')
const log = require('../../../utils/logger')('teams')
@@ -86,6 +87,34 @@ async function viewerFor(ctx, user) {
}
}
+/**
+ * Fan a new thread or reply out to the Team (TEAMS.md Part 6, phase 6).
+ *
+ * **Here rather than in the forum model**, because the model takes an
+ * already-resolved access decision and reads no membership table by design, and
+ * the fan-out reads both to compute its recipients. A notification call inside the
+ * model would make it transitively depend on what its own header says it must not.
+ *
+ * **Awaited, and it still cannot fail the request.** `teamNotify.forumPost` catches
+ * everything and returns; awaiting it costs the response the time of one recipient
+ * query plus, in `immediate` mode, the SMTP calls — which is why the alternative
+ * (fire-and-forget) is tempting and wrong here: an un-awaited rejection in an
+ * Express handler is an unhandled rejection, and the tests would have no moment at
+ * which to assert the fan-out happened.
+ */
+async function announce(ctx, actor, notify) {
+ if (!notify) return
+ await teamNotify.forumPost({
+ team: ctx.team,
+ threadId: notify.threadId,
+ threadTitle: notify.title,
+ type: notify.type,
+ authorUserId: actor.id,
+ authorName: actor.username,
+ bodyHtml: notify.bodyHtml,
+ })
+}
+
// ── threads ────────────────────────────────────────────────────────────────
async function listThreads(req, res) {
@@ -151,13 +180,14 @@ async function createThread(req, res) {
return res.status(403).json({ message: 'Only Team leaders may post announcements' })
}
- const result = await forum.createThread({
+ const { notify, ...result } = await forum.createThread({
team: ctx.team,
actor: req.user,
type,
title: req.body.title,
body: req.body.body,
})
+ if (result.ok) await announce(ctx, req.user, notify)
return send(res, result)
} catch (err) {
return fail(res, err, 'create thread')
@@ -170,12 +200,14 @@ async function createPost(req, res) {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
- return send(res, await forum.createPost({
+ const { notify, ...result } = await forum.createPost({
team: ctx.team,
threadId: Number(req.params.id),
actor: req.user,
body: req.body.body,
- }))
+ })
+ if (result.ok) await announce(ctx, req.user, notify)
+ return send(res, result)
} catch (err) {
return fail(res, err, 'create post')
}
diff --git a/server/src/router/v1/public/teams.controller.js b/server/src/router/v1/public/teams.controller.js
index d2be486..b41825f 100644
--- a/server/src/router/v1/public/teams.controller.js
+++ b/server/src/router/v1/public/teams.controller.js
@@ -6,6 +6,8 @@
const teams = require('../../../model/teams/teams.model')
const teamActivity = require('../../../model/teams/teamActivity.model')
+const teamPrefs = require('../../../model/teams/teamNotify.model')
+const unsubscribeToken = require('../../../utils/unsubscribeToken')
const log = require('../../../utils/logger')('teams')
@@ -98,4 +100,52 @@ async function getActivity(req, res) {
}
}
-module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity }
+/**
+ * POST /public/teams/unsubscribe/:token — one-click unsubscribe (TEAMS.md §6.4).
+ *
+ * **The one write in this tier, and it is unauthenticated on purpose.** A person
+ * reading their mail is not logged into the site, and an unsubscribe that first
+ * demands a login is an unsubscribe most people do not complete. The token is what
+ * stands in for the session, and the capability it carries is deliberately the
+ * narrowest one that does the job: set `muted` for ONE (user, Team) pair. It reads
+ * nothing, cannot un-mute, and names no other Team.
+ *
+ * **Always 200, whatever the token was.** A response that distinguished a valid
+ * token from a forged one would turn this into an oracle for which (user, Team)
+ * pairs exist, on an endpoint with no session behind it. The page says "you will
+ * not receive further emails about this team" either way, which is true either way.
+ *
+ * Reached two ways with the same effect: a mail client's RFC 8058 one-click POST
+ * (the `List-Unsubscribe-Post` header), and the site's own /unsubscribe page,
+ * which POSTs here after a human clicks the link in the body.
+ */
+async function unsubscribe(req, res) {
+ const claim = unsubscribeToken.verify(req.params.token)
+ if (claim) {
+ try {
+ await teamPrefs.mute(claim.userId, claim.teamId)
+ } catch (err) {
+ // Logged, not surfaced. A failed write here is worth an operator's
+ // attention and is not worth telling an anonymous caller about — and a 500
+ // would make a mail client retry a request it should not repeat.
+ log.error('unsubscribe', err)
+ }
+ }
+ return res.json({ ok: true })
+}
+
+/**
+ * GET on the same path — for a mail client that shows the `List-Unsubscribe` URL
+ * as a link and has no one-click support.
+ *
+ * Redirects to the site's own page rather than acting, because a GET must not
+ * mutate: a link prefetcher or a mail client's link scanner would otherwise
+ * silently mute Teams nobody asked to leave. The page it lands on does the POST
+ * once a human is looking at it.
+ */
+function unsubscribeLanding(req, res) {
+ const base = (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
+ return res.redirect(302, `${base}/unsubscribe/${encodeURIComponent(req.params.token)}`)
+}
+
+module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity, unsubscribe, unsubscribeLanding }
diff --git a/server/src/router/v1/public/teams.router.js b/server/src/router/v1/public/teams.router.js
index 5723ad2..118ae81 100644
--- a/server/src/router/v1/public/teams.router.js
+++ b/server/src/router/v1/public/teams.router.js
@@ -90,4 +90,38 @@ teamsRouter.get(
ctrl.getActivity,
)
+// ── One-click unsubscribe (TEAMS.md §6.4) ──────────────────────────────────
+//
+// Declared last, and the shadowing question is worth answering rather than
+// assuming: these are two segments, so the one-segment '/:slug' cannot take them,
+// and the two-segment '/:slug/members' and '/:slug/activity' both pin a LITERAL
+// second segment. Only a token spelled exactly "members" or "activity" could
+// collide, and a token is `...`.
+//
+// No `siteMode`, unlike every other route in this file. An unsubscribe has to work
+// while the site is in maintenance: the mail that carried the link went out before
+// the site went down, and "we are doing maintenance" is not an answer to "stop
+// emailing me".
+teamsRouter.post(
+ '/unsubscribe/:token',
+ // #swagger.tags = ['Public · Teams']
+ // #swagger.summary = 'Unsubscribe from one Team’s notification emails'
+ // #swagger.description = 'Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.'
+ // #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
+ // #swagger.security = [{}]
+ /* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
+ ctrl.unsubscribe,
+)
+
+teamsRouter.get(
+ '/unsubscribe/:token',
+ // #swagger.tags = ['Public · Teams']
+ // #swagger.summary = 'Land a human on the unsubscribe page'
+ // #swagger.description = 'For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the site’s own confirmation page and changes nothing — a GET must not mutate, or a link scanner would mute Teams nobody asked to leave.'
+ // #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
+ // #swagger.security = [{}]
+ /* #swagger.responses[302] = { description: 'Redirect to the site’s unsubscribe page' } */
+ ctrl.unsubscribeLanding,
+)
+
module.exports = teamsRouter
diff --git a/server/src/server.js b/server/src/server.js
index 9b4dbce..6a99914 100644
--- a/server/src/server.js
+++ b/server/src/server.js
@@ -11,6 +11,7 @@ const botScore = require('./middleware/botScore')
const announceWorker = require('./utils/announceWorker')
const teamActivityPrune = require('./utils/teamActivityPrune')
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
+const teamDigestWorker = require('./utils/teamDigestWorker')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
@@ -157,6 +158,7 @@ async function start() {
// rather than after someone notices. No-op on a deployment with no Teams.
teamActivityPrune.start()
teamForumUploadSweep.start()
+ teamDigestWorker.start()
setupShutdown(server, internalServer)
}
@@ -177,6 +179,7 @@ function setupShutdown(server, internalServer) {
announceWorker.stop() // stop the news-announcement dispatcher poller
teamActivityPrune.stop() // stop the Team activity retention timer
teamForumUploadSweep.stop() // stop the forum upload sweep
+ teamDigestWorker.stop() // stop the Team forum digest timer
server.close(() => log.info('http server closed'))
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
try {
diff --git a/server/src/utils/mailer.js b/server/src/utils/mailer.js
index 781a227..a8764ac 100644
--- a/server/src/utils/mailer.js
+++ b/server/src/utils/mailer.js
@@ -187,4 +187,81 @@ async function sendPasswordReset({ to, resetUrl, username }) {
}
}
-module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite, sendPasswordReset }
+/**
+ * Send a Team notification — one event (`immediate` mode) or a day's worth
+ * (`digest` mode). TEAMS.md §6.4.
+ *
+ * **This one carries CONTENT, and the push tickle beside it deliberately does
+ * not.** A tickle goes to ntfy, an untrusted relay reachable by an unguessable
+ * topic, so it carries `{ stream, ref }` and the app pulls the real thing over an
+ * access-checked API. A mailbox is a destination the recipient chose. Same
+ * reasoning as the Discord bridge (§7.2), and it is why this function takes
+ * excerpts rather than ids.
+ *
+ * **Excerpts, never full posts.** Partly courtesy, mostly so that the blast radius
+ * of a mis-addressed or forwarded mail is a sentence rather than a thread. The
+ * caller does the truncation, because it is the caller that knows the body was
+ * already stripped of markup.
+ *
+ * The `List-Unsubscribe` pair is what makes a mail client's own unsubscribe button
+ * appear, and both halves are needed: the `mailto:`-free URL form for clients that
+ * open the link, and `List-Unsubscribe-Post` for RFC 8058 one-click, which POSTs
+ * without ever showing the user a page. Both reach the same tokened endpoint that
+ * writes the same per-Team mute the site shows.
+ *
+ * Never throws. A notification failing must not fail the forum write that caused
+ * it, and there is nobody up the stack to catch it — the digest worker runs on a
+ * timer and the immediate send is fired from a request that has already replied.
+ */
+async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubscribeUrl, unsubscribeApiUrl }) {
+ const built = await buildTransport()
+ if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
+ const { transport, config } = built
+
+ const lines = [intro, '']
+ for (const item of items || []) {
+ lines.push(`${item.heading}`)
+ if (item.excerpt) lines.push(` ${item.excerpt}`)
+ if (item.url) lines.push(` ${item.url}`)
+ lines.push('')
+ }
+ if (teamUrl) lines.push(teamUrl, '')
+ if (unsubscribeUrl) {
+ lines.push('To stop these emails for this team, use this link:', unsubscribeUrl)
+ }
+
+ try {
+ await transport.sendMail({
+ from: fromHeader(config),
+ to,
+ subject,
+ text: lines.join('\n'),
+ // The header carries the API url, not the one in the body: a one-click
+ // client POSTs to whatever is here without rendering anything, so it has to
+ // be an endpoint. Falls back to the body's url when no API one was passed.
+ headers: (unsubscribeApiUrl || unsubscribeUrl)
+ ? {
+ 'List-Unsubscribe': `<${unsubscribeApiUrl || unsubscribeUrl}>`,
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
+ }
+ : undefined,
+ })
+ return { sent: true }
+ } catch (err) {
+ // Logged and swallowed, unlike every other sender in this file. Those are
+ // called by a request that can report the failure to whoever caused it; this
+ // one is not, and recordStatus already puts the error where an admin reads it.
+ log.warn('team notification send failed', { message: err.message })
+ await emailConfig.recordStatus({ status: 'error', statusDetail: err.message }).catch(() => {})
+ return { sent: false, reason: 'SEND_FAILED' }
+ }
+}
+
+module.exports = {
+ isConfigured,
+ sendContactMessage,
+ sendTest,
+ sendInvite,
+ sendPasswordReset,
+ sendTeamNotification,
+}
diff --git a/server/src/utils/pushDispatch.js b/server/src/utils/pushDispatch.js
index 5a96ccc..20688b6 100644
--- a/server/src/utils/pushDispatch.js
+++ b/server/src/utils/pushDispatch.js
@@ -108,4 +108,40 @@ async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) {
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
}
-module.exports = { publish, isAllowedEndpoint }
+// `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
+// integer, so a null slipping into a caller's list would become user id 0 and
+// ride into an IN clause. No row has id 0, so it is harmless today — which is
+// exactly why it would never be noticed.
+const isUserId = (n) => Number.isInteger(n) && n > 0
+
+/**
+ * Publish one content-free tickle to a COMPUTED SET of users (TEAMS.md §6.2).
+ *
+ * The third fan-out shape. `publish` answers "everyone subscribed" and "this one
+ * owner"; Team notifications need "these N users", because the four `team.*`
+ * streams are global and which Team an event belongs to is expressed by who is in
+ * the set. Nothing about the tickle changes — same `{ stream, ref }`, same
+ * untrusted-relay assumption, same SSRF gate on every endpoint.
+ *
+ * The set arrives already resolved: the caller has asked the access resolver who
+ * may read this Team and subtracted the per-Team mutes. What this function still
+ * enforces is each recipient's own stream subscription, in the query. Never
+ * throws — a notification failing must not fail the write that produced it.
+ */
+async function publishToUsers(streamId, { ref, userIds } = {}, deps = {}) {
+ const devices = deps.pushDevices || pushDevicesModel
+ const ids = [...new Set((userIds || []).map(Number).filter(isUserId))]
+ if (ids.length === 0) return
+ let rows
+ try {
+ rows = await devices.endpointsForUsersStream(ids, streamId)
+ } catch (err) {
+ log.warn('push endpoint lookup failed', { streamId, message: err.message })
+ return
+ }
+ if (!rows || rows.length === 0) return
+ const bodyStr = JSON.stringify({ stream: streamId, ref: ref ?? null })
+ await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
+}
+
+module.exports = { publish, publishToUsers, isAllowedEndpoint }
diff --git a/server/src/utils/teamDigestWorker.js b/server/src/utils/teamDigestWorker.js
new file mode 100644
index 0000000..50d5c74
--- /dev/null
+++ b/server/src/utils/teamDigestWorker.js
@@ -0,0 +1,158 @@
+// ── Team forum digest worker (TEAMS.md §6.4, phase 6) ──────────────────────
+//
+// Daily, per (user, Team): "here is what you missed". The same in-process shape as
+// utils/teamActivityPrune and utils/announceWorker — setInterval + unref + stop(),
+// wired into server.js start/shutdown. There is no cron in this stack.
+//
+// **It computes at send time and keeps no queue.** The only state is
+// `team_notification_prefs.last_digest_at`; everything else is re-derived from the
+// forum tables when the mail is about to go out. Three properties fall out of that,
+// and they are why the design chose it over a pending-items table:
+//
+// 1. A deployment that was down for two days sends ONE correct digest, not two
+// days of replay.
+// 2. A post a moderator hid after it was written is not in the query, so it is
+// not in the mail. A queue written at publish time would have to remember to
+// go back and remove it.
+// 3. A user who lost forum access between the post and the send is no longer in
+// the recipient set, so they are not emailed content they can no longer read.
+// This is the one that would have been a security bug.
+//
+// **The first run is delayed, for the same reason the prune's is**: a boot that is
+// crash-looping must not send mail on every loop.
+
+const teamNotify = require('../model/teams/teamNotify.model')
+const forumSettings = require('../model/teams/teamForumSettings.model')
+const mailer = require('./mailer')
+const notify = require('./teamNotify')
+const brand = require('../config/brand')
+const log = require('./logger')('team-digest')
+
+const INTERVAL_MS = Number(process.env.TEAM_DIGEST_INTERVAL_MS) || 24 * 60 * 60 * 1000
+const FIRST_RUN_MS = Number(process.env.TEAM_DIGEST_DELAY_MS) || 10 * 60 * 1000
+
+// How far back a recipient with no `last_digest_at` reaches. A first digest must
+// not be the entire history of the forum, and this is also the clamp that stops a
+// long outage producing one enormous mail — `since` is never older than this,
+// however long ago the last send was.
+const MAX_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000
+
+// Posts per digest. Beyond this the mail is a list, not a summary, and the link
+// to the Team is the better answer.
+const MAX_ITEMS = 20
+
+let timer = null
+let firstRun = null
+
+const clampSince = (last, now) => {
+ const floor = new Date(now.getTime() - MAX_LOOKBACK_MS)
+ // No previous send: reach back one interval, not to the floor. A brand-new
+ // subscriber's first digest should cover today, not the past week.
+ if (!last) return new Date(now.getTime() - INTERVAL_MS)
+ const at = new Date(last)
+ return at < floor ? floor : at
+}
+
+/**
+ * One recipient's digest for one Team. Returns true if a mail went out.
+ *
+ * Stamps `last_digest_at` ONLY on a successful send. A failed SMTP call leaves the
+ * stamp alone so the next run tries the same window again — the alternative,
+ * stamping first, silently eats a day of somebody's notifications every time the
+ * mail provider has a bad minute.
+ */
+async function sendOne(team, recipient, now) {
+ const since = clampSince(recipient.last_digest_at, now)
+ const posts = await teamNotify.digestPostsSince(team.id, since, MAX_ITEMS)
+ // Nothing new for THIS recipient — which is not the same as nothing new for the
+ // Team, because each recipient has their own `since`. No mail, and no stamp: a
+ // stamp here would move the window past posts they have not been told about.
+ if (posts.length === 0) return false
+
+ const label = notify.teamLabel(team)
+ const res = await mailer.sendTeamNotification({
+ to: recipient.email,
+ subject: `[${brand.name}] ${label}: ${posts.length} new post${posts.length === 1 ? '' : 's'}`,
+ intro: `Since your last digest, ${posts.length} new post${posts.length === 1 ? '' : 's'} in ${label}:`,
+ items: posts.map((p) => ({
+ heading: `${p.title} — ${p.author_username || 'someone'}`,
+ excerpt: notify.excerpt(p.body_html),
+ url: notify.threadUrl(team, p.thread_id),
+ })),
+ teamUrl: notify.teamPageUrl(team),
+ unsubscribeUrl: notify.unsubscribeUrl(recipient.user_id, team.id),
+ unsubscribeApiUrl: notify.unsubscribeApiUrl(recipient.user_id, team.id),
+ })
+ if (!res || !res.sent) return false
+ await teamNotify.stampDigest(recipient.user_id, team.id, now)
+ return true
+}
+
+/**
+ * One sweep. Never throws — it runs on a timer with nobody to catch it.
+ *
+ * Returns a small summary so a test (and the log line) can tell "nothing to do"
+ * from "did nothing".
+ */
+async function tick(now = new Date()) {
+ const summary = { teams: 0, sent: 0, skipped: null }
+ try {
+ // Two cheap gates before any query that costs anything. Forums switched off
+ // means the content this digest summarises is not readable on the site
+ // either, and un-configured email means there is no sink at all (§6.4).
+ if (!(await forumSettings.forumsEnabled())) return { ...summary, skipped: 'forums-disabled' }
+ if (!(await mailer.isConfigured())) return { ...summary, skipped: 'email-unconfigured' }
+
+ const floor = new Date(now.getTime() - MAX_LOOKBACK_MS)
+ const teams = await teamNotify.teamsWithForumActivitySince(floor)
+ summary.teams = teams.length
+
+ for (const team of teams) {
+ // eslint-disable-next-line no-await-in-loop
+ const rows = await teamNotify.emailRecipients(team.id)
+ for (const r of rows.filter((x) => x.email_mode === 'digest')) {
+ try {
+ // Serial, like the immediate sender and for the same reason: one SMTP
+ // conversation at a time against a provider with its own rate limits.
+ // eslint-disable-next-line no-await-in-loop
+ if (await sendOne(team, r, now)) summary.sent += 1
+ } catch (err) {
+ // One recipient's failure must not end the sweep for the rest. The
+ // unstamped preference means the next run retries this one.
+ log.warn('digest send failed', { teamId: team.id, userId: r.user_id, message: err.message })
+ }
+ }
+ }
+ if (summary.sent > 0) log.info('team digests sent', summary)
+ return summary
+ } catch (err) {
+ log.error('team digest sweep failed', { message: err.message })
+ return { ...summary, skipped: 'error' }
+ }
+}
+
+function start() {
+ if (timer || firstRun) return timer
+ firstRun = setTimeout(() => {
+ firstRun = null
+ tick()
+ timer = setInterval(() => { tick() }, INTERVAL_MS)
+ if (timer.unref) timer.unref()
+ }, FIRST_RUN_MS)
+ if (firstRun.unref) firstRun.unref()
+ log.info('team forum digests started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
+ return timer
+}
+
+function stop() {
+ if (firstRun) {
+ clearTimeout(firstRun)
+ firstRun = null
+ }
+ if (timer) {
+ clearInterval(timer)
+ timer = null
+ }
+}
+
+module.exports = { start, stop, tick, clampSince, MAX_LOOKBACK_MS, MAX_ITEMS }
diff --git a/server/src/utils/teamNotify.js b/server/src/utils/teamNotify.js
new file mode 100644
index 0000000..9176bc3
--- /dev/null
+++ b/server/src/utils/teamNotify.js
@@ -0,0 +1,225 @@
+// ── Team notification fan-out (TEAMS.md Part 6, phase 6) ───────────────────
+//
+// One event in, up to two sinks out: a content-free push tickle and — for forum
+// content only — an email. The expensive part of a notification is working out
+// who should get it, and that is computed once here and handed to both.
+//
+// **Nothing in this file ever throws.** Every entry point is called from a path
+// that has already done the real work: a forum reply is written and answered
+// before this runs, and the roster sync's whole job is the roster. A notification
+// is a courtesy, and a courtesy that can fail the transaction behind it is a
+// defect. So every export catches, logs and returns.
+//
+// **Push and email do not carry the same thing, on purpose.** The tickle is
+// `{ stream, ref }` and goes to ntfy, an untrusted relay reached by an unguessable
+// topic; the app wakes and PULLS the real content over the authenticated,
+// access-checked API. The email carries a title and an excerpt, because a mailbox
+// is a destination the recipient chose rather than a relay (§6.4). The asymmetry
+// is the security model, not an inconsistency to tidy up.
+//
+// **Roster events are push-only, and forum events are the only ones that email.**
+// §6.4's argument for the email sink is the web-only user who never learns that
+// someone replied to their own thread. "Someone joined the guild" is not that: it
+// arrives from a sweep that runs every fifteen minutes, it is already on the
+// activity feed, and mailing it is how a notification feature earns a spam
+// complaint. The streams exist for all four events; the SINKS differ, and this is
+// the file that says so.
+
+const pushDispatch = require('./pushDispatch')
+const teamNotify = require('../model/teams/teamNotify.model')
+const forumSettings = require('../model/teams/teamForumSettings.model')
+const mailer = require('./mailer')
+const registries = require('../modules/registries')
+const unsubscribeToken = require('./unsubscribeToken')
+const brand = require('../config/brand')
+const log = require('./logger')('team-notify')
+
+const STREAMS = {
+ MEMBER_JOINED: 'team.member.joined',
+ LEADERSHIP_CHANGED: 'team.leadership.changed',
+ FORUM_POST: 'team.forum.post',
+ ANNOUNCEMENT: 'team.announcement',
+}
+
+// How much of a post body an email carries. Long enough to tell whether the
+// thread is worth opening, short enough that the mail is not a copy of the forum.
+const EXCERPT_CHARS = 200
+
+const baseUrl = () => (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
+
+/**
+ * Where this Team's page lives, or null.
+ *
+ * Core does not own a Team page — the module that owns the vocabulary owns the
+ * page (Part 3) — so the only way core can write a link to one is the optional
+ * `pageUrlTemplate` the provider registers. A deployment whose module omits it
+ * gets email that names the Team and cannot link to it, which is a worse email
+ * and not a broken one.
+ */
+function teamPageUrl(team) {
+ const provider = registries.registeredTeamProvider()
+ const template = provider && provider.pageUrlTemplate
+ if (!template || !team) return null
+ const path = template
+ .replace('{externalId}', encodeURIComponent(team.external_id ?? team.externalId ?? ''))
+ .replace('{slug}', encodeURIComponent(team.slug ?? ''))
+ return `${baseUrl()}${path}`
+}
+
+const threadUrl = (team, threadId) => {
+ const page = teamPageUrl(team)
+ // The forum navigates by SEARCH PARAM rather than by a route, because core has
+ // no route on a page it does not own (TeamForumPanel.jsx). So a deep link to a
+ // thread is the module's page plus `?thread=`, and it works under whatever path
+ // the module chose.
+ return page ? `${page}?thread=${Number(threadId)}` : null
+}
+
+// TWO urls from one token, and they are not interchangeable.
+//
+// `unsubscribeUrl` is the human one that goes in the mail body: the site's own
+// page, which explains what is about to happen and POSTs once a person has read
+// it. `unsubscribeApiUrl` is the machine one that goes in the `List-Unsubscribe`
+// header, where RFC 8058 says a client may POST without showing anybody anything —
+// so it has to be an endpoint, not a page. The API route answers GET on the same
+// path with a redirect to the page, which covers the clients that render the
+// header as an ordinary link.
+const unsubscribeUrl = (userId, teamId) =>
+ `${baseUrl()}/unsubscribe/${unsubscribeToken.sign(userId, teamId)}`
+
+const unsubscribeApiUrl = (userId, teamId) =>
+ `${baseUrl()}/api/v1/public/teams/unsubscribe/${unsubscribeToken.sign(userId, teamId)}`
+
+const teamLabel = (team) => (team && (team.display_name_override || team.name)) || 'your team'
+
+/** Markup out, whitespace collapsed, truncated. The email is plain text. */
+function excerpt(html) {
+ const text = String(html || '')
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/\s+/g, ' ')
+ .trim()
+ return text.length > EXCERPT_CHARS ? `${text.slice(0, EXCERPT_CHARS - 1)}…` : text
+}
+
+/** The push half. Resolves recipients, honours mutes and subscriptions, never throws. */
+async function tickle(streamId, team, { ref, exclude = [] } = {}) {
+ const userIds = await teamNotify.recipientIds(team.id, { exclude })
+ if (userIds.length === 0) return 0
+ await pushDispatch.publishToUsers(streamId, { ref, userIds })
+ return userIds.length
+}
+
+// ── Roster events (push only, see the header) ──────────────────────────────
+
+// No `memberName` argument, and that is the point: a tickle is content-free, so
+// there is nothing about WHO joined for this function to carry. The name is on
+// the activity feed the app pulls after waking.
+async function memberJoined(team) {
+ try {
+ return await tickle(STREAMS.MEMBER_JOINED, team, { ref: `team:${team.id}` })
+ } catch (err) {
+ log.warn('member-joined notification failed', { teamId: team && team.id, message: err.message })
+ return 0
+ }
+}
+
+async function leadershipChanged(team) {
+ try {
+ return await tickle(STREAMS.LEADERSHIP_CHANGED, team, { ref: `team:${team.id}` })
+ } catch (err) {
+ log.warn('leadership notification failed', { teamId: team && team.id, message: err.message })
+ return 0
+ }
+}
+
+// ── Forum events (push + immediate email) ──────────────────────────────────
+
+/**
+ * A new thread or reply.
+ *
+ * `type` picks the stream: an announcement is its own stream so a user can take
+ * the thing a leader wants everyone to read and mute the day-to-day chatter,
+ * which is the split §6.2 drew and the reason there are four streams and not two.
+ *
+ * The author is excluded from both sinks. Not as a nicety — a forum that emails
+ * you your own post is the first thing anyone turns off, and turning it off costs
+ * the deployment every other notification too.
+ */
+async function forumPost({ team, threadId, threadTitle, type, authorUserId, authorName, bodyHtml }) {
+ try {
+ // Belt and braces with the routes, which already 404 when forums are off. The
+ // digest worker has no route in front of it, so the check has to live here as
+ // well as there — and a switch flipped between a write and its notification
+ // must silence the notification.
+ if (!(await forumSettings.forumsEnabled())) return { push: 0, emails: 0 }
+
+ const stream = type === 'announcement' ? STREAMS.ANNOUNCEMENT : STREAMS.FORUM_POST
+ const exclude = authorUserId ? [authorUserId] : []
+ const push = await tickle(stream, team, { ref: `team:${team.id}:thread:${threadId}`, exclude })
+ const emails = await emailImmediate({ team, threadId, threadTitle, type, exclude, authorName, bodyHtml })
+ return { push, emails }
+ } catch (err) {
+ log.warn('forum notification failed', { teamId: team && team.id, message: err.message })
+ return { push: 0, emails: 0 }
+ }
+}
+
+/**
+ * The `immediate` email mode: one mail per event, to the people who asked for
+ * exactly that.
+ *
+ * Skipped entirely when no email is configured — §6.4's "off unless configured"
+ * — and checked BEFORE the recipient query so a deployment with no Gmail
+ * connected pays nothing for the sink it does not have.
+ */
+async function emailImmediate({ team, threadId, threadTitle, type, exclude, authorName, bodyHtml }) {
+ if (!(await mailer.isConfigured())) return 0
+ const rows = await teamNotify.emailRecipients(team.id, { exclude })
+ const recipients = rows.filter((r) => r.email_mode === 'immediate')
+ if (recipients.length === 0) return 0
+
+ const label = teamLabel(team)
+ const kind = type === 'announcement' ? 'announcement' : 'post'
+ const url = threadUrl(team, threadId)
+ let sent = 0
+
+ for (const r of recipients) {
+ // Serial rather than Promise.all: this is an SMTP conversation per recipient
+ // against a provider with its own rate limits, and a burst of them from a
+ // busy thread is how a Gmail sender gets throttled. The loop is also why the
+ // send below is fire-and-report rather than fire-and-throw.
+ // eslint-disable-next-line no-await-in-loop
+ const res = await mailer.sendTeamNotification({
+ to: r.email,
+ subject: `[${brand.name}] ${label}: ${threadTitle}`,
+ intro: `${authorName || 'Someone'} posted a new ${kind} in ${label}.`,
+ items: [{ heading: threadTitle, excerpt: excerpt(bodyHtml), url }],
+ teamUrl: teamPageUrl(team),
+ unsubscribeUrl: unsubscribeUrl(r.user_id, team.id),
+ unsubscribeApiUrl: unsubscribeApiUrl(r.user_id, team.id),
+ })
+ if (res && res.sent) sent += 1
+ }
+ return sent
+}
+
+module.exports = {
+ STREAMS,
+ memberJoined,
+ leadershipChanged,
+ forumPost,
+ // Exported for the digest worker and for the tests, which is the whole reason
+ // they are not inlined: a URL that only ever appears inside a mail body is a
+ // URL nothing can assert on.
+ teamPageUrl,
+ threadUrl,
+ unsubscribeUrl,
+ unsubscribeApiUrl,
+ excerpt,
+ teamLabel,
+}
diff --git a/server/src/utils/unsubscribeToken.js b/server/src/utils/unsubscribeToken.js
new file mode 100644
index 0000000..fb4dc8c
--- /dev/null
+++ b/server/src/utils/unsubscribeToken.js
@@ -0,0 +1,91 @@
+// ── One-click unsubscribe tokens (TEAMS.md §6.4) ───────────────────────────
+//
+// A stateless HMAC over (userId, teamId, version), not a row in a table.
+//
+// **Why stateless.** The alternative is a `password_resets`-shaped token table,
+// and it is the wrong shape for this: an unsubscribe link sits in a mailbox for
+// months and must still work, so it has no useful expiry; it is not single-use,
+// because clicking it twice must mean the same thing as clicking it once; and a
+// table would need pruning for a capability that never expires. Every property
+// that makes a reset token a row is absent here.
+//
+// **What the capability actually is.** Holding a token lets the holder set
+// `muted = 1` for ONE (user, Team) pair. It cannot read anything, cannot unmute,
+// cannot touch email mode, and names no other Team. So the honest threat model is:
+// someone who intercepts the mail can silence one Team's notifications for that
+// account, visibly and reversibly on the account screen. That is a smaller
+// capability than the mail itself already carries (it contains the content).
+//
+// **`v` is the version prefix, and it is what makes rotation possible at all.** A
+// stateless token cannot be revoked individually; bumping VERSION invalidates
+// every outstanding link at once, which is the only revocation a design with no
+// server-side state can offer, and it needs to exist before it is needed.
+//
+// The key is SECRET_ENC_KEY, derived through the same dev fallback as
+// utils/secretBox — a separate label so an unsubscribe token can never be
+// confused with, or replayed as, anything else keyed by the same secret.
+
+const crypto = require('crypto')
+require('dotenv').config()
+
+const log = require('./logger')('unsub-token')
+
+const VERSION = 1
+
+function resolveKey() {
+ const explicit = process.env.SECRET_ENC_KEY
+ if (explicit) return crypto.createHash('sha256').update(`unsubscribe:${explicit}`).digest()
+ if (process.env.NODE_ENV === 'production') {
+ throw new Error('SECRET_ENC_KEY must be set in production')
+ }
+ const jwt = process.env.JWT_SECRET || 'dev-insecure-jwt-secret-do-not-use-in-production'
+ log.warn('SECRET_ENC_KEY is not set — deriving an insecure unsubscribe key from JWT_SECRET for development.')
+ return crypto.createHash('sha256').update(`unsubscribe:${jwt}`).digest()
+}
+
+let cachedKey = null
+const key = () => {
+ // Lazily, not at require time. utils/secretBox resolves its key on import and
+ // that is fine for a module every boot loads anyway; this one is reached from a
+ // mail template, and a test that never sends mail should not have to set an env
+ // var to require the module that sends it.
+ if (!cachedKey) cachedKey = resolveKey()
+ return cachedKey
+}
+
+// base64url so the token survives being a path segment, a query value and a mail
+// client's own re-wrapping of a long URL without any of the three escaping it.
+const b64u = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
+
+function sign(userId, teamId) {
+ const body = `${VERSION}.${Number(userId)}.${Number(teamId)}`
+ const mac = crypto.createHmac('sha256', key()).update(body).digest()
+ // Truncated to 16 bytes (128 bits). Full-length would double the URL for no
+ // reachable gain: forging this buys one mute, and 128 bits is far past the
+ // point where that is worth anyone's compute.
+ return `${body}.${b64u(mac.subarray(0, 16))}`
+}
+
+/**
+ * Verify a token. Returns { userId, teamId } or null — null for every failure
+ * mode, deliberately, so a caller cannot accidentally report which part was wrong.
+ */
+function verify(token) {
+ const parts = String(token || '').split('.')
+ if (parts.length !== 4) return null
+ const [v, uid, tid] = parts
+ if (Number(v) !== VERSION) return null
+ const userId = Number(uid)
+ const teamId = Number(tid)
+ if (!Number.isInteger(userId) || !Number.isInteger(teamId)) return null
+
+ const expected = sign(userId, teamId)
+ const a = Buffer.from(expected)
+ const b = Buffer.from(String(token))
+ // Length-check first: timingSafeEqual throws on a length mismatch, and the
+ // length of a token is not a secret.
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
+ return { userId, teamId }
+}
+
+module.exports = { sign, verify, VERSION }
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 8c0e56c..5cdc2dd 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -8364,6 +8364,114 @@
}
}
},
+ "/api/v1/auth/me/notifications/teams": {
+ "get": {
+ "tags": [
+ "Auth · Me"
+ ],
+ "summary": "Get the current user’s per-Team notification preferences",
+ "description": "One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they have a stored preference for. Defaults are applied server-side: `muted` false, `emailMode` \"off\".",
+ "responses": {
+ "200": {
+ "description": "Per-Team preferences",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TeamNotificationPrefs"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "put": {
+ "tags": [
+ "Auth · Me"
+ ],
+ "summary": "Replace the current user’s per-Team notification preferences",
+ "description": "Replaces the whole set. The `teams` array is required even when empty. Entries naming a Team the caller has no access to are ignored; the stored set is echoed back.",
+ "responses": {
+ "200": {
+ "description": "Updated preferences",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TeamNotificationPrefs"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Validation error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ValidationError"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TeamNotificationPrefs"
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/auth/me/sessions": {
"get": {
"tags": [
@@ -12070,6 +12178,67 @@
}
}
},
+ "/api/v1/public/teams/unsubscribe/{token}": {
+ "post": {
+ "tags": [
+ "Public · Teams"
+ ],
+ "summary": "Unsubscribe from one Team’s notification emails",
+ "description": "Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.",
+ "parameters": [
+ {
+ "name": "token",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The signed token from the email link."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Acknowledged",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OkFlag"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {}
+ ]
+ },
+ "get": {
+ "tags": [
+ "Public · Teams"
+ ],
+ "summary": "Land a human on the unsubscribe page",
+ "description": "For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the site’s own confirmation page and changes nothing — a GET must not mutate, or a link scanner would mute Teams nobody asked to leave.",
+ "parameters": [
+ {
+ "name": "token",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The signed token from the email link."
+ }
+ ],
+ "responses": {
+ "302": {
+ "description": "Redirect to the site’s unsubscribe page"
+ }
+ },
+ "security": [
+ {}
+ ]
+ }
+ },
"/api/v1/public/teams/{slug}": {
"get": {
"tags": [
@@ -16081,6 +16250,143 @@
}
}
},
+ "TeamNotificationPref": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`)."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "teamId": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 3
+ }
+ }
+ },
+ "slug": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "the-silver-hand"
+ }
+ }
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "The Silver Hand"
+ }
+ }
+ },
+ "archived": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
+ "muted": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
+ "emailMode": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "off",
+ "digest",
+ "immediate"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "example": {
+ "type": "string",
+ "example": "off"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "TeamNotificationPrefs": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "Per-Team notification preferences (used for both GET and PUT). The `teams` array is required on PUT even when empty."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "teams": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/TeamNotificationPref"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"Appeal": {
"type": "object",
"properties": {
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index 6ec430f..62dbc12 100644
--- a/server/swagger/swagger.js
+++ b/server/swagger/swagger.js
@@ -573,6 +573,25 @@ const doc = {
},
},
},
+ TeamNotificationPref: {
+ type: 'object',
+ description: "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`).",
+ properties: {
+ teamId: { type: 'integer', example: 3 },
+ slug: { type: 'string', example: 'the-silver-hand' },
+ name: { type: 'string', example: 'The Silver Hand' },
+ archived: { type: 'boolean', example: false },
+ muted: { type: 'boolean', example: false },
+ emailMode: { type: 'string', enum: ['off', 'digest', 'immediate'], example: 'off' },
+ },
+ },
+ TeamNotificationPrefs: {
+ type: 'object',
+ description: 'Per-Team notification preferences (used for both GET and PUT). The `teams` array is required on PUT even when empty.',
+ properties: {
+ teams: { type: 'array', items: { $ref: '#/components/schemas/TeamNotificationPref' } },
+ },
+ },
// ── Moderation appeals (Phase 6c/6d) ────────────────────────────────────
Appeal: {
type: 'object',
diff --git a/server/test/moduleRegistries.test.js b/server/test/moduleRegistries.test.js
index bade2b5..e659321 100644
--- a/server/test/moduleRegistries.test.js
+++ b/server/test/moduleRegistries.test.js
@@ -44,11 +44,22 @@ function tryApply(owner, build) {
test('registerCore registers exactly what core owns, and nothing else', () => {
registries.registerCore()
- // One stream, one leg, no filled slot. Before Phase 3 this was eight streams,
+ // Five streams, one leg, no filled slot. Before Phase 3 this was eight streams,
// two legs and a core-filled `admin.users.detail` — core was holding shard
// CONTENT so the seam would be exercised on every boot before a module first
// used it. module-uo registers all of it now, through the same door.
- assert.deepEqual(registries.allStreams().map((s) => s.id), ['news.post'])
+ //
+ // The four `team.*` streams arrived with Teams phase 6 and ARE core's: a module
+ // supplies who is in a Team, but who may be told about it is the access
+ // resolver's answer. Asserted as an exact list so a shard-content stream
+ // creeping back into core's registration fails here rather than shipping.
+ assert.deepEqual(registries.allStreams().map((s) => s.id), [
+ 'news.post',
+ 'team.member.joined',
+ 'team.leadership.changed',
+ 'team.forum.post',
+ 'team.announcement',
+ ])
assert.deepEqual(registries.announceLegIds(), ['discord'])
assert.equal(registries.slotFilledBy('admin.users.detail'), null)
assert.equal(registries.isCoreRegistered(), true)
diff --git a/server/test/notificationsRoutes.test.js b/server/test/notificationsRoutes.test.js
index adff96e..042a5c9 100644
--- a/server/test/notificationsRoutes.test.js
+++ b/server/test/notificationsRoutes.test.js
@@ -25,6 +25,11 @@ test('/auth/me push routes reject unauthenticated callers with 401', async () =>
['GET', '/api/v1/auth/me/notifications/streams'],
['GET', '/api/v1/auth/me/notifications/subscriptions'],
['PUT', '/api/v1/auth/me/notifications/subscriptions', { streams: ['news.post'] }],
+ // Phase 6's two. Per-Team preferences are as much a private fact as the
+ // subscriptions beside them — the LIST of Teams a user may be notified
+ // about answers "which guilds is this person in".
+ ['GET', '/api/v1/auth/me/notifications/teams'],
+ ['PUT', '/api/v1/auth/me/notifications/teams', { teams: [] }],
]
for (const [method, path, body] of calls) {
const res = await fetch(app.url + path, {
@@ -38,3 +43,40 @@ test('/auth/me push routes reject unauthenticated callers with 401', async () =>
await app.close()
}
})
+
+// ── The unsubscribe endpoint (TEAMS.md §6.4) ───────────────────────────────
+//
+// The mirror image of every test above: this one is reached WITHOUT a session, on
+// purpose, because the person following it is reading their mail. So the things
+// worth asserting are that it is mounted unauthenticated, that it says the same
+// thing whatever the token was, and that the GET twin does not write.
+
+const publicRouter = require('../src/router/v1/public')
+
+test('unsubscribe answers 200 unauthenticated, and says nothing about the token', async () => {
+ const app = await startApp((a) => a.use('/api/v1/public', publicRouter))
+ try {
+ // A forged token and a well-formed one must be indistinguishable from the
+ // outside — otherwise this is an oracle for which (user, Team) pairs exist.
+ for (const token of ['1.1.1.AAAAAAAAAAAAAAAAAAAAAA', 'total-nonsense']) {
+ const res = await fetch(`${app.url}/api/v1/public/teams/unsubscribe/${token}`, { method: 'POST' })
+ assert.equal(res.status, 200)
+ assert.deepEqual(await res.json(), { ok: true })
+ }
+ } finally {
+ await app.close()
+ }
+})
+
+test('a GET on the same path redirects and does not act', async () => {
+ const app = await startApp((a) => a.use('/api/v1/public', publicRouter))
+ try {
+ const res = await fetch(`${app.url}/api/v1/public/teams/unsubscribe/1.1.1.AAAAAAAAAAAAAAAAAAAAAA`, {
+ redirect: 'manual',
+ })
+ assert.equal(res.status, 302)
+ assert.match(res.headers.get('location') || '', /\/unsubscribe\//)
+ } finally {
+ await app.close()
+ }
+})
diff --git a/server/test/pushDispatch.test.js b/server/test/pushDispatch.test.js
index 1b4542c..29b4896 100644
--- a/server/test/pushDispatch.test.js
+++ b/server/test/pushDispatch.test.js
@@ -126,3 +126,53 @@ test('publish skips endpoints that fail the SSRF guard', async () => {
assert.equal(calls[0].url, 'https://relay.test/ok')
})
})
+
+// ── publishToUsers — the third fan-out shape (TEAMS.md §6.2) ───────────────
+//
+// `publish` answers "everyone subscribed" and "this one owner". Team
+// notifications need "these N users", because the four `team.*` streams are
+// global and which Team an event belongs to lives in the SET, not the stream id.
+
+test('publishToUsers tickles the given set, and asks for exactly that set', async () => {
+ await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
+ const { calls, fetchImpl } = captureFetch()
+ const asked = []
+ const pushDevices = {
+ endpointsForUsersStream: async (ids, stream) => {
+ asked.push({ ids, stream })
+ return [{ endpoint: 'https://relay.test/a' }, { endpoint: 'https://relay.test/b' }]
+ },
+ }
+ await pushDispatch.publishToUsers('team.forum.post', { ref: 'team:1:thread:7', userIds: [4, 9] }, { pushDevices, fetchImpl })
+ assert.deepEqual(asked, [{ ids: [4, 9], stream: 'team.forum.post' }])
+ assert.equal(calls.length, 2)
+ assert.deepEqual(JSON.parse(calls[0].opts.body), { stream: 'team.forum.post', ref: 'team:1:thread:7' })
+ })
+})
+
+test('publishToUsers with an empty set never touches the database', async () => {
+ const { calls, fetchImpl } = captureFetch()
+ let looked = false
+ const pushDevices = { endpointsForUsersStream: async () => { looked = true; return [] } }
+ await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [] }, { pushDevices, fetchImpl })
+ assert.equal(looked, false, 'an empty IN () is a syntax error, so the query must not be made at all')
+ assert.equal(calls.length, 0)
+})
+
+test('publishToUsers de-duplicates and drops non-numeric ids', async () => {
+ await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
+ const { fetchImpl } = captureFetch()
+ const asked = []
+ const pushDevices = {
+ endpointsForUsersStream: async (ids) => { asked.push(ids); return [] },
+ }
+ await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [4, 4, null, 'nope', 9] }, { pushDevices, fetchImpl })
+ assert.deepEqual(asked, [[4, 9]])
+ })
+})
+
+test('publishToUsers never throws when the lookup fails', async () => {
+ const { fetchImpl } = captureFetch()
+ const pushDevices = { endpointsForUsersStream: async () => { throw new Error('down') } }
+ await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [1] }, { pushDevices, fetchImpl })
+})
diff --git a/server/test/teamNotify.test.js b/server/test/teamNotify.test.js
new file mode 100644
index 0000000..ccdea1c
--- /dev/null
+++ b/server/test/teamNotify.test.js
@@ -0,0 +1,289 @@
+// 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 worker’s 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 token verifies for exactly the pair it was signed for', () => {
+ const token = unsubscribeToken.sign(10, 1)
+ assert.deepEqual(unsubscribeToken.verify(token), { userId: 10, teamId: 1 })
+})
+
+test('editing the ids in a token invalidates it — the mac covers them', () => {
+ const token = unsubscribeToken.sign(10, 1)
+ const [v, uid, tid, mac] = token.split('.')
+ assert.equal(unsubscribeToken.verify(`${v}.99.${tid}.${mac}`), null)
+ assert.equal(unsubscribeToken.verify(`${v}.${uid}.99.${mac}`), null)
+})
+
+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 token = unsubscribeToken.sign(10, 1)
+ const [, uid, tid, mac] = token.split('.')
+ assert.equal(unsubscribeToken.verify(`${unsubscribeToken.VERSION + 1}.${uid}.${tid}.${mac}`), null)
+})
diff --git a/server/test/teamNotifyDispatch.test.js b/server/test/teamNotifyDispatch.test.js
new file mode 100644
index 0000000..11ae236
--- /dev/null
+++ b/server/test/teamNotifyDispatch.test.js
@@ -0,0 +1,281 @@
+// The Team notification fan-out and the digest worker (TEAMS.md §6.2/§6.4).
+//
+// 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.
+//
+// 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:
+//
+// • forums switched off silences forum notifications, including the digest;
+// • no email configured means the sink is absent, not broken;
+// • 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.
+const { test, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+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 forumSettings = require('../src/model/teams/teamForumSettings.model')
+const notifyModel = require('../src/model/teams/teamNotify.model')
+const registries = require('../src/modules/registries')
+
+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: 1, slug: 'silver-hand', name: 'The Silver Hand', external_id: 'g1', display_name_override: null }
+
+let sent // tickles
+let mails // emails
+let world
+
+function stub({ forumsEnabled = true, emailConfigured = true, recipients = [10, 11], emailRows = [] } = {}) {
+ sent = []
+ mails = []
+ world = { stamped: [] }
+
+ patch(forumSettings, 'forumsEnabled', async () => forumsEnabled)
+ patch(mailer, 'isConfigured', async () => emailConfigured)
+ patch(mailer, 'sendTeamNotification', async (msg) => {
+ mails.push(msg)
+ return { sent: true }
+ })
+ patch(pushDispatch, 'publishToUsers', async (streamId, payload) => { sent.push({ streamId, ...payload }) })
+ 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 }) })
+ // 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)
+}
+
+beforeEach(() => stub())
+afterEach(restore)
+
+// ── Which stream fires ─────────────────────────────────────────────────────
+
+test('a discussion reply fires team.forum.post; an announcement fires its own stream', async () => {
+ 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'])
+})
+
+test('the tickle is content-free and refs the thread, never the body', async () => {
+ await notify.forumPost({
+ team: TEAM, threadId: 7, threadTitle: 'Secret plans', type: 'discussion', authorUserId: 99,
+ bodyHtml: '
', 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 },
+ ],
+ })
+ await digest.tick(new Date())
+ assert.deepEqual(mails.map((m) => m.to), ['e@example.test'])
+})
+
+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')
+ stub({ emailConfigured: false })
+ assert.equal((await digest.tick(new Date())).skipped, 'email-unconfigured')
+})
+
+test('the sweep never throws, and says so in its summary', async () => {
+ patch(notifyModel, 'teamsWithForumActivitySince', async () => { throw new Error('nope') })
+ assert.equal((await digest.tick(new Date())).skipped, 'error')
+})
+
+// ── The digest window ──────────────────────────────────────────────────────
+
+test('a first digest reaches back one interval, not to the lookback floor', () => {
+ const now = new Date('2026-08-18T12:00:00Z')
+ const since = digest.clampSince(null, now)
+ assert.equal(now - since <= 24 * 60 * 60 * 1000, true)
+})
+
+test('a long outage is clamped: one digest, not a week of replay', () => {
+ const now = new Date('2026-08-18T12:00:00Z')
+ const ancient = new Date('2026-01-01T00:00:00Z')
+ const since = digest.clampSince(ancient, now)
+ assert.equal(now - since, digest.MAX_LOOKBACK_MS)
+})
+
+test('an ordinary last-send is used as-is', () => {
+ const now = new Date('2026-08-18T12:00:00Z')
+ const yesterday = new Date('2026-08-17T12:00:00Z')
+ assert.equal(digest.clampSince(yesterday, now).getTime(), yesterday.getTime())
+})
diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js
index f160d6b..a121276 100644
--- a/server/test/teamProvider.test.js
+++ b/server/test/teamProvider.test.js
@@ -309,6 +309,39 @@ test('a non-function projectRoster is rejected at registration, not at call time
)
})
+// ── pageUrlTemplate: the optional fifth member (§6.4) ──────────────────────
+//
+// Data, not a method, and the only thing core can use to link to a Team page —
+// Teams have no core surface, so the module that owns the page has to say where
+// it is. Validated hard because the output goes into an email as a link.
+
+test('pageUrlTemplate is optional: a provider without it registers fine', () => {
+ const api = registries.stage('uo')
+ assert.doesNotThrow(() => api.registerTeamProvider(ok()))
+})
+
+test('a relative template is kept exactly as given', () => {
+ register('uo', { ...ok(), pageUrlTemplate: '/uo/guilds/{externalId}' })
+ assert.equal(registries.registeredTeamProvider().pageUrlTemplate, '/uo/guilds/{externalId}')
+})
+
+test('an absolute template is refused — a module may not redirect the site’s mail', () => {
+ const api = registries.stage('uo')
+ for (const bad of [
+ 'https://evil.test/{externalId}',
+ '//evil.test/x',
+ 'uo/guilds/{externalId}', // not rooted
+ '/uo/guilds/{externalId}?x=