Files
website/server/src/utils/teamNotify.js
Claude 47c8b37d45 feat(email): remove Gmail OAuth2, put SMTP behind a transport registry
Engagement Phase 1 (docs/website/ENGAGEMENT.md §1.2a, §3.1, §3.2). A
subtraction and a replacement in one commit, because leaving the OAuth2
flow half-wired across a release is worse than either end state.

Deleted, per the §1.2a inventory: GET /admin/email/connect/start and
/connect/callback, the connectStart/connectCallback controllers with the
email_oauth_tx signed cookie, the PKCE verifier and CSRF nonce plumbing,
the https://mail.google.com/ scope, the borrowed `google` auth-providers
client, the OAuth2 nodemailer transport with its smtp.gmail.com:465
literals, the refresh-token decrypt in the model, and the client's
Connect Gmail button, redirect banner and six Gmail error strings.
`provider` and `refresh_token_enc` stay as columns under the
additive-only discipline, unread.

Added: a mail transport registry (server/src/engagement/transports) with
`smtp` as the sole registration. `credentialFields` is the single
declaration the admin form renders, the sanitizer filters against, and
the "is it secret" answer comes from, so adding a transport is a
registration rather than four edits. email_config gains transport /
credential_enc (one encrypted JSON blob, since the field list is the
transport's to declare) / reply_to.

All six call sites keep their exact failure contracts: the contact
form's mailto fallback, the invite's copyable link, the reset's generic
200, and sendTeamNotification's never-throws. One deliberate behaviour
change: `enabled` now gates every sender rather than only isConfigured()
— the connect flow used to set it as a side effect, and with a credential
form the toggle has to mean what it says.

Send-test becomes the real verification. Under OAuth2 the sender came
back from Google and was guaranteed to belong to the credential;
operator-typed, it can be refused, so failures name the sender and the
SPF/DMARC reason (§1.2a consequence 2).

G22, the silent degradation: an upgraded deployment backfills to smtp
with no credentials and every sink politely does nothing. The admin
dashboard now warns when the deprecated Gmail token is present and no
replacement credential is, so the one deployment this happens to is told.
A fresh install has never had mail and is not nagged.

Guardrails: new `npm run check:hosts` (§3.2 rule 4) with its own
self-test, wired into pr-checks before the install; routes.manifest and
routes.guards regenerated (-2 routes).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 20:52:55 -05:00

263 lines
12 KiB
JavaScript

// ── 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/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,
}