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:
2026-08-18 14:34:38 -05:00
parent 26c23bd603
commit 686a214979
5 changed files with 555 additions and 1 deletions

View File

@@ -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,
}