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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user