// ── 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. // // **Phase 8 added a THIRD sink, and it is a second delivery rather than a second // pipeline.** `utils/teamBridge.js` takes the same event, already computed, and // hands it to a Discord channel the operator configured — which is why every // entry point below calls it beside the tickle instead of anything re-deriving // the event. Note that the bridge does NOT take the recipient set: its audience // is whoever can read a channel, which is why enabling it for members-only // content needs an operator acknowledgement (§7.2, teamIntegration.model.js). // // **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 teamBridge = require('./teamBridge') 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. // // `count` is phase 8's one addition and it is for the BRIDGE, not the tickle: a // Discord channel has no app on the other end to pull anything, so the message // has to say something, and "3 new members joined" is the most a caller that // notifies once per sweep can honestly say. Optional, so the sync is the only // caller that has to know it exists. async function memberJoined(team, { count } = {}) { try { const sent = await tickle(STREAMS.MEMBER_JOINED, team, { ref: `team:${team.id}` }) await teamBridge.deliver(STREAMS.MEMBER_JOINED, team, { body: teamBridge.memberJoinedBody(count), teamUrl: teamPageUrl(team), url: teamPageUrl(team), }) return sent } catch (err) { log.warn('member-joined notification failed', { teamId: team && team.id, message: err.message }) return 0 } } async function leadershipChanged(team) { try { const sent = await tickle(STREAMS.LEADERSHIP_CHANGED, team, { ref: `team:${team.id}` }) await teamBridge.deliver(STREAMS.LEADERSHIP_CHANGED, team, { body: 'Leadership has changed.', teamUrl: teamPageUrl(team), url: teamPageUrl(team), }) return sent } 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, bridged: false } 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 }) // The bridge is NOT given `exclude`. Excluding the author is a property of a // per-recipient sink — nobody wants their own post mailed back to them — and a // channel has no per-recipient anything. Suppressing the message because the // author happens to be in the channel would deprive everyone else in it. const bridged = await teamBridge.deliver(stream, team, { title: threadTitle, body: teamBridge.excerpt(bodyHtml), url: threadUrl(team, threadId), teamUrl: teamPageUrl(team), }) return { push, emails, bridged } } catch (err) { log.warn('forum notification failed', { teamId: team && team.id, message: err.message }) return { push: 0, emails: 0, bridged: false } } } /** * 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 mail * transport configured 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 relay with its own rate limits, and a burst of them from a // busy thread is how a sending account 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, }