feat(teams): email as the third sink, with a digest that keeps no queue
A web-only user on a deployment running neither the Android app nor Discord gets no notification that someone replied to their own thread — which is most users on most deployments, and a forum where replies are invisible is a forum nobody returns to. Email is a third consumer of the recipient set the previous commit builds, not a fourth pipeline. Unlike a push tickle, an email carries content: a mailbox is a destination the recipient chose, not an untrusted relay reached by an unguessable topic. It carries a title and an excerpt, never a full post. The digest COMPUTES AT SEND TIME and keeps no pending-items queue. The only state is `last_digest_at`. Three properties fall out, and the third is why it was chosen: a deployment down for two days sends one correct digest rather than replaying a backlog; a post a moderator hid after it was written is simply not in the query; and 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. `last_digest_at` is stamped only on a SUCCESSFUL send — stamping first would quietly eat a day of somebody's notifications every time the mail provider had a bad minute. One-click unsubscribe is a stateless HMAC rather than a token table. Every property that makes a password-reset token a row is absent: the link sits in a mailbox for months so it has no useful expiry, and clicking it twice must mean what clicking it once meant. Its whole capability is setting `muted` for one (user, Team) pair. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
225
server/src/utils/teamNotify.js
Normal file
225
server/src/utils/teamNotify.js
Normal file
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user