feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.
Seven decisions settled by the org lead before any code:
- email only moves; the push tickle and the Discord bridge stay direct calls
- the EVENT carries its access-checked audience, and `members` resolves to it
- the four Team rules are seeded DISABLED, with an admin banner and a note
- team_notification_prefs stays, read by the engine as a scoped preference
- the payload wins and a structural projection fills the gaps
- the digest keeps computing at send time; only its state generalizes
- an unsubscribe token turns off the channel it names, and nothing else
Three defects found while building it:
- `email.button` never absolutized its href, while image and itemList both
did. Every rule-driven CTA would have been a dead relative link, because a
trigger's url variables are validated site-relative by construction.
- Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
not to build. An outbox row snapshots the payload and so has none of the
three properties the digest design exists for, including the security one.
- the digest's send-log row carried no address_hash while the instant row
beside it did, which would have made half the mail uncorrelatable in Phase 9.
Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.
Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.
Docs: RunicGateway/docs#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -52,7 +52,15 @@ registerEmailBlock({
|
||||
// still renders, inert, because dropping it silently would hide from the
|
||||
// reader that the mail was meant to offer them something.
|
||||
if (ctx.t(props.url).trim() === '') return ''
|
||||
const href = ctx.safeHref(props.url)
|
||||
// ABSOLUTIZED, like `email.image` and `email.itemList` already do, and this
|
||||
// was a real defect until Phase 6 put a rule-driven variable in here. A
|
||||
// trigger's `url` variables are validated site-RELATIVE by construction
|
||||
// (`engagementEmit.RELATIVE_URL`), so `{{actionUrl}}` interpolates to
|
||||
// `/guilds/the-silver-anvil` and a mail client has no origin to resolve that
|
||||
// against: the button rendered a dead link. `absolute()` returns null for a
|
||||
// relative path when no base is configured, which falls into the inert-label
|
||||
// branch below rather than shipping the broken href.
|
||||
const href = ctx.absolute(ctx.safeHref(props.url))
|
||||
const label = ctx.h(props.label)
|
||||
if (!href) {
|
||||
return (
|
||||
@@ -78,8 +86,12 @@ registerEmailBlock({
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
const url = ctx.t(props.url).trim()
|
||||
if (url === '') return '' // see toHtml: no url, no block, in either part
|
||||
const raw = ctx.t(props.url).trim()
|
||||
if (raw === '') return '' // see toHtml: no url, no block, in either part
|
||||
// The text part shows the same absolute URL the button links to. Falls back
|
||||
// to the raw value rather than dropping the block: a reader who can see a
|
||||
// relative path can still find the site, and `itemList` makes the same trade.
|
||||
const url = ctx.absolute(raw) || raw
|
||||
const lead = props.textLead ? ctx.t(props.textLead).trim() : ''
|
||||
return lead ? `${lead}\n${url}` : url
|
||||
},
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
// The caller must not send. Falling back to the rule's plain `audience`
|
||||
// column would reach a different population than the one composed (§5.1a
|
||||
// rule 4), which is the failure mode this whole design exists to avoid.
|
||||
// 3. `members` as a PLAIN audience resolves to nobody. It is the ceiling for
|
||||
// "a module-declared list", and without a segment there is no list - core
|
||||
// 3. `members` resolves to nobody unless something NAMED the list: a segment, or
|
||||
// (from Phase 6) an event carrying its own access-checked recipient set. Core
|
||||
// knows no game vocabulary and cannot guess which members were meant. A rule
|
||||
// saved that way is inert and visible as such, rather than quietly falling
|
||||
// back to something wider.
|
||||
// with neither is inert and visible as such, rather than quietly falling back
|
||||
// to something wider.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const channels = require('./channels')
|
||||
@@ -104,13 +104,36 @@ async function resolveForRule(rule, event) {
|
||||
case 'authenticated':
|
||||
case 'everyone':
|
||||
return { userIds: await recipients.active(), ceiling: rule.audience, dormant: false, reason: null }
|
||||
case 'members':
|
||||
case 'members': {
|
||||
// **The event may name its own list, and Phase 6 is why that exists.**
|
||||
// `members` is the ceiling for "a module-declared list", and until this
|
||||
// phase the only way to name one was a segment — an operator-composed tree
|
||||
// over audiences with CONSTANT params. That cannot express "the members of
|
||||
// the Team this particular post was in": the list is different for every
|
||||
// firing, and nothing in a saved segment reads the event.
|
||||
//
|
||||
// So an emitter that has already computed an access-checked recipient set
|
||||
// hands it over on the envelope, and this is where it is used. It is not a
|
||||
// bypass of anything: the set is still filtered through `users.status`
|
||||
// below, and the ceiling returned is still `members`, so the G24 re-check
|
||||
// in the engine still refuses a rule whose trigger has since narrowed.
|
||||
// What it removes is core having to guess a game's membership vocabulary —
|
||||
// the thing this case's original comment said it could not do.
|
||||
if (Array.isArray(event.recipientUserIds) && event.recipientUserIds.length) {
|
||||
return {
|
||||
userIds: await recipients.filterActive(event.recipientUserIds),
|
||||
ceiling: 'members',
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
userIds: [],
|
||||
ceiling: 'members',
|
||||
dormant: false,
|
||||
reason: 'a "members" audience needs a segment naming which list',
|
||||
reason: 'a "members" audience needs a segment naming which list, or an event that carries one',
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Fails closed on an audience name the lattice does not know - the same
|
||||
// posture `ceilings.permits` takes, and for the same reason.
|
||||
|
||||
@@ -44,6 +44,15 @@ const isMode = (value) => MODES.includes(value)
|
||||
* @param {string} def.defaultMode the mode that applies with no stored row
|
||||
* @param {boolean} def.supportsDigest may a preference for this channel be 'digest'
|
||||
* @param {string} [def.description] one line for the preferences screen
|
||||
* @param {(userId: number) => Promise<{address: string}|null>} [def.addressFor]
|
||||
* where this channel would send to, or null when it cannot reach the user
|
||||
* @param {(row: object) => Promise<{ok?: boolean, retry?: boolean, transport?: string, detail?: string, addressHash?: string}>}
|
||||
* [def.deliver] deliver one claimed outbox row. **Must not throw** — the
|
||||
* worker treats a throw as a transient failure, which is the right guess
|
||||
* and a worse answer than the channel's own classification. A channel
|
||||
* without one is declared but not yet deliverable, which is exactly what
|
||||
* `inapp` is until Phase 7; the worker finishes such a row `failed` and
|
||||
* says so in the send log rather than pretending it was sent.
|
||||
*/
|
||||
function registerDeliveryChannel(def) {
|
||||
if (!def || typeof def !== 'object') throw new Error('registerDeliveryChannel: definition required')
|
||||
@@ -67,6 +76,16 @@ function registerDeliveryChannel(def) {
|
||||
if (defaultMode === 'digest' && !supportsDigest) {
|
||||
throw new Error(`registerDeliveryChannel(${id}): defaultMode 'digest' needs supportsDigest`)
|
||||
}
|
||||
// Optional, but not optionally-typed. A channel registering `deliver: true` or
|
||||
// a stale import that resolved to undefined would otherwise be a channel that
|
||||
// silently never delivers — the failure Phase 3 deferred the whole behavioural
|
||||
// half to avoid freezing, and the one the worker's "no delivery implementation
|
||||
// yet" branch would report as if it were by design.
|
||||
for (const fn of ['addressFor', 'deliver']) {
|
||||
if (def[fn] !== undefined && typeof def[fn] !== 'function') {
|
||||
throw new Error(`registerDeliveryChannel(${id}): ${fn} must be a function`)
|
||||
}
|
||||
}
|
||||
|
||||
channels.set(id, {
|
||||
id,
|
||||
@@ -75,12 +94,22 @@ function registerDeliveryChannel(def) {
|
||||
carriesContent,
|
||||
defaultMode,
|
||||
supportsDigest,
|
||||
addressFor: def.addressFor,
|
||||
deliver: def.deliver,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/** Every channel, in registration order. The preferences screen's column set. */
|
||||
const all = () => [...channels.values()].map((c) => ({ ...c }))
|
||||
/**
|
||||
* Every channel, in registration order. The preferences screen's column set.
|
||||
*
|
||||
* **Declarative fields only** — `addressFor` and `deliver` are stripped. This is
|
||||
* what a route serializes, and a function on an object bound for `res.json` is a
|
||||
* key that silently disappears rather than an error; keeping the boundary here
|
||||
* means the API shape is decided in one place instead of by JSON.stringify.
|
||||
*/
|
||||
const all = () =>
|
||||
[...channels.values()].map(({ addressFor, deliver, ...declared }) => ({ ...declared }))
|
||||
|
||||
/** Just the ids. */
|
||||
const ids = () => [...channels.keys()]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// there is nowhere to express that generically". This is that place.
|
||||
|
||||
const { registerDeliveryChannel } = require('./channels')
|
||||
const emailChannel = require('./emailChannel')
|
||||
|
||||
const CHANNELS = [
|
||||
{
|
||||
@@ -53,6 +54,12 @@ const CHANNELS = [
|
||||
// `team_notification_prefs.email_mode` already takes ('off' by default).
|
||||
defaultMode: 'off',
|
||||
supportsDigest: true,
|
||||
// Phase 6: the first channel with a body. `supportsDigest` above is now load-
|
||||
// bearing rather than aspirational — a 'digest' preference means the engine
|
||||
// writes NO outbox row and the digest worker re-derives the content at send
|
||||
// time (§4.2b), which is a different delivery path rather than a batched one.
|
||||
addressFor: emailChannel.addressFor,
|
||||
deliver: emailChannel.deliver,
|
||||
},
|
||||
{
|
||||
id: 'inapp',
|
||||
|
||||
149
server/src/engagement/coreRules.js
Normal file
149
server/src/engagement/coreRules.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// ── The four Team rules core ships, all of them OFF ────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
|
||||
// people with no operator configuration at all: the code decided who was mailed
|
||||
// and about what, and the only knobs were per-user. Phase 6 moves that decision
|
||||
// onto rules — which default `enabled = 0`, and of which core seeds none.
|
||||
//
|
||||
// **So a straight migration would have stopped Team email on every existing
|
||||
// deployment, silently.** The org lead's decision was to honour the invariant
|
||||
// rather than carve an exception into it: the rules are seeded, and they are
|
||||
// seeded OFF. Team email resumes when an operator opens Admin → Engagement →
|
||||
// Rules and switches one on, and until then the admin screen says so in as many
|
||||
// words (`EngagementRules.jsx`). The release note names it.
|
||||
//
|
||||
// The alternative — seeding them enabled so nothing changes for anybody — was
|
||||
// considered and refused. "Nothing is seeded, nothing is on by default" is what
|
||||
// makes a rules table safe to restore, import or replicate, and an exception
|
||||
// carved for the one pipeline that predates the engine is an exception that has
|
||||
// to be re-argued every time somebody reads the invariant.
|
||||
//
|
||||
// **Seeded once, not ensured on every boot**, and the difference matters: an
|
||||
// operator who deletes a rule must not find it back after a restart. The guard is
|
||||
// a settings key, the same mechanism a one-shot migration uses, so a deployment
|
||||
// that has seen this seed never sees it again — deleted rules stay deleted, and
|
||||
// an enabled rule stays enabled rather than being reset to off.
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const settingsDb = require('../model/settings/settings.db')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
// The one-shot guard. Its VALUE is the timestamp, purely so an operator reading
|
||||
// the settings table can tell when it ran; only its presence is read.
|
||||
const SEEDED_KEY = 'engagement_team_rules_seeded'
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
trigger_id: 'team.forum.post',
|
||||
name: 'Team forum posts',
|
||||
// `members`, which resolves to the recipient set the event carries — the
|
||||
// access-checked list `teamNotify` has always computed. Not `authenticated`,
|
||||
// and the trigger's own ceiling would refuse that anyway: a private Team's
|
||||
// forum excerpt reaching the whole site is the failure G24 exists for.
|
||||
audience: 'members',
|
||||
channels: ['email'],
|
||||
// `email` is the instant body; `digest` is what the digest worker renders.
|
||||
// Two keys because they are two different messages — a template written for
|
||||
// one post renders a day of them as a single missing variable.
|
||||
template_keys: { email: 'notify.team-post', digest: 'notify.digest' },
|
||||
// No cooldown. A busy thread is exactly what the per-user `email_mode` and
|
||||
// the digest option are for, and a cooldown here would silently drop the
|
||||
// second reply of a conversation rather than batching it.
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'team.announcement',
|
||||
name: 'Team announcements',
|
||||
audience: 'members',
|
||||
channels: ['email'],
|
||||
// **The generic body, not `notify.team-post`, and the reason is a naming
|
||||
// inconsistency in the Phase 2 declarations rather than a design choice
|
||||
// here.** The two triggers describe the same underlying thing — a thread in a
|
||||
// Team forum — but `team.forum.post` declares its title as `threadTitle` and
|
||||
// `team.announcement` declares it as `title`. A template can only name one of
|
||||
// them, so `notify.team-post`'s `{{threadTitle}}` renders empty for an
|
||||
// announcement. `notify.event` + the structural projection gets it right
|
||||
// (`title` is in the payload, `actionUrl` falls back to `postUrl`), and
|
||||
// reconciling the two declarations is a version bump this phase did not take
|
||||
// on its own authority.
|
||||
template_keys: { email: 'notify.event', digest: 'notify.digest' },
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'team.member.joined',
|
||||
name: 'Team — new member',
|
||||
audience: 'members',
|
||||
channels: ['email'],
|
||||
// The generic body: `notify.event` plus the structural projection renders it
|
||||
// with no authoring (§4.6.1 property 1). A deployment that wants a better one
|
||||
// duplicates the template and points this rule at the copy.
|
||||
template_keys: { email: 'notify.event' },
|
||||
// An hour, per user per Team. This is the rule §6.4 argued should not exist
|
||||
// as a sink at all — a fifteen-minute sweep, already on the activity feed —
|
||||
// and the cooldown is what makes it survivable for the operator who wants it
|
||||
// anyway: a guild recruiting ten people in an afternoon sends one mail.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'team.leadership.changed',
|
||||
name: 'Team — leadership change',
|
||||
audience: 'members',
|
||||
channels: ['email'],
|
||||
template_keys: { email: 'notify.event' },
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Seed the four rules, once. Returns a small summary for the boot log.
|
||||
*
|
||||
* Never throws: it is on the boot path beside `seedTemplates`, and a rule that
|
||||
* failed to seed costs an operator one visit to the "new rule" form, not a
|
||||
* deployment.
|
||||
*/
|
||||
async function seedTeamRules() {
|
||||
const summary = { inserted: 0, skipped: 0 }
|
||||
try {
|
||||
const seen = await settingsDb.get(SEEDED_KEY)
|
||||
if (seen) return { ...summary, skipped: RULES.length }
|
||||
|
||||
for (const rule of RULES) {
|
||||
try {
|
||||
await rulesDb.insert({
|
||||
audience_segment_id: null,
|
||||
conditions: null,
|
||||
// No delay and nothing cancels these. `delay_seconds` is the grace
|
||||
// window a cancelling event needs, and nothing cancels "someone
|
||||
// posted" — the post happened.
|
||||
delay_seconds: 0,
|
||||
cancel_on: [],
|
||||
...rule,
|
||||
enabled: 0,
|
||||
updated_by: null,
|
||||
})
|
||||
summary.inserted += 1
|
||||
} catch (err) {
|
||||
log.error('team rule seed failed', { trigger: rule.trigger_id, message: err.message })
|
||||
}
|
||||
}
|
||||
// Stamped even on a partial run. Re-running would duplicate the rules that
|
||||
// did insert, and a duplicate rule is two mails per event — a worse outcome
|
||||
// than the one missing rule an operator can add from the screen.
|
||||
await settingsDb.set(SEEDED_KEY, new Date().toISOString())
|
||||
if (summary.inserted) {
|
||||
log.info('seeded Team engagement rules, all disabled', {
|
||||
rules: summary.inserted,
|
||||
note: 'Team email stays off until an operator enables one',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('team rule seeding failed', { message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
module.exports = { seedTeamRules, RULES, SEEDED_KEY }
|
||||
64
server/src/engagement/coreScopePrefs.js
Normal file
64
server/src/engagement/coreScopePrefs.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// ── Core's own scope-preference provider: Teams ────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6, decision 4. `team_notification_prefs` stays exactly
|
||||
// where it is and keeps exactly the meaning it has had since Teams shipped; this
|
||||
// is the adapter that lets the generic engine read it without knowing what a Team
|
||||
// is. Registered here rather than at the bottom of `scopedPrefs.js` for the same
|
||||
// reason `coreChannels` and `transports/smtp` are: requiring a registry must not
|
||||
// have the side effect of populating it.
|
||||
//
|
||||
// **The two columns say different things and the mapping is not symmetric.**
|
||||
//
|
||||
// - `muted` is the Team's master switch and it silences EVERY channel. That is
|
||||
// what the toggle has always meant on the account screen ("mute this Team"),
|
||||
// and narrowing it to email would be a behaviour change nobody asked for. Note
|
||||
// this is belt-and-braces on the live path — `teamNotify.recipientIds` already
|
||||
// excludes muted users before the event is emitted — and it is here anyway so
|
||||
// the meaning survives an emitter that stops filtering.
|
||||
// - `email_mode` says nothing about any other channel, so on push or in-app this
|
||||
// provider returns no opinion and the stream-level preference decides.
|
||||
//
|
||||
// **Absence of a row means 'off' for email, and that is the whole reason this
|
||||
// provider answers for every user rather than only for the rows it finds.** The
|
||||
// column defaults to `'off'` and both recipient queries COALESCE to it: no row
|
||||
// has always meant "this person has not asked for Team email". Deferring to the
|
||||
// stream-level preference instead would mean a user who once switched on
|
||||
// `team.forum.post` email in the channels screen starts receiving mail from every
|
||||
// Team on the deployment — a widening, produced by a migration, of a preference
|
||||
// they expressed about something else.
|
||||
|
||||
const { registerScopePreference } = require('./scopedPrefs')
|
||||
const teamNotify = require('../model/teams/teamNotify.model')
|
||||
|
||||
// team_notification_prefs.email_mode → the three modes the engine speaks. The
|
||||
// vocabularies differ by one word and only one word: 'immediate' predates
|
||||
// `notification_channel_prefs`, whose ENUM says 'instant'.
|
||||
const EMAIL_MODE = { off: 'off', immediate: 'instant', digest: 'digest' }
|
||||
|
||||
async function modesFor(userIds, channel, scopeId) {
|
||||
const teamId = Number(scopeId)
|
||||
if (!Number.isInteger(teamId) || teamId < 1) return new Map()
|
||||
|
||||
const rows = await teamNotify.prefsForTeam(userIds, teamId)
|
||||
const byUser = new Map(rows.map((r) => [Number(r.user_id), r]))
|
||||
const modes = new Map()
|
||||
|
||||
for (const userId of userIds) {
|
||||
const row = byUser.get(Number(userId))
|
||||
if (row && Number(row.muted)) {
|
||||
modes.set(Number(userId), 'off')
|
||||
continue
|
||||
}
|
||||
if (channel !== 'email') continue // no opinion; the stream preference decides
|
||||
modes.set(Number(userId), EMAIL_MODE[(row && row.email_mode) || 'off'] || 'off')
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
registerScopePreference({
|
||||
prefix: 'team',
|
||||
label: 'Team',
|
||||
modesFor,
|
||||
})
|
||||
|
||||
module.exports = { modesFor, EMAIL_MODE }
|
||||
147
server/src/engagement/emailChannel.js
Normal file
147
server/src/engagement/emailChannel.js
Normal file
@@ -0,0 +1,147 @@
|
||||
// ── The email DeliveryChannel: addressFor + deliver ────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6. Phase 3 declared this channel and deliberately left it
|
||||
// behaviourless ("declaring a function nothing calls freezes a signature before
|
||||
// anything has tried to use it"); this is the phase that has something to try it
|
||||
// with, and the signature survived unchanged.
|
||||
//
|
||||
// **What it does is four lookups and one send**, and the order matters because
|
||||
// each step is a way the mail should not go out:
|
||||
//
|
||||
// 1. the address — re-checked for `status = 'active'`, because a delayed
|
||||
// row can outlive the account it was queued for
|
||||
// 2. the rule — for its per-channel template key; the outbox row
|
||||
// carries `rule_id` and FK CASCADE guarantees it exists
|
||||
// 3. the values — the payload snapshot, plus §4.6.1's structural
|
||||
// projection, plus this recipient's unsubscribe link
|
||||
// 4. the template — `renderByKey`, which falls back to the shipped seed
|
||||
// rather than failing, and refuses a draft
|
||||
// 5. the send — `mailer.sendNotification`, which classifies rather
|
||||
// than throwing
|
||||
//
|
||||
// **It never throws**, and that is a stronger statement than the worker's
|
||||
// `try/catch` around it: a throw would be read as a transient failure and retried
|
||||
// five times, so an unrenderable template would become five identical failures in
|
||||
// the send log instead of one honest terminal row.
|
||||
//
|
||||
// **The unsubscribe link is per recipient and is built from `scope_key`, never
|
||||
// from `subject_key`.** They differ for every Team event: the subject is
|
||||
// `teamName` (a display string the cooldown keys on) and the scope is `team:12`.
|
||||
// A Team renamed between the mail and the click must not orphan the link in it.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const templates = require('./templates')
|
||||
const projection = require('./projection')
|
||||
const unsubscribeToken = require('../utils/unsubscribeToken')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
// **Required lazily, and it is a real cycle rather than a style preference.**
|
||||
// `engagement/index.js` requires `coreChannels`, which requires this file; and
|
||||
// `utils/mailer` requires `engagement/index` for the transport registry. A
|
||||
// top-level `require('../utils/mailer')` here therefore resolves while
|
||||
// `engagement/index` is mid-evaluation, so mailer would capture `{}` for
|
||||
// `transports` and every send would fail on `transports.get is not a function` —
|
||||
// at send time, on a deployment, with the boot log clean. Resolved at call time
|
||||
// instead, by which point both modules are fully evaluated.
|
||||
const mailer = () => require('../utils/mailer')
|
||||
|
||||
// The template a rule renders through when it names none. §4.6.1 property 1: a
|
||||
// new trigger must be mailable with no authoring at all, and this plus
|
||||
// `projection.project` is that property's implementation.
|
||||
const DEFAULT_TEMPLATE = 'notify.event'
|
||||
|
||||
const baseUrl = () => templates.baseUrl()
|
||||
|
||||
// Lower-cased first: a bounce reported for "Darrow@example.com" has to match the
|
||||
// row written for "darrow@example.com", and a hash of two spellings is two
|
||||
// different rows. The local part is technically case-sensitive per RFC 5321 and
|
||||
// no relay anybody deploys treats it that way.
|
||||
const hashAddress = (address) =>
|
||||
crypto.createHash('sha256').update(String(address).trim().toLowerCase()).digest('hex')
|
||||
|
||||
/**
|
||||
* The two unsubscribe URLs for one recipient of one scope, or nulls.
|
||||
*
|
||||
* 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 for 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 clients that render the header as an ordinary link.
|
||||
*
|
||||
* A scope the token format cannot carry yields nulls rather than an exception:
|
||||
* the mail is worth sending without a one-click unsubscribe, and the recipient
|
||||
* still has the preferences screen. It is logged because it is a programming
|
||||
* error in whatever chose the scope key.
|
||||
*/
|
||||
function unsubscribeUrls(userId, scopeKey) {
|
||||
try {
|
||||
const token = unsubscribeToken.sign(userId, 'email', scopeKey || '')
|
||||
const base = baseUrl()
|
||||
return {
|
||||
unsubscribeUrl: `${base}/unsubscribe/${token}`,
|
||||
unsubscribeApiUrl: `${base}/api/v1/public/engagement/unsubscribe/${token}`,
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('could not build an unsubscribe link', { scope: scopeKey, message: err.message })
|
||||
return { unsubscribeUrl: null, unsubscribeApiUrl: null }
|
||||
}
|
||||
}
|
||||
|
||||
/** Where this channel would send to, or null. */
|
||||
const addressFor = (userId) => recipients.addressFor(userId)
|
||||
|
||||
/**
|
||||
* Deliver one claimed outbox row.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
try {
|
||||
const to = await addressFor(row.user_id)
|
||||
if (!to) {
|
||||
// Terminal. Retrying does not give somebody an address, and a banned
|
||||
// account is not going to be un-banned by a five-minute backoff.
|
||||
return { ok: false, detail: 'no deliverable address for this user' }
|
||||
}
|
||||
|
||||
const rule = await rulesDb.getById(row.rule_id)
|
||||
const key = (rule && rule.template_keys && rule.template_keys.email) || DEFAULT_TEMPLATE
|
||||
|
||||
// Once, not once per use: the body's link and the header's must be the same
|
||||
// token, or a client that offers both offers two different unsubscribes.
|
||||
const unsub = unsubscribeUrls(row.user_id, row.scope_key)
|
||||
const values = projection.project(row.trigger_id, row.payload || {}, unsub)
|
||||
|
||||
const rendered = await templates.renderByKey(key, values)
|
||||
if (!rendered) {
|
||||
// Neither a usable row nor a shipped seed. Terminal, and it names the key:
|
||||
// the operator deleted a template a rule points at, which the admin surface
|
||||
// refuses with a 409 — so reaching here means it happened out of band.
|
||||
return { ok: false, detail: `no template and no shipped default for "${key}"` }
|
||||
}
|
||||
if (rendered.missing.length) {
|
||||
// Not a refusal: an optional variable a trigger chose not to supply renders
|
||||
// as nothing by design. Logged with NAMES ONLY, never values — the same
|
||||
// rule the emit and dispatch log lines follow.
|
||||
log.debug('template variables had no value', { key, missing: rendered.missing })
|
||||
}
|
||||
|
||||
const result = await mailer().sendNotification({ to: to.address, rendered, ...unsub })
|
||||
// The send log stores a sha256 of the address and never the address itself
|
||||
// (schema.sql): enough to correlate a bounce in Phase 9, useless as a mailing
|
||||
// list. Attached on every outcome, because a failure is exactly the row a
|
||||
// bounce would need to be matched against.
|
||||
return { ...result, addressHash: hashAddress(to.address) }
|
||||
} catch (err) {
|
||||
// See the header: a throw here would be retried as if it were the relay's
|
||||
// fault. Classified as terminal instead, with the reason in the send log.
|
||||
log.error('email delivery failed', { outbox: row.id, message: err.message })
|
||||
return { ok: false, detail: `delivery error: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { addressFor, deliver, unsubscribeUrls, hashAddress, DEFAULT_TEMPLATE }
|
||||
@@ -37,6 +37,7 @@ const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const conditions = require('./conditions')
|
||||
const audiences = require('./audiences')
|
||||
const channels = require('./channels')
|
||||
const scopedPrefs = require('./scopedPrefs')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
@@ -52,23 +53,47 @@ const HOUR_MS = 60 * 60 * 1000
|
||||
const liveChannels = (rule) => (rule.channels || []).filter((c) => channels.has(c))
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to the users whose EFFECTIVE mode for (id, channel) is
|
||||
* not 'off'.
|
||||
* The EFFECTIVE mode each candidate holds for (id, channel), given the event's
|
||||
* scope.
|
||||
*
|
||||
* Effective, not stored: a row exists only where a user has expressed something,
|
||||
* and absence means the channel's `defaultMode` (§3.1). Reading the stored rows
|
||||
* and applying the default here keeps that answer in the registry, which is the
|
||||
* invariant Phase 3 established.
|
||||
*
|
||||
* A 'digest' preference is kept, not dropped. Digest delivery is Phase 6's, and
|
||||
* an outbox row for it is still the right record of "this person should be told";
|
||||
* what changes in Phase 6 is who drains it.
|
||||
* **A scoped preference wins outright where one exists** (Phase 6, decision 4).
|
||||
* `team_notification_prefs` stayed where it is and `scopedPrefs` is the adapter;
|
||||
* for a Team-scoped event that table is the preference, exactly as it has been
|
||||
* since Teams shipped. The argument for replacing rather than intersecting is in
|
||||
* scopedPrefs.js's header, and it is short: intersecting would have silenced
|
||||
* every existing Team-email subscriber on the deploy that migrated them.
|
||||
*/
|
||||
async function subscribedTo(userIds, streamId, channel) {
|
||||
if (!userIds.length) return []
|
||||
async function effectiveModes(userIds, streamId, channel, scopeKey) {
|
||||
const modes = new Map()
|
||||
if (!userIds.length) return modes
|
||||
const scoped = await scopedPrefs.resolve(userIds, channel, scopeKey)
|
||||
const stored = await recipients.storedModes(userIds, streamId, channel)
|
||||
const fallback = channels.defaultMode(channel)
|
||||
return userIds.filter((id) => (stored.get(id) ?? fallback) !== 'off')
|
||||
for (const id of userIds) modes.set(id, scoped.get(id) ?? stored.get(id) ?? fallback)
|
||||
return modes
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidates who should get an OUTBOX ROW for this channel.
|
||||
*
|
||||
* `off` is excluded for the obvious reason. **`digest` is excluded too, and that
|
||||
* corrects what Phase 4a said here** — its comment read "a 'digest' preference is
|
||||
* kept, not dropped… what changes in Phase 6 is who drains it", and what changed
|
||||
* in Phase 6 is that nothing drains it. §4.2b keeps `teamDigestWorker`'s
|
||||
* compute-at-send-time design, so a digest is re-derived from the source tables
|
||||
* when it goes out, not assembled from snapshots taken hours earlier. An outbox
|
||||
* row for a digest recipient would be a second copy of the content with none of
|
||||
* the three properties that design exists for — most importantly, it would mail
|
||||
* a user who lost access between the post and the send.
|
||||
*/
|
||||
async function subscribedTo(userIds, streamId, channel, scopeKey = null) {
|
||||
const modes = await effectiveModes(userIds, streamId, channel, scopeKey)
|
||||
return userIds.filter((id) => modes.get(id) === 'instant')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +157,7 @@ async function applyRule(rule, event, now) {
|
||||
const dueAt = new Date(now.getTime() + Math.max(0, rule.delay_seconds) * 1000)
|
||||
|
||||
for (const channel of live) {
|
||||
const eligible = await subscribedTo(resolved.userIds, event.triggerId, channel)
|
||||
const eligible = await subscribedTo(resolved.userIds, event.triggerId, channel, event.scopeKey)
|
||||
for (const userId of eligible) {
|
||||
if (budget <= 0) {
|
||||
summary.capped += 1
|
||||
@@ -151,6 +176,10 @@ async function applyRule(rule, event, now) {
|
||||
user_id: userId,
|
||||
channel,
|
||||
subject_key: subjectKey,
|
||||
// The scope a PREFERENCE and an UNSUBSCRIBE are keyed on, which is not
|
||||
// `subject_key`: for the Team triggers the subject is `teamName` (what a
|
||||
// cooldown counts) and the scope is `team:12` (what survives a rename).
|
||||
scope_key: event.scopeKey ?? null,
|
||||
payload: event.data,
|
||||
// Scoped per (rule, user, channel) by the unique index, so one event
|
||||
// fanned out to fifty people is fifty rows carrying the same key.
|
||||
@@ -238,4 +267,4 @@ async function dispatch(event, now = new Date()) {
|
||||
return summary
|
||||
}
|
||||
|
||||
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, liveChannels, HOUR_MS }
|
||||
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, effectiveModes, liveChannels, HOUR_MS }
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
require('./transports/smtp')
|
||||
require('./coreChannels')
|
||||
require('./coreScopePrefs')
|
||||
|
||||
const transports = require('./transports')
|
||||
const channels = require('./channels')
|
||||
|
||||
73
server/src/engagement/projection.js
Normal file
73
server/src/engagement/projection.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// ── The structural projection: any trigger through a generic template ──────
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.1 property 1, implemented in Phase 6. The property is that
|
||||
// **a new trigger renders through `notify.event` with no authoring at all** —
|
||||
// "add a trigger" must not mean "and now write a template". Nothing implemented
|
||||
// it before this phase, and building the email channel is what made the hole
|
||||
// visible: a trigger payload is domain-named (`teamName`, `threadTitle`,
|
||||
// `postUrl`) while the generic seeds are structural (`title`, `intro`, `items`,
|
||||
// `actionUrl`). The two vocabularies never met.
|
||||
//
|
||||
// **The rule, settled by the org lead 2026-08-29: the payload wins, and the
|
||||
// projection fills gaps.** A name the payload already carries is left exactly as
|
||||
// emitted — `news.post` and `team.announcement` both declare their own `title`,
|
||||
// and a projection that overwrote it would replace a real headline with a
|
||||
// category label. Only a name the payload does NOT define is supplied here.
|
||||
//
|
||||
// **What it is careful not to do is guess at domain meaning.** There is no table
|
||||
// mapping `threadTitle` onto `title`, and there will not be one: every such
|
||||
// mapping is a piece of one game's vocabulary compiled into core, and it is wrong
|
||||
// the first time a module names the same thing differently. The three fallbacks
|
||||
// below are all derived from the DECLARATION — a trigger's own label, its own
|
||||
// description, its own first declared url — which every trigger has by
|
||||
// construction because `registerEventTriggers` refuses one without them.
|
||||
//
|
||||
// The consequence, stated plainly: an unauthored mail for `team.forum.post` is
|
||||
// titled "Team — new forum post" rather than the thread's title. That is a plain
|
||||
// mail, not a wrong one, and the operator's answer is the bespoke template that
|
||||
// ships beside it (`notify.team-post` reads the payload's own names). A projection
|
||||
// clever enough to do better would be a projection that is confidently wrong on
|
||||
// the first module that does not follow core's naming.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
/**
|
||||
* The values a template renders with, for one event and one recipient.
|
||||
*
|
||||
* @param {string} triggerId
|
||||
* @param {Record<string, unknown>} payload the outbox row's snapshot — already
|
||||
* validated at emit, so it holds declared variables and nothing else
|
||||
* @param {Record<string, unknown>} [extra] per-recipient additions the channel
|
||||
* computes (`unsubscribeUrl`), merged LAST because they are facts about
|
||||
* the delivery rather than about the event
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
function project(triggerId, payload = {}, extra = {}) {
|
||||
const declaration = registries.eventTrigger(triggerId)
|
||||
const values = { ...payload }
|
||||
|
||||
// A dormant trigger still has an outbox row to deliver — the module was
|
||||
// uninstalled between enqueue and now. The payload is intact and the template
|
||||
// may well only reference payload names, so the mail goes out with whatever the
|
||||
// snapshot holds rather than being refused for want of a label.
|
||||
if (declaration) {
|
||||
if (values.title === undefined) values.title = declaration.label
|
||||
if (values.intro === undefined) values.intro = declaration.description || ''
|
||||
if (values.actionUrl === undefined) {
|
||||
const url = (declaration.variables || []).find(
|
||||
(v) => v.type === 'url' && typeof payload[v.name] === 'string' && payload[v.name],
|
||||
)
|
||||
if (url) values.actionUrl = payload[url.name]
|
||||
}
|
||||
}
|
||||
|
||||
// Set rather than left absent, so a generic template's item list renders as
|
||||
// nothing instead of reporting `items` as a missing variable. `missing` is what
|
||||
// the editor's preview shows an operator, and a name no trigger was ever going
|
||||
// to supply is noise in it.
|
||||
if (values.items === undefined) values.items = []
|
||||
|
||||
return { ...values, ...extra }
|
||||
}
|
||||
|
||||
module.exports = { project }
|
||||
122
server/src/engagement/scopedPrefs.js
Normal file
122
server/src/engagement/scopedPrefs.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// ── Scoped preferences: "this channel, for this one Team" ──────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6, decision 4. `notification_channel_prefs` is keyed
|
||||
// (user, stream, channel) and has no scope column; `team_notification_prefs` is
|
||||
// keyed (user, Team) and is the preference people actually hold today — someone
|
||||
// in six Teams silences one. Migrating the second into the first would mean a
|
||||
// live migration of user data, a wire-shape change on two clients, and the loss
|
||||
// of the granularity in between. The org lead's decision was to keep the Team
|
||||
// table and have the engine consult it; this file is the seam that lets it,
|
||||
// without core's engine learning what a Team is.
|
||||
//
|
||||
// A registrant claims a scope PREFIX — the part of a scope key before the colon,
|
||||
// `team` in `team:12` — and answers, for a set of users and one channel, what
|
||||
// that scope says their mode is.
|
||||
//
|
||||
// **Where a scope answers, its answer REPLACES the stream-level preference; it
|
||||
// does not intersect with it.** The decision was phrased as "a suppression below
|
||||
// the channel preference", and building it showed that reading is the one that
|
||||
// cannot ship: `notification_channel_prefs` holds a row only where a user has
|
||||
// expressed something, absence means the channel's `defaultMode`, and email's is
|
||||
// `off`. Nobody has ever expressed a stream-level opinion about `team.forum.post`
|
||||
// — the screen that would let them is Phase 3's and the preference predates it —
|
||||
// so intersecting would resolve every existing Team-email subscriber to `off` and
|
||||
// silence the entire live pipeline on the deploy that migrated it. That is the
|
||||
// G22 failure mode with a different cause. Replacement keeps today's behaviour
|
||||
// byte-for-byte: for a Team-scoped event, `team_notification_prefs` is the
|
||||
// preference, exactly as it has been since Teams shipped.
|
||||
//
|
||||
// The cost, stated so nobody has to rediscover it: a user cannot turn Team email
|
||||
// off for every Team at once from the channels screen. That control lives on the
|
||||
// per-Team screen, which is where it has always lived and where the unsubscribe
|
||||
// link points.
|
||||
//
|
||||
// Nothing here caches. A preference read is one indexed query per (event,
|
||||
// channel), against a table the user can change between two events.
|
||||
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
// prefix → provider
|
||||
const providers = new Map()
|
||||
|
||||
const PREFIX_RE = /^[a-z][a-z0-9_-]*$/
|
||||
|
||||
/**
|
||||
* Parse a scope key into its prefix and id. `''` and anything malformed are
|
||||
* `null` — an unparseable scope must read as "no scope", never as some other
|
||||
* scope's.
|
||||
*
|
||||
* @returns {{ prefix: string, id: string }|null}
|
||||
*/
|
||||
function parse(scopeKey) {
|
||||
const raw = String(scopeKey || '')
|
||||
const at = raw.indexOf(':')
|
||||
if (at < 1 || at === raw.length - 1) return null
|
||||
const prefix = raw.slice(0, at)
|
||||
if (!PREFIX_RE.test(prefix)) return null
|
||||
return { prefix, id: raw.slice(at + 1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a scope-preference provider.
|
||||
*
|
||||
* Validate-then-commit, the same discipline the transport and channel registries
|
||||
* use: every check runs before the map is touched.
|
||||
*
|
||||
* @param {object} def
|
||||
* @param {string} def.prefix the scope-key prefix this provider owns, e.g. 'team'
|
||||
* @param {string} def.label operator-facing, for the send log and admin copy
|
||||
* @param {(userIds: number[], channel: string, scopeId: string) => Promise<Map<number, string>>} def.modesFor
|
||||
* A mode per user for the users this scope has an opinion about. A user
|
||||
* left OUT of the map defers to the stream-level preference; a user in it
|
||||
* is answered by the scope. Must not throw — see `resolve`.
|
||||
*/
|
||||
function registerScopePreference(def) {
|
||||
if (!def || typeof def !== 'object') throw new Error('registerScopePreference: definition required')
|
||||
const { prefix, label, modesFor } = def
|
||||
if (typeof prefix !== 'string' || !PREFIX_RE.test(prefix)) {
|
||||
throw new Error(`registerScopePreference: invalid prefix ${JSON.stringify(prefix)}`)
|
||||
}
|
||||
if (providers.has(prefix)) throw new Error(`registerScopePreference: ${prefix} is already registered`)
|
||||
if (typeof label !== 'string' || !label) throw new Error(`registerScopePreference(${prefix}): label required`)
|
||||
if (typeof modesFor !== 'function') throw new Error(`registerScopePreference(${prefix}): modesFor required`)
|
||||
providers.set(prefix, { prefix, label, modesFor })
|
||||
return prefix
|
||||
}
|
||||
|
||||
/**
|
||||
* What does this scope say about these users on this channel?
|
||||
*
|
||||
* @returns {Promise<Map<number, string>>} empty when the scope is absent,
|
||||
* unparseable, or owned by nobody — all three of which mean "this event
|
||||
* is not scoped as far as preferences are concerned", which is the right
|
||||
* answer for a module whose scope provider has been uninstalled.
|
||||
*/
|
||||
async function resolve(userIds, channel, scopeKey) {
|
||||
const parsed = parse(scopeKey)
|
||||
if (!parsed || !userIds.length) return new Map()
|
||||
const provider = providers.get(parsed.prefix)
|
||||
if (!provider) return new Map()
|
||||
try {
|
||||
const modes = await provider.modesFor(userIds, channel, parsed.id)
|
||||
return modes instanceof Map ? modes : new Map()
|
||||
} catch (err) {
|
||||
// **Fails OPEN, and that is the uncomfortable choice made deliberately.** A
|
||||
// provider that throws leaves the stream-level preference in charge, which
|
||||
// for every core channel is `off` — so the practical effect of a failure is
|
||||
// that nothing is sent, not that everybody is mailed. Failing closed by
|
||||
// refusing the whole event would instead drop an IDOC warning because a Team
|
||||
// preference query timed out.
|
||||
log.error('scope preference lookup failed', { scope: scopeKey, channel, message: err.message })
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
const has = (prefix) => providers.has(prefix)
|
||||
|
||||
// Test-only: the registry is module-level state.
|
||||
function _reset() {
|
||||
providers.clear()
|
||||
}
|
||||
|
||||
module.exports = { registerScopePreference, resolve, parse, has, _reset }
|
||||
@@ -236,21 +236,21 @@ const SEEDS = [
|
||||
name: 'Team post notification',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
seedVersion: 2,
|
||||
subject: '{{teamName}}: {{threadTitle}}',
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil' },
|
||||
{ name: 'authorName', type: 'string', required: true, example: 'Aldric' },
|
||||
{ name: 'threadTitle', type: 'string', required: true, example: 'Meeting moved to Friday' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'We are pushing this week back a day so more people can make it.' },
|
||||
{ name: 'threadUrl', type: 'string', required: false, example: 'https://example.com/teams/1?thread=9' },
|
||||
{ name: 'postUrl', type: 'string', required: false, example: '/guilds/the-silver-anvil/forum/412' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', '{{authorName}} posted in {{teamName}}.'),
|
||||
heading('h', '{{threadTitle}}', 'h2'),
|
||||
text('excerpt', '{{excerpt}}', { muted: true }),
|
||||
button('cta', 'Read the thread', '{{threadUrl}}'),
|
||||
button('cta', 'Read the thread', '{{postUrl}}'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails for this team, use this link:'),
|
||||
],
|
||||
|
||||
62
server/src/model/engagement/engagementDigest.db.js
Normal file
62
server/src/model/engagement/engagementDigest.db.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// ── engagement_digest_state (ENGAGEMENT.md §4.2b, Phase 6) ─────────────────
|
||||
//
|
||||
// The state a digest keeps, and deliberately the ONLY state a digest keeps. What
|
||||
// goes IN a digest is re-derived from the source tables when the mail is about to
|
||||
// go out; this table answers one question — "what window does this person's next
|
||||
// digest cover?" — and nothing else.
|
||||
//
|
||||
// Lifted out of `team_notification_prefs.last_digest_at`, where it was a worker's
|
||||
// column sitting on a user's preferences row. Keyed (user, channel, scope) so a
|
||||
// second digest — on another channel, or over another scope — needs no second
|
||||
// column on somebody else's table.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* The stamps for a set of users in one scope, as a Map.
|
||||
*
|
||||
* Returns only the rows that exist. Absence is the CALLER's to interpret, and it
|
||||
* matters that it is: `clampSince` treats a missing row and a NULL stamp
|
||||
* identically (reach back one interval, not to the seven-day floor), so a person
|
||||
* who has never had a digest and a person whose row was written by the backfill
|
||||
* get the same first window.
|
||||
*/
|
||||
async function stampsFor(userIds, channel, scopeKey = '') {
|
||||
const ids = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT user_id, last_digest_at FROM engagement_digest_state
|
||||
WHERE channel = ? AND scope_key = ? AND user_id IN (${ids.map(() => '?').join(',')})`,
|
||||
[channel, scopeKey, ...ids],
|
||||
)
|
||||
return new Map(rows.map((r) => [Number(r.user_id), r.last_digest_at]))
|
||||
}
|
||||
|
||||
/** One user's stamp, or undefined. */
|
||||
async function stampFor(userId, channel, scopeKey = '') {
|
||||
const rows = await query(
|
||||
`SELECT last_digest_at FROM engagement_digest_state
|
||||
WHERE user_id = ? AND channel = ? AND scope_key = ?`,
|
||||
[Number(userId), channel, scopeKey],
|
||||
)
|
||||
return rows.length ? rows[0].last_digest_at : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a digest as delivered.
|
||||
*
|
||||
* Written ONLY after a successful send, which is the property the old
|
||||
* `stampDigest` had and the one worth restating: stamping first would silently
|
||||
* eat a day of somebody's notifications every time the mail provider has a bad
|
||||
* minute.
|
||||
*/
|
||||
async function stamp(userId, channel, scopeKey, at) {
|
||||
await query(
|
||||
`INSERT INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE last_digest_at = VALUES(last_digest_at)`,
|
||||
[Number(userId), channel, scopeKey || '', at],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { stampsFor, stampFor, stamp }
|
||||
@@ -22,14 +22,18 @@ const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, {}) }
|
||||
async function enqueue(row) {
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO engagement_outbox
|
||||
(rule_id, trigger_id, user_id, channel, subject_key, payload, dedupe_key, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(rule_id, trigger_id, user_id, channel, subject_key, scope_key, payload, dedupe_key, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.rule_id,
|
||||
row.trigger_id,
|
||||
row.user_id,
|
||||
row.channel,
|
||||
row.subject_key || '',
|
||||
// NULL, not '', for an unscoped event: '' is a scope key that means
|
||||
// "deployment-wide" in engagement_digest_state, and this column has to be
|
||||
// able to say "no scope at all" as well.
|
||||
row.scope_key ?? null,
|
||||
JSON.stringify(row.payload || {}),
|
||||
row.dedupe_key ?? null,
|
||||
row.due_at,
|
||||
|
||||
@@ -122,4 +122,29 @@ const storedModes = async (userIds, streamId, channel) => {
|
||||
return new Map(rows.map((r) => [Number(r.user_id), r.mode]))
|
||||
}
|
||||
|
||||
module.exports = { active, staff, subscribers, filterActive, storedModes, MAX_AUDIENCE }
|
||||
/**
|
||||
* One user's mailable address, or null — the email channel's `addressFor`
|
||||
* (Phase 6).
|
||||
*
|
||||
* `status = 'active'` is re-checked here even though every audience query already
|
||||
* filtered on it, and the gap it closes is real rather than theoretical: an
|
||||
* outbox row can sit through a `delay_seconds` grace window, so a user banned
|
||||
* between the emit and the send is exactly the case this catches. The cost is one
|
||||
* primary-key lookup on a path that is about to open an SMTP conversation.
|
||||
*
|
||||
* **It does not gate on `email_verified`.** Whether an unverified address may
|
||||
* receive opt-in mail is §7.1 Q1's narrower half, and it is a Phase 9 decision
|
||||
* with the suppression list in front of it; deciding it here by accident would
|
||||
* mean every deployment that upgraded before verifying its users stopped mailing
|
||||
* them.
|
||||
*/
|
||||
const addressFor = async (userId) => {
|
||||
const rows = await query(
|
||||
`SELECT email FROM users
|
||||
WHERE id = ? AND status = 'active' AND email IS NOT NULL AND email <> ''`,
|
||||
[Number(userId)],
|
||||
)
|
||||
return rows.length ? { address: rows[0].email } : null
|
||||
}
|
||||
|
||||
module.exports = { active, staff, subscribers, filterActive, storedModes, addressFor, MAX_AUDIENCE }
|
||||
|
||||
@@ -41,4 +41,27 @@ async function offPushExcept(userId, keep) {
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { listByUser, upsert, offPushExcept }
|
||||
/**
|
||||
* Turn one channel off for every named stream — the deployment-wide unsubscribe
|
||||
* (ENGAGEMENT.md Phase 6).
|
||||
*
|
||||
* It WRITES a row per stream rather than updating the rows that happen to exist,
|
||||
* and the difference is the same one `offPushExcept` argues: absence means the
|
||||
* channel's `defaultMode`, so updating only what is there would leave a user
|
||||
* unsubscribed today and re-subscribed the day a channel ships a non-off default.
|
||||
* An unsubscribe has to be a statement, not the absence of one.
|
||||
*/
|
||||
async function offForChannel(userId, channel, streamIds) {
|
||||
const ids = [...new Set(streamIds)].filter((s) => typeof s === 'string' && s)
|
||||
if (!ids.length) return null
|
||||
const values = ids.map(() => '(?, ?, ?, ?)').join(', ')
|
||||
const params = ids.flatMap((streamId) => [userId, streamId, channel, 'off'])
|
||||
return query(
|
||||
`INSERT INTO notification_channel_prefs (user_id, stream_id, channel, mode)
|
||||
VALUES ${values}
|
||||
ON DUPLICATE KEY UPDATE mode = VALUES(mode)`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { listByUser, upsert, offPushExcept, offForChannel }
|
||||
|
||||
Binary file not shown.
@@ -211,11 +211,31 @@ async function digestPostsSince(teamId, since, limit = 20) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The preference rows for a set of users in one Team — the scope-preference
|
||||
* provider's only query (ENGAGEMENT.md Phase 6).
|
||||
*
|
||||
* Returns only the rows that EXIST. Absence is answered by the caller, which is
|
||||
* the same discipline the two recipient queries follow with their COALESCEs: the
|
||||
* default lives in one place and it is the schema.
|
||||
*/
|
||||
async function prefsForTeam(userIds, teamId) {
|
||||
const ids = userIds.filter(isUserId)
|
||||
if (!ids.length) return []
|
||||
return query(
|
||||
`SELECT user_id, muted, email_mode
|
||||
FROM team_notification_prefs
|
||||
WHERE team_id = ? AND user_id IN (${ids.map(() => '?').join(',')})`,
|
||||
[teamId, ...ids],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
recipientIds,
|
||||
emailRecipients,
|
||||
prefsForUser,
|
||||
prefFor,
|
||||
prefsForTeam,
|
||||
setPref,
|
||||
stampDigest,
|
||||
teamsWithForumActivitySince,
|
||||
|
||||
@@ -140,6 +140,8 @@ module.exports = {
|
||||
// time, so the boundary is real.
|
||||
recipientIds: (teamId, opts) => db.recipientIds(teamId, opts),
|
||||
emailRecipients: (teamId, opts) => db.emailRecipients(teamId, opts),
|
||||
prefsForTeam: (userIds, teamId) => db.prefsForTeam(userIds, teamId),
|
||||
setEmailMode: (userId, teamId, emailMode) => db.setPref(userId, teamId, { emailMode }),
|
||||
stampDigest: (userId, teamId, at) => db.stampDigest(userId, teamId, at),
|
||||
teamsWithForumActivitySince: (since) => db.teamsWithForumActivitySince(since),
|
||||
digestPostsSince: (teamId, since, limit) => db.digestPostsSince(teamId, since, limit),
|
||||
|
||||
@@ -217,8 +217,23 @@ async function notifyRoster(team, { joined, promoted, demoted }) {
|
||||
// The count rides along for the Discord bridge (§7.2), which has no app on
|
||||
// the other end to pull the roster after a content-free nudge. The tickle
|
||||
// itself is unchanged and still carries nothing.
|
||||
if (joined.length > 0) await teamNotify.memberJoined(team, { count: joined.length })
|
||||
if (promoted.length > 0 || demoted.length > 0) await teamNotify.leadershipChanged(team)
|
||||
// `names` is the engagement engine's half (ENGAGEMENT.md Phase 6): the two
|
||||
// triggers declare `memberName` / `leaderName` as required single values, so
|
||||
// the fan-out emits one event per person while the tickle and the bridge stay
|
||||
// one per run. A member the module reported without a display name is skipped
|
||||
// rather than emitted as "someone" — a required variable filled with a
|
||||
// placeholder is a mail that names nobody.
|
||||
if (joined.length > 0) {
|
||||
await teamNotify.memberJoined(team, {
|
||||
count: joined.length,
|
||||
names: joined.map((m) => m.display_name).filter(Boolean),
|
||||
})
|
||||
}
|
||||
if (promoted.length > 0 || demoted.length > 0) {
|
||||
await teamNotify.leadershipChanged(team, {
|
||||
names: promoted.map((m) => m.display_name).filter(Boolean),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('roster notification not sent', { teamId: team.id, message: err.message })
|
||||
}
|
||||
|
||||
108
server/src/router/v1/public/engagement.controller.js
Normal file
108
server/src/router/v1/public/engagement.controller.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// ── Public engagement surface: one-click unsubscribe ───────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6. This is the generalization of what
|
||||
// `public/teams.controller.js` did for Teams: a token names a CHANNEL and a
|
||||
// SCOPE, and honouring it turns that channel off for that scope.
|
||||
//
|
||||
// **The old path stays forever**, and that is not tidiness debt. A link in a mail
|
||||
// sent before this deploy points at `/public/teams/unsubscribe/:token`, and mail
|
||||
// is not editable after it has been sent; a route that moves is a person who
|
||||
// cannot unsubscribe. `teams.router.js` therefore keeps its two routes and hands
|
||||
// them straight to these handlers, so the two paths cannot drift into meaning
|
||||
// different things.
|
||||
|
||||
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
||||
const prefs = require('../../../model/notificationChannelPrefs/notificationChannelPrefs.model')
|
||||
const unsubscribeToken = require('../../../utils/unsubscribeToken')
|
||||
const scopedPrefs = require('../../../engagement/scopedPrefs')
|
||||
const log = require('../../../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* Apply one verified claim.
|
||||
*
|
||||
* **Scoped claims are written to the scope's own store, not to
|
||||
* `notification_channel_prefs`.** A scoped preference is what the engine reads
|
||||
* for a scoped event (engine.js `effectiveModes`), so writing 'off' anywhere else
|
||||
* would be an unsubscribe that changes a row nothing consults. Today `team` is
|
||||
* the only registered scope, and it is handled here rather than through a
|
||||
* registry write-back for the reason Phase 3 gave for deferring `deliver`: a
|
||||
* second scope is what should design that interface, not the first one.
|
||||
*
|
||||
* An UNSCOPED claim (`scopeKey === ''`) turns the channel off across the board —
|
||||
* which today no mail produces, because every mail this platform sends carries a
|
||||
* scope. It is implemented rather than refused so that the first deployment-wide
|
||||
* mail does not ship with an unsubscribe link that quietly does nothing.
|
||||
*/
|
||||
async function applyClaim(claim) {
|
||||
if (!claim.scopeKey) {
|
||||
await prefs.setAllChannelOff(claim.userId, claim.channel)
|
||||
return
|
||||
}
|
||||
const parsed = scopedPrefs.parse(claim.scopeKey)
|
||||
if (parsed && parsed.prefix === 'team') {
|
||||
if (claim.channel === 'email') {
|
||||
// **Not `mute`, and this is Phase 6's deliberate narrowing.** A v1 token set
|
||||
// `muted = 1`, which silenced that Team's push as well as its email — a link
|
||||
// labelled "stop these emails" quietly stopping notifications on somebody's
|
||||
// phone. A token now names its channel and turns off that channel only.
|
||||
await teamPrefs.setEmailMode(claim.userId, Number(parsed.id), 'off')
|
||||
return
|
||||
}
|
||||
await teamPrefs.mute(claim.userId, Number(parsed.id))
|
||||
return
|
||||
}
|
||||
// A scope whose provider is not registered — a module uninstalled since the
|
||||
// mail went out. Nothing to write, and the caller is still told 200: the mail
|
||||
// that named it cannot be sent again either.
|
||||
log.warn('unsubscribe named an unknown scope', { scope: claim.scopeKey })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /public/engagement/unsubscribe/:token — one-click unsubscribe (RFC 8058).
|
||||
*
|
||||
* **The one write in this tier, and it is unauthenticated on purpose.** A person
|
||||
* reading their mail is not logged into the site, and an unsubscribe that first
|
||||
* demands a login is an unsubscribe most people do not complete. The token is what
|
||||
* stands in for the session, and the capability it carries is deliberately the
|
||||
* narrowest one that does the job: turn ONE channel off for ONE scope. It reads
|
||||
* nothing, cannot turn anything back on, and names no other scope.
|
||||
*
|
||||
* **Always 200, whatever the token was.** A response that distinguished a valid
|
||||
* token from a forged one would turn this into an oracle for which (user, scope)
|
||||
* pairs exist, on an endpoint with no session behind it. The page says "you will
|
||||
* not receive further emails about this" either way, which is true either way.
|
||||
*
|
||||
* Reached two ways with the same effect: a mail client's RFC 8058 one-click POST
|
||||
* (the `List-Unsubscribe-Post` header), and the site's own /unsubscribe page,
|
||||
* which POSTs here after a human clicks the link in the body.
|
||||
*/
|
||||
async function unsubscribe(req, res) {
|
||||
const claim = unsubscribeToken.verify(req.params.token)
|
||||
if (claim) {
|
||||
try {
|
||||
await applyClaim(claim)
|
||||
} catch (err) {
|
||||
// Logged, not surfaced. A failed write here is worth an operator's
|
||||
// attention and is not worth telling an anonymous caller about — and a 500
|
||||
// would make a mail client retry a request it should not repeat.
|
||||
log.error('unsubscribe', err)
|
||||
}
|
||||
}
|
||||
return res.json({ ok: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET on the same path — for a mail client that shows the `List-Unsubscribe` URL
|
||||
* as a link and has no one-click support.
|
||||
*
|
||||
* Redirects to the site's own page rather than acting, because a GET must not
|
||||
* mutate: a link prefetcher or a mail client's link scanner would otherwise
|
||||
* silently unsubscribe people who asked for nothing. The page it lands on does the
|
||||
* POST once a human is looking at it.
|
||||
*/
|
||||
function unsubscribeLanding(req, res) {
|
||||
const base = (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
return res.redirect(302, `${base}/unsubscribe/${encodeURIComponent(req.params.token)}`)
|
||||
}
|
||||
|
||||
module.exports = { unsubscribe, unsubscribeLanding, applyClaim }
|
||||
39
server/src/router/v1/public/engagement.router.js
Normal file
39
server/src/router/v1/public/engagement.router.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./engagement.controller')
|
||||
|
||||
const engagementRouter = express.Router()
|
||||
|
||||
// ── One-click unsubscribe (ENGAGEMENT.md Phase 6) ──────────────────────────
|
||||
//
|
||||
// The canonical home of the unsubscribe pair, generalized off
|
||||
// `/public/teams/unsubscribe/:token`. That path still exists and still works —
|
||||
// see `teams.router.js` — because links in mail already sent cannot be rewritten.
|
||||
//
|
||||
// No `siteMode`, unlike almost every other public route. An unsubscribe has to
|
||||
// work while the site is in maintenance: the mail that carried the link went out
|
||||
// before the site went down, and "we are doing maintenance" is not an answer to
|
||||
// "stop emailing me".
|
||||
engagementRouter.post(
|
||||
'/unsubscribe/:token',
|
||||
// #swagger.tags = ['Public · Engagement']
|
||||
// #swagger.summary = 'Unsubscribe from one channel for one scope'
|
||||
// #swagger.description = 'Honours the tokened link in an engagement email, including RFC 8058 one-click. The token names a delivery channel and a scope; the write turns that channel off for that scope and nothing else. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, scope) pairs exist. Tokens signed before this route existed are still honoured, at this path and at the older /public/teams one.'
|
||||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||||
// #swagger.security = [{}]
|
||||
/* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||||
ctrl.unsubscribe,
|
||||
)
|
||||
|
||||
engagementRouter.get(
|
||||
'/unsubscribe/:token',
|
||||
// #swagger.tags = ['Public · Engagement']
|
||||
// #swagger.summary = 'Land a human on the unsubscribe page'
|
||||
// #swagger.description = 'For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the site’s own confirmation page and changes nothing — a GET must not mutate, or a link scanner would unsubscribe people who asked for nothing.'
|
||||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||||
// #swagger.security = [{}]
|
||||
/* #swagger.responses[302] = { description: 'Redirect to the site’s unsubscribe page' } */
|
||||
ctrl.unsubscribeLanding,
|
||||
)
|
||||
|
||||
module.exports = engagementRouter
|
||||
@@ -22,6 +22,7 @@ const wikiRouter = require('./wiki.router')
|
||||
const pagesRouter = require('./pages.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const engagementRouter = require('./engagement.router')
|
||||
const siteRouter = require('./site.router')
|
||||
|
||||
const publicRouter = express.Router()
|
||||
@@ -41,6 +42,12 @@ publicRouter.use('/modules', modulesRouter)
|
||||
// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the
|
||||
// content above it.
|
||||
publicRouter.use('/teams', teamsRouter)
|
||||
// The unauthenticated half of the engagement system: today exactly the
|
||||
// unsubscribe pair. Its own prefix rather than a Teams sub-path, because what a
|
||||
// token names is a channel and a scope and a scope is not always a Team
|
||||
// (ENGAGEMENT.md Phase 6). Never site-mode gated — an unsubscribe has to work
|
||||
// while the site is in maintenance.
|
||||
publicRouter.use('/engagement', engagementRouter)
|
||||
|
||||
// The four singletons that own no path segment of their own: /settings, /status,
|
||||
// /version and /contact. Mounted at the group root, last — safe only because
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
const teams = require('../../../model/teams/teams.model')
|
||||
const teamActivity = require('../../../model/teams/teamActivity.model')
|
||||
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
||||
const unsubscribeToken = require('../../../utils/unsubscribeToken')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
@@ -100,52 +98,18 @@ async function getActivity(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /public/teams/unsubscribe/:token — one-click unsubscribe (TEAMS.md §6.4).
|
||||
*
|
||||
* **The one write in this tier, and it is unauthenticated on purpose.** A person
|
||||
* reading their mail is not logged into the site, and an unsubscribe that first
|
||||
* demands a login is an unsubscribe most people do not complete. The token is what
|
||||
* stands in for the session, and the capability it carries is deliberately the
|
||||
* narrowest one that does the job: set `muted` for ONE (user, Team) pair. It reads
|
||||
* nothing, cannot un-mute, and names no other Team.
|
||||
*
|
||||
* **Always 200, whatever the token was.** A response that distinguished a valid
|
||||
* token from a forged one would turn this into an oracle for which (user, Team)
|
||||
* pairs exist, on an endpoint with no session behind it. The page says "you will
|
||||
* not receive further emails about this team" either way, which is true either way.
|
||||
*
|
||||
* Reached two ways with the same effect: a mail client's RFC 8058 one-click POST
|
||||
* (the `List-Unsubscribe-Post` header), and the site's own /unsubscribe page,
|
||||
* which POSTs here after a human clicks the link in the body.
|
||||
*/
|
||||
async function unsubscribe(req, res) {
|
||||
const claim = unsubscribeToken.verify(req.params.token)
|
||||
if (claim) {
|
||||
try {
|
||||
await teamPrefs.mute(claim.userId, claim.teamId)
|
||||
} catch (err) {
|
||||
// Logged, not surfaced. A failed write here is worth an operator's
|
||||
// attention and is not worth telling an anonymous caller about — and a 500
|
||||
// would make a mail client retry a request it should not repeat.
|
||||
log.error('unsubscribe', err)
|
||||
}
|
||||
}
|
||||
return res.json({ ok: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET on the same path — for a mail client that shows the `List-Unsubscribe` URL
|
||||
* as a link and has no one-click support.
|
||||
*
|
||||
* Redirects to the site's own page rather than acting, because a GET must not
|
||||
* mutate: a link prefetcher or a mail client's link scanner would otherwise
|
||||
* silently mute Teams nobody asked to leave. The page it lands on does the POST
|
||||
* once a human is looking at it.
|
||||
*/
|
||||
function unsubscribeLanding(req, res) {
|
||||
const base = (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
return res.redirect(302, `${base}/unsubscribe/${encodeURIComponent(req.params.token)}`)
|
||||
}
|
||||
// ── One-click unsubscribe: the legacy path ─────────────────────────────────
|
||||
//
|
||||
// The handlers moved to `engagement.controller.js` in ENGAGEMENT.md Phase 6,
|
||||
// because what a token names is a channel and a scope and a scope is not always a
|
||||
// Team. **This path did NOT move**, and cannot: every Team notification sent
|
||||
// before that phase carries `/public/teams/unsubscribe/<token>` in its
|
||||
// `List-Unsubscribe` header and in its body, mail is not editable once sent, and
|
||||
// a route that moves is a person who cannot unsubscribe.
|
||||
//
|
||||
// Re-exported rather than reimplemented, so the two paths cannot drift into
|
||||
// meaning different things. A v1 token arriving here reads as
|
||||
// `{ channel: 'email', scopeKey: 'team:<id>' }` — see unsubscribeToken's header.
|
||||
const { unsubscribe, unsubscribeLanding } = require('./engagement.controller')
|
||||
|
||||
module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity, unsubscribe, unsubscribeLanding }
|
||||
|
||||
@@ -90,13 +90,20 @@ teamsRouter.get(
|
||||
ctrl.getActivity,
|
||||
)
|
||||
|
||||
// ── One-click unsubscribe (TEAMS.md §6.4) ──────────────────────────────────
|
||||
// ── One-click unsubscribe — the LEGACY path (TEAMS.md §6.4) ────────────────
|
||||
//
|
||||
// The canonical pair now lives at `/public/engagement/unsubscribe/:token`
|
||||
// (ENGAGEMENT.md Phase 6). These two stay, permanently, and hand straight to the
|
||||
// same handlers: mail sent before that phase carries this path in its
|
||||
// `List-Unsubscribe` header, and a route that moves is a person who cannot
|
||||
// unsubscribe.
|
||||
//
|
||||
// Declared last, and the shadowing question is worth answering rather than
|
||||
// assuming: these are two segments, so the one-segment '/:slug' cannot take them,
|
||||
// and the two-segment '/:slug/members' and '/:slug/activity' both pin a LITERAL
|
||||
// second segment. Only a token spelled exactly "members" or "activity" could
|
||||
// collide, and a token is `<v>.<uid>.<tid>.<mac>`.
|
||||
// collide, and a token is `<v>.<uid>.<tid>.<mac>` (v1) or
|
||||
// `<v>.<uid>.<channel>.<scope>.<mac>` (v2).
|
||||
//
|
||||
// No `siteMode`, unlike every other route in this file. An unsubscribe has to work
|
||||
// while the site is in maintenance: the mail that carried the link went out before
|
||||
@@ -105,8 +112,8 @@ teamsRouter.get(
|
||||
teamsRouter.post(
|
||||
'/unsubscribe/:token',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'Unsubscribe from one Team’s notification emails'
|
||||
// #swagger.description = 'Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.'
|
||||
// #swagger.summary = 'Unsubscribe from one Team’s notification emails (legacy path)'
|
||||
// #swagger.description = 'The pre-Phase-6 path, kept permanently because links in mail already sent point at it. Identical to POST /public/engagement/unsubscribe/{token}. Honours the tokened link including RFC 8058 one-click; a token signed before Phase 6 turns off that Team’s email and no longer mutes its push. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.'
|
||||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||||
// #swagger.security = [{}]
|
||||
/* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const engine = require('../engagement/engine')
|
||||
const scopedPrefs = require('../engagement/scopedPrefs')
|
||||
const createLogger = require('./logger')
|
||||
|
||||
const log = createLogger('engagement')
|
||||
@@ -39,6 +40,14 @@ const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
|
||||
// to prevent.
|
||||
const DEDUPE_KEY_MAX = 190
|
||||
|
||||
// engagement_outbox.scope_key is VARCHAR(190), same reasoning as above.
|
||||
const SCOPE_KEY_MAX = 190
|
||||
|
||||
// An emitter asserting an audience asserts a BOUNDED one. `MAX_AUDIENCE` (5000)
|
||||
// already caps what the engine will load from a query; this is the matching bound
|
||||
// on a list a caller built itself, and it is the same number for the same reason.
|
||||
const RECIPIENTS_MAX = 5000
|
||||
|
||||
const isProd = () => process.env.NODE_ENV === 'production'
|
||||
|
||||
/** Coerce and check one declared variable. Returns `{ value }` or `{ error }`. */
|
||||
@@ -156,7 +165,7 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
return fail(`"${triggerId}" is kind "${declaration.kind}" and is not emitted directly`)
|
||||
}
|
||||
|
||||
const { subject, data, ownerUserId, dedupeKey, occurredAt } = envelope || {}
|
||||
const { subject, data, ownerUserId, dedupeKey, occurredAt, scopeKey, recipientUserIds } = envelope || {}
|
||||
|
||||
const payload = validatePayload(declaration, data)
|
||||
if (!payload.ok) return fail(`payload for "${triggerId}" is invalid`, payload.errors.join('; '))
|
||||
@@ -181,6 +190,41 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// The scope this event is ABOUT: `team:12`, or absent. Distinct from `subject`,
|
||||
// which is what a cooldown counts — see the engine's enqueue. It is a stable
|
||||
// identifier because an unsubscribe token is signed over it and sits in a
|
||||
// mailbox for months; a display name would orphan the link on the first rename.
|
||||
let resolvedScope = null
|
||||
if (scopeKey !== undefined && scopeKey !== null) {
|
||||
if (typeof scopeKey !== 'string' || !scopedPrefs.parse(scopeKey)) {
|
||||
return fail('scopeKey must be a string of the form "<kind>:<id>"')
|
||||
}
|
||||
if (scopeKey.length > SCOPE_KEY_MAX) return fail(`scopeKey must be at most ${SCOPE_KEY_MAX} characters`)
|
||||
resolvedScope = scopeKey
|
||||
}
|
||||
|
||||
// **The audience this particular firing is about** (Phase 6, decision 2). An
|
||||
// emitter that has already computed an access-checked recipient set — the Team
|
||||
// fan-out is the case that forced it — hands it over here, and a rule whose
|
||||
// audience is `members` resolves to it. It is a NARROWING input, not a
|
||||
// widening one: `audiences.resolveForRule` still filters it through
|
||||
// `users.status`, the ceiling is still `members`, and the G24 check still runs.
|
||||
// A rule with any other audience ignores it entirely.
|
||||
let resolvedRecipients = null
|
||||
if (recipientUserIds !== undefined && recipientUserIds !== null) {
|
||||
if (!Array.isArray(recipientUserIds)) return fail('recipientUserIds must be an array')
|
||||
if (recipientUserIds.length > RECIPIENTS_MAX) {
|
||||
// Bounded here rather than at the query, because the bound is about what an
|
||||
// emitter may assert. `MAX_AUDIENCE` already caps what the engine will load;
|
||||
// this stops a caller building a list that large in the first place.
|
||||
return fail(`recipientUserIds must hold at most ${RECIPIENTS_MAX} ids`)
|
||||
}
|
||||
if (!recipientUserIds.every((n) => Number.isInteger(n) && n > 0)) {
|
||||
return fail('recipientUserIds must be positive integers')
|
||||
}
|
||||
resolvedRecipients = [...new Set(recipientUserIds)]
|
||||
}
|
||||
|
||||
if (dedupeKey !== undefined && dedupeKey !== null) {
|
||||
if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) {
|
||||
return fail(`dedupeKey must be a string of 1-${DEDUPE_KEY_MAX} characters`)
|
||||
@@ -200,6 +244,8 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
version: declaration.version,
|
||||
subject: resolvedSubject,
|
||||
ownerUserId: ownerUserId === undefined ? null : ownerUserId,
|
||||
scopeKey: resolvedScope,
|
||||
recipientUserIds: resolvedRecipients,
|
||||
dedupeKey: dedupeKey === undefined ? null : dedupeKey,
|
||||
occurredAt: at.toISOString(),
|
||||
data: payload.data,
|
||||
@@ -233,4 +279,4 @@ function emit(owner, triggerId, envelope = {}) {
|
||||
return { ok: true, event }
|
||||
}
|
||||
|
||||
module.exports = { emit, validatePayload, RELATIVE_URL, DEDUPE_KEY_MAX }
|
||||
module.exports = { emit, validatePayload, RELATIVE_URL, DEDUPE_KEY_MAX, SCOPE_KEY_MAX, RECIPIENTS_MAX }
|
||||
|
||||
@@ -56,7 +56,7 @@ const STALE_MS = 15 * 60 * 1000
|
||||
/**
|
||||
* Deliver one claimed row.
|
||||
*
|
||||
* @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string }}
|
||||
* @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string, addressHash?: string }}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
const channel = channels.get(row.channel)
|
||||
@@ -71,9 +71,17 @@ async function deliver(row) {
|
||||
}
|
||||
try {
|
||||
const result = await channel.deliver(row)
|
||||
if (result && result.ok) return { outcome: 'sent', transport: result.transport, detail: result.detail }
|
||||
if (result && result.retry) return { outcome: 'retry', detail: result.detail || 'transient failure' }
|
||||
return { outcome: 'terminal', detail: (result && result.detail) || 'delivery refused' }
|
||||
// `addressHash` rides on every outcome, success or not: a bounce (Phase 9)
|
||||
// arrives with an address and has to find the row it belongs to, and the rows
|
||||
// worth correlating include the ones that already failed once.
|
||||
const hash = (result && result.addressHash) || undefined
|
||||
if (result && result.ok) {
|
||||
return { outcome: 'sent', transport: result.transport, detail: result.detail, addressHash: hash }
|
||||
}
|
||||
if (result && result.retry) {
|
||||
return { outcome: 'retry', detail: result.detail || 'transient failure', addressHash: hash }
|
||||
}
|
||||
return { outcome: 'terminal', detail: (result && result.detail) || 'delivery refused', addressHash: hash }
|
||||
} catch (err) {
|
||||
// A channel shouldn't throw, but if one does it is a transient failure
|
||||
// rather than a crashed tick - announceWorker's posture with its legs.
|
||||
@@ -111,6 +119,7 @@ async function processRow(row, now = new Date(), deliverFn = deliver) {
|
||||
user_id: row.user_id,
|
||||
channel: row.channel,
|
||||
transport: result.transport ?? null,
|
||||
address_hash: result.addressHash ?? null,
|
||||
status,
|
||||
detail: result.detail ?? null,
|
||||
})
|
||||
|
||||
@@ -20,9 +20,15 @@
|
||||
// contracts are unchanged by the transport rewrite and are asserted in
|
||||
// test/mailer.test.js: sendContactMessage returns a mailto fallback, sendInvite
|
||||
// and sendPasswordReset return { sent: false, reason: 'NOT_CONFIGURED' } so their
|
||||
// callers can surface a link / answer a generic 200, sendTeamNotification never
|
||||
// throws at all, and only sendTest throws — because only sendTest has an admin
|
||||
// waiting to be told why.
|
||||
// callers can surface a link / answer a generic 200, sendNotification classifies
|
||||
// its failure for a worker rather than reporting it to anybody, and only sendTest
|
||||
// throws — because only sendTest has an admin waiting to be told why.
|
||||
//
|
||||
// Phase 6 removed `sendTeamNotification`. It was the last sender that built its
|
||||
// own body shape, and what replaced it is not another sender: the email
|
||||
// DeliveryChannel renders an `engagement_templates` row and calls
|
||||
// `sendNotification`, so a Team mail is now the same kind of thing as any other
|
||||
// rule-driven mail.
|
||||
|
||||
const emailConfig = require('../model/emailConfig/emailConfig.model')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
@@ -338,77 +344,6 @@ async function sendEmailVerification({ to, verifyUrl, username }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
replyTo: replyToFor(config),
|
||||
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: describeSendError(err, config) }).catch(() => {})
|
||||
return { sent: false, reason: 'SEND_FAILED' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an ALREADY-RENDERED body to one address — the template editor's test send
|
||||
* (§4.6.2, Phase 5b).
|
||||
@@ -451,13 +386,77 @@ async function sendRendered(to, rendered) {
|
||||
}
|
||||
}
|
||||
|
||||
// A send failure that will still be a failure on the fifth attempt. Everything
|
||||
// else — a refused connection, a timeout, a relay having a bad minute, a
|
||||
// deployment whose operator is halfway through typing its credentials — is worth
|
||||
// the flat five-minute retry `engagementWorker` gives it. Getting this backwards
|
||||
// in the safe direction costs four pointless reconnects; getting it backwards in
|
||||
// the other direction drops somebody's mail on a transient blip.
|
||||
const PERMANENT_CODES = new Set([550, 553, 554, 'EENVELOPE', 'EAUTH'])
|
||||
|
||||
/**
|
||||
* Deliver one already-rendered engagement message (ENGAGEMENT.md Phase 6).
|
||||
*
|
||||
* The third sender in this file that never throws, and the reasons are the three
|
||||
* different ones: `sendContactMessage` reports to a request, `sendTest` to an
|
||||
* admin standing at a button, and this one to a worker sweeping a queue at three
|
||||
* in the morning. What it returns is a CLASSIFICATION rather than a boolean,
|
||||
* because the worker's next move — retry, or write a terminal row in the send
|
||||
* log — is exactly what a boolean cannot say.
|
||||
*
|
||||
* **It does not `recordStatus('connected')` on success**, unlike `sendRendered`.
|
||||
* That column is the admin screen's account of whether the operator's
|
||||
* configuration works, written by the actions an operator takes; a background
|
||||
* sweep quietly flipping it to "Test send OK" would be this file reporting on
|
||||
* itself. A FAILURE is still recorded, because a relay that has started refusing
|
||||
* mail is precisely what that screen exists to show.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
|
||||
*/
|
||||
async function sendNotification({ to, rendered, unsubscribeUrl, unsubscribeApiUrl }) {
|
||||
const built = await buildTransport()
|
||||
// Retryable, not terminal: an operator midway through setting up SMTP should
|
||||
// find the queue drains rather than a backlog of permanently failed rows.
|
||||
if (!built) return { ok: false, retry: true, detail: 'email is not configured' }
|
||||
const { transport, config } = built
|
||||
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
replyTo: replyToFor(config),
|
||||
subject: rendered.subject,
|
||||
text: rendered.text,
|
||||
html: rendered.html,
|
||||
// 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. Both headers or neither — RFC 8058 one-click is only
|
||||
// one-click when the POST variant says so.
|
||||
headers: (unsubscribeApiUrl || unsubscribeUrl)
|
||||
? {
|
||||
'List-Unsubscribe': `<${unsubscribeApiUrl || unsubscribeUrl}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
return { ok: true, transport: config.transport }
|
||||
} catch (err) {
|
||||
const detail = describeSendError(err, config)
|
||||
const code = err && (err.responseCode || err.code)
|
||||
log.warn('engagement send failed', { message: err.message })
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: detail }).catch(() => {})
|
||||
return { ok: false, retry: !PERMANENT_CODES.has(code), transport: config.transport, detail }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
sendContactMessage,
|
||||
sendTest,
|
||||
sendRendered,
|
||||
sendNotification,
|
||||
sendInvite,
|
||||
sendPasswordReset,
|
||||
sendEmailVerification,
|
||||
sendTeamNotification,
|
||||
PERMANENT_CODES,
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// ── Team forum digest worker (TEAMS.md §6.4, phase 6) ──────────────────────
|
||||
// ── Team forum digest worker (TEAMS.md §6.4; migrated in ENGAGEMENT.md Phase 6) ──
|
||||
//
|
||||
// Daily, per (user, Team): "here is what you missed". The same in-process shape as
|
||||
// utils/teamActivityPrune and utils/announceWorker — setInterval + unref + stop(),
|
||||
// wired into server.js start/shutdown. There is no cron in this stack.
|
||||
//
|
||||
// **It computes at send time and keeps no queue.** The only state is
|
||||
// `team_notification_prefs.last_digest_at`; everything else is re-derived from the
|
||||
// forum tables when the mail is about to go out. Three properties fall out of that,
|
||||
// and they are why the design chose it over a pending-items table:
|
||||
// **It computes at send time and keeps no queue, and Phase 6 deliberately did not
|
||||
// change that.** §4.2b's decision was to generalize this worker's STATE, not its
|
||||
// absence of one. Three properties fall out of computing at send time, and they
|
||||
// are why the design chose it over a pending-items table — and, now, over the
|
||||
// engine's own outbox:
|
||||
//
|
||||
// 1. A deployment that was down for two days sends ONE correct digest, not two
|
||||
// days of replay.
|
||||
@@ -18,14 +19,29 @@
|
||||
// the recipient set, so they are not emailed content they can no longer read.
|
||||
// This is the one that would have been a security bug.
|
||||
//
|
||||
// An outbox row carries a snapshot of the payload taken at emit time and has none
|
||||
// of the three. That is why `engine.subscribedTo` enqueues only `instant`
|
||||
// recipients and leaves `digest` to this file.
|
||||
//
|
||||
// **What DID change is everything around the query.** The state is
|
||||
// `engagement_digest_state` rather than a column on the preferences row; the body
|
||||
// is the `notify.digest` template an operator can edit rather than a literal in
|
||||
// `mailer`; the unsubscribe link is a v2 token naming the email channel; and the
|
||||
// whole worker is gated on an ENABLED rule, so an operator who switches Team
|
||||
// email off switches off both halves of it rather than the immediate half only.
|
||||
//
|
||||
// **The first run is delayed, for the same reason the prune's is**: a boot that is
|
||||
// crash-looping must not send mail on every loop.
|
||||
|
||||
const teamNotify = require('../model/teams/teamNotify.model')
|
||||
const forumSettings = require('../model/teams/teamForumSettings.model')
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const sendsDb = require('../model/engagement/engagementSends.db')
|
||||
const digestDb = require('../model/engagement/engagementDigest.db')
|
||||
const templates = require('../engagement/templates')
|
||||
const emailChannel = require('../engagement/emailChannel')
|
||||
const mailer = require('./mailer')
|
||||
const notify = require('./teamNotify')
|
||||
const brand = require('../config/brand')
|
||||
const log = require('./logger')('team-digest')
|
||||
|
||||
const INTERVAL_MS = Number(process.env.TEAM_DIGEST_INTERVAL_MS) || 24 * 60 * 60 * 1000
|
||||
@@ -41,6 +57,16 @@ const MAX_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
// to the Team is the better answer.
|
||||
const MAX_ITEMS = 20
|
||||
|
||||
// The two triggers a Team forum digest summarises. A rule on either, enabled and
|
||||
// naming the email channel, is what turns this worker on.
|
||||
const DIGEST_TRIGGERS = ['team.forum.post', 'team.announcement']
|
||||
|
||||
// The digest's own template key, and the rule's `template_keys.digest` overrides
|
||||
// it. A digest is not the same message as the instant mail and must not silently
|
||||
// borrow `template_keys.email`: that template is written for one event and would
|
||||
// render a day's worth of posts as a single missing `{{threadTitle}}`.
|
||||
const DEFAULT_TEMPLATE = 'notify.digest'
|
||||
|
||||
let timer = null
|
||||
let firstRun = null
|
||||
|
||||
@@ -53,6 +79,29 @@ const clampSince = (last, now) => {
|
||||
return at < floor ? floor : at
|
||||
}
|
||||
|
||||
/**
|
||||
* The enabled email rule that authorises Team forum digests, or null.
|
||||
*
|
||||
* **The gate is why this worker did not simply keep running.** Phase 6's decision
|
||||
* 3 is that Team notifications become rules an operator turns on; if the immediate
|
||||
* mail were rule-gated and the digest were not, disabling the rule would stop one
|
||||
* kind of Team mail and leave a daily summary arriving indefinitely — which reads
|
||||
* as the switch being broken.
|
||||
*
|
||||
* The FIRST matching rule wins, and the tie-break is not interesting because what
|
||||
* is read off it is one template key. Two rules disagreeing about the template of
|
||||
* a digest neither of them describes is a configuration an operator can see in the
|
||||
* rules list.
|
||||
*/
|
||||
async function digestRule() {
|
||||
for (const triggerId of DIGEST_TRIGGERS) {
|
||||
const rules = await rulesDb.enabledForTrigger(triggerId)
|
||||
const rule = rules.find((r) => (r.channels || []).includes('email'))
|
||||
if (rule) return rule
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* One recipient's digest for one Team. Returns true if a mail went out.
|
||||
*
|
||||
@@ -61,7 +110,8 @@ const clampSince = (last, now) => {
|
||||
* stamping first, silently eats a day of somebody's notifications every time the
|
||||
* mail provider has a bad minute.
|
||||
*/
|
||||
async function sendOne(team, recipient, now) {
|
||||
async function sendOne(team, recipient, rule, now) {
|
||||
const scopeKey = notify.scopeKey(team)
|
||||
const since = clampSince(recipient.last_digest_at, now)
|
||||
const posts = await teamNotify.digestPostsSince(team.id, since, MAX_ITEMS)
|
||||
// Nothing new for THIS recipient — which is not the same as nothing new for the
|
||||
@@ -70,21 +120,54 @@ async function sendOne(team, recipient, now) {
|
||||
if (posts.length === 0) return false
|
||||
|
||||
const label = notify.teamLabel(team)
|
||||
const res = await mailer.sendTeamNotification({
|
||||
to: recipient.email,
|
||||
subject: `[${brand.name}] ${label}: ${posts.length} new post${posts.length === 1 ? '' : 's'}`,
|
||||
const unsub = emailChannel.unsubscribeUrls(recipient.user_id, scopeKey)
|
||||
const key = (rule && rule.template_keys && rule.template_keys.digest) || DEFAULT_TEMPLATE
|
||||
|
||||
const rendered = await templates.renderByKey(key, {
|
||||
periodLabel: `${label}: ${posts.length} new post${posts.length === 1 ? '' : 's'}`,
|
||||
intro: `Since your last digest, ${posts.length} new post${posts.length === 1 ? '' : 's'} in ${label}:`,
|
||||
items: posts.map((p) => ({
|
||||
heading: `${p.title} — ${p.author_username || 'someone'}`,
|
||||
excerpt: notify.excerpt(p.body_html),
|
||||
url: notify.threadUrl(team, p.thread_id),
|
||||
url: notify.threadPath(team, p.thread_id),
|
||||
})),
|
||||
teamUrl: notify.teamPageUrl(team),
|
||||
unsubscribeUrl: notify.unsubscribeUrl(recipient.user_id, team.id),
|
||||
unsubscribeApiUrl: notify.unsubscribeApiUrl(recipient.user_id, team.id),
|
||||
scopeUrl: notify.teamPagePath(team),
|
||||
...unsub,
|
||||
})
|
||||
if (!res || !res.sent) return false
|
||||
await teamNotify.stampDigest(recipient.user_id, team.id, now)
|
||||
if (!rendered) {
|
||||
log.warn('digest template is missing and has no shipped default', { key })
|
||||
return false
|
||||
}
|
||||
|
||||
const result = await mailer.sendNotification({ to: recipient.email, rendered, ...unsub })
|
||||
|
||||
// Recorded in `engagement_sends` like every other message the platform sends,
|
||||
// which is G15's whole point: "did user X get the digest?" was unanswerable
|
||||
// before this phase because the digest went out through a sender that wrote
|
||||
// nothing down. `outbox_id` is null because a digest has no outbox row — see
|
||||
// this file's header — and that null is the honest record of a different path,
|
||||
// not a missing value.
|
||||
await sendsDb
|
||||
.record({
|
||||
outbox_id: null,
|
||||
rule_id: rule ? rule.id : null,
|
||||
trigger_id: DIGEST_TRIGGERS[0],
|
||||
user_id: recipient.user_id,
|
||||
channel: 'email',
|
||||
transport: result.transport ?? null,
|
||||
// The same hash the instant path writes, and it has to be the same
|
||||
// function: a bounce (Phase 9) arrives with an address and is matched
|
||||
// against this column, so a digest row without one is a delivery that
|
||||
// cannot be correlated. Found by reading the send log on the live rig,
|
||||
// where the instant row had a hash and the digest row beside it did not.
|
||||
address_hash: emailChannel.hashAddress(recipient.email),
|
||||
status: result.ok ? 'sent' : 'failed',
|
||||
detail: result.ok ? null : result.detail,
|
||||
})
|
||||
.catch((err) => log.warn('digest send not logged', { message: err.message }))
|
||||
|
||||
if (!result.ok) return false
|
||||
await digestDb.stamp(recipient.user_id, 'email', scopeKey, now)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -97,11 +180,14 @@ async function sendOne(team, recipient, now) {
|
||||
async function tick(now = new Date()) {
|
||||
const summary = { teams: 0, sent: 0, skipped: null }
|
||||
try {
|
||||
// Two cheap gates before any query that costs anything. Forums switched off
|
||||
// Three cheap gates before any query that costs anything. Forums switched off
|
||||
// means the content this digest summarises is not readable on the site
|
||||
// either, and un-configured email means there is no sink at all (§6.4).
|
||||
// either; un-configured email means there is no sink at all (§6.4); and no
|
||||
// enabled rule means the operator has not turned Team email on.
|
||||
if (!(await forumSettings.forumsEnabled())) return { ...summary, skipped: 'forums-disabled' }
|
||||
if (!(await mailer.isConfigured())) return { ...summary, skipped: 'email-unconfigured' }
|
||||
const rule = await digestRule()
|
||||
if (!rule) return { ...summary, skipped: 'no-enabled-rule' }
|
||||
|
||||
const floor = new Date(now.getTime() - MAX_LOOKBACK_MS)
|
||||
const teams = await teamNotify.teamsWithForumActivitySince(floor)
|
||||
@@ -110,15 +196,27 @@ async function tick(now = new Date()) {
|
||||
for (const team of teams) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const rows = await teamNotify.emailRecipients(team.id)
|
||||
for (const r of rows.filter((x) => x.email_mode === 'digest')) {
|
||||
const wanted = rows.filter((x) => x.email_mode === 'digest')
|
||||
if (!wanted.length) continue
|
||||
// The stamps now live in their own table, so they are read here rather than
|
||||
// arriving on the recipient row. One query per Team, not one per recipient.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const stamps = await digestDb.stampsFor(
|
||||
wanted.map((r) => r.user_id),
|
||||
'email',
|
||||
notify.scopeKey(team),
|
||||
)
|
||||
for (const r of wanted) {
|
||||
try {
|
||||
// Serial, like the immediate sender and for the same reason: one SMTP
|
||||
// Serial, like the instant path and for the same reason: one SMTP
|
||||
// conversation at a time against a provider with its own rate limits.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await sendOne(team, r, now)) summary.sent += 1
|
||||
const recipient = { ...r, last_digest_at: stamps.get(Number(r.user_id)) ?? null }
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await sendOne(team, recipient, rule, now)) summary.sent += 1
|
||||
} catch (err) {
|
||||
// One recipient's failure must not end the sweep for the rest. The
|
||||
// unstamped preference means the next run retries this one.
|
||||
// unstamped window means the next run retries this one.
|
||||
log.warn('digest send failed', { teamId: team.id, userId: r.user_id, message: err.message })
|
||||
}
|
||||
}
|
||||
@@ -155,4 +253,15 @@ function stop() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop, tick, clampSince, MAX_LOOKBACK_MS, MAX_ITEMS }
|
||||
module.exports = {
|
||||
start,
|
||||
stop,
|
||||
tick,
|
||||
sendOne,
|
||||
clampSince,
|
||||
digestRule,
|
||||
MAX_LOOKBACK_MS,
|
||||
MAX_ITEMS,
|
||||
DIGEST_TRIGGERS,
|
||||
DEFAULT_TEMPLATE,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
// ── Team notification fan-out (TEAMS.md Part 6, phase 6) ───────────────────
|
||||
// ── Team notification fan-out (TEAMS.md Part 6; migrated in ENGAGEMENT.md 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.
|
||||
// One event in, three sinks out — and **as of Phase 6 only two of them are still
|
||||
// this file's**. The expensive part of a notification is working out who should
|
||||
// get it, and that is still computed once here and handed to all three.
|
||||
//
|
||||
// 1. the content-free push tickle — direct, here
|
||||
// 2. the Discord bridge — direct, here
|
||||
// 3. **email — now the engagement engine's**, reached by `events.emit`
|
||||
//
|
||||
// **Why email left and the other two did not.** A channel in the engine's sense
|
||||
// is a per-recipient sink with a preference, an address and a digest mode; email
|
||||
// is one, the bridge is not (its audience is whoever can read a Discord channel,
|
||||
// which is why §3.1 warns against unifying a *leg* with a *channel*), and push's
|
||||
// `deliver` belongs to Phase 7, which is when the inbox gives a tickle a `ref`
|
||||
// worth deep-linking. Moving push a phase early would also have meant
|
||||
// reconciling its per-Team opt-OUT with a registry whose `defaultMode` is `off`,
|
||||
// and getting that wrong silences every existing member.
|
||||
//
|
||||
// **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
|
||||
@@ -17,30 +30,28 @@
|
||||
// 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).
|
||||
// **The recipient set is computed HERE and travels on the envelope.** It is the
|
||||
// same access-checked query it has always been (`teamNotify.recipientIds`, which
|
||||
// asks the two tables `teamAccess.forumAccess()` asks), and the engine resolves a
|
||||
// `members` audience to exactly it. That is Phase 6's decision 2, and the reason
|
||||
// for it is that a saved audience segment composes module-declared lists with
|
||||
// CONSTANT parameters — it cannot express "the members of the Team this post was
|
||||
// in", because the answer is different for every firing. Core does not learn what
|
||||
// a Team is; the event says who it is about.
|
||||
//
|
||||
// **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.
|
||||
// **Roster events now emit too, and they still do not mail anybody by default.**
|
||||
// §6.4's argument for keeping "someone joined" out of the mail — it arrives from
|
||||
// a fifteen-minute sweep, it is already on the activity feed, and mailing it is
|
||||
// how a notification feature earns a spam complaint — is now expressed as a rule
|
||||
// an operator has to enable rather than as a sink this file declines to call. The
|
||||
// four rules core seeds are all disabled; the argument survives as the default.
|
||||
|
||||
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 engagementEmit = require('./engagementEmit')
|
||||
const registries = require('../modules/registries')
|
||||
const unsubscribeToken = require('./unsubscribeToken')
|
||||
const brand = require('../config/brand')
|
||||
const log = require('./logger')('team-notify')
|
||||
|
||||
const STREAMS = {
|
||||
@@ -56,6 +67,9 @@ const EXCERPT_CHARS = 200
|
||||
|
||||
const baseUrl = () => (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
|
||||
/** The scope an unsubscribe link and a per-Team preference are keyed on. */
|
||||
const scopeKey = (team) => `team:${Number(team.id)}`
|
||||
|
||||
/**
|
||||
* Where this Team's page lives, or null.
|
||||
*
|
||||
@@ -65,39 +79,41 @@ const baseUrl = () => (process.env.APP_BASE_URL || 'http://localhost:5173').repl
|
||||
* gets email that names the Team and cannot link to it, which is a worse email
|
||||
* and not a broken one.
|
||||
*/
|
||||
function teamPageUrl(team) {
|
||||
function teamPagePath(team) {
|
||||
const provider = registries.registeredTeamProvider()
|
||||
const template = provider && provider.pageUrlTemplate
|
||||
if (!template || !team) return null
|
||||
const path = template
|
||||
return 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.
|
||||
// **Two functions where there used to be one, and the split is the emit
|
||||
// contract.** A trigger's `url` variables are validated site-RELATIVE
|
||||
// (`engagementEmit.RELATIVE_URL`), because a variable that ends up in an href
|
||||
// must not be able to carry an absolute one somewhere else; the mail renderer
|
||||
// then absolutizes them against the deployment's base. The Discord bridge, which
|
||||
// posts to a client that has no notion of this origin, still needs the absolute
|
||||
// form. So the path is the value that travels and the URL is the value that is
|
||||
// displayed.
|
||||
const teamPageUrl = (team) => {
|
||||
const path = teamPagePath(team)
|
||||
return path ? `${baseUrl()}${path}` : null
|
||||
}
|
||||
|
||||
// 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.
|
||||
const threadPath = (team, threadId) => {
|
||||
const page = teamPagePath(team)
|
||||
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 threadUrl = (team, threadId) => {
|
||||
const path = threadPath(team, threadId)
|
||||
return path ? `${baseUrl()}${path}` : null
|
||||
}
|
||||
|
||||
const teamLabel = (team) => (team && (team.display_name_override || team.name)) || 'your team'
|
||||
|
||||
@@ -123,20 +139,58 @@ async function tickle(streamId, team, { ref, exclude = [] } = {}) {
|
||||
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 } = {}) {
|
||||
/**
|
||||
* Hand one Team event to the engagement engine.
|
||||
*
|
||||
* Fire-and-forget by construction: `emit` validates the payload and dispatches
|
||||
* without awaiting (see engagementEmit's header), so this returns as soon as the
|
||||
* contract has been checked. A refused emit is a contract bug — it throws in
|
||||
* development and is logged in production — and either way it must not reach the
|
||||
* caller, which is a forum write that has already replied.
|
||||
*
|
||||
* `recipientUserIds` is the SAME set the tickle used, so the two sinks cannot
|
||||
* disagree about who this event is for.
|
||||
*/
|
||||
function emitTeamEvent(triggerId, team, data, recipientUserIds, { dedupeKey } = {}) {
|
||||
try {
|
||||
if (!recipientUserIds.length) return false
|
||||
const result = engagementEmit.emit('core', triggerId, {
|
||||
data: { teamName: teamLabel(team), ...data },
|
||||
scopeKey: scopeKey(team),
|
||||
recipientUserIds,
|
||||
dedupeKey,
|
||||
})
|
||||
return Boolean(result && result.ok)
|
||||
} catch (err) {
|
||||
log.warn('team event not emitted', { trigger: triggerId, teamId: team && team.id, message: err.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Roster events ──────────────────────────────────────────────────────────
|
||||
|
||||
// No `memberName` argument on the TICKLE, and that is the point: a tickle is
|
||||
// content-free, so there is nothing about WHO joined for it to carry. The name is
|
||||
// on the activity feed the app pulls after waking.
|
||||
//
|
||||
// `count` is phase 8's addition and it is for the BRIDGE: 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.
|
||||
//
|
||||
// `names` is Phase 6's, and it is for the ENGINE. `team.member.joined` declares
|
||||
// `memberName` required, so an event is emitted per joiner rather than per sweep:
|
||||
// the trigger names one person, and there is no honest way to put five into a
|
||||
// variable declared as one. What stops five joiners becoming five mails is the
|
||||
// rule's own cooldown and its hourly ceiling — the mechanisms that exist for
|
||||
// exactly this — rather than this file deciding on the operator's behalf.
|
||||
async function memberJoined(team, { count, names = [] } = {}) {
|
||||
try {
|
||||
const recipientIds = await teamNotify.recipientIds(team.id)
|
||||
const sent = await tickle(STREAMS.MEMBER_JOINED, team, { ref: `team:${team.id}` })
|
||||
for (const memberName of names) {
|
||||
emitTeamEvent(STREAMS.MEMBER_JOINED, team, { memberName, teamUrl: teamPagePath(team) }, recipientIds)
|
||||
}
|
||||
await teamBridge.deliver(STREAMS.MEMBER_JOINED, team, {
|
||||
body: teamBridge.memberJoinedBody(count),
|
||||
teamUrl: teamPageUrl(team),
|
||||
@@ -149,9 +203,18 @@ async function memberJoined(team, { count } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function leadershipChanged(team) {
|
||||
// `leaderName` is required by the declaration and means "the NEW leader", so an
|
||||
// event is emitted per promotion and a run that only demoted somebody emits none.
|
||||
// The tickle and the bridge still fire for either, exactly as before: "leadership
|
||||
// changed" is a true thing to nudge about even when nobody was promoted, and it
|
||||
// is not a true thing to name a new leader in.
|
||||
async function leadershipChanged(team, { names = [] } = {}) {
|
||||
try {
|
||||
const recipientIds = await teamNotify.recipientIds(team.id)
|
||||
const sent = await tickle(STREAMS.LEADERSHIP_CHANGED, team, { ref: `team:${team.id}` })
|
||||
for (const leaderName of names) {
|
||||
emitTeamEvent(STREAMS.LEADERSHIP_CHANGED, team, { leaderName, teamUrl: teamPagePath(team) }, recipientIds)
|
||||
}
|
||||
await teamBridge.deliver(STREAMS.LEADERSHIP_CHANGED, team, {
|
||||
body: 'Leadership has changed.',
|
||||
teamUrl: teamPageUrl(team),
|
||||
@@ -164,7 +227,7 @@ async function leadershipChanged(team) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Forum events (push + immediate email) ──────────────────────────────────
|
||||
// ── Forum events ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A new thread or reply.
|
||||
@@ -173,9 +236,15 @@ async function leadershipChanged(team) {
|
||||
* 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.
|
||||
* The author is excluded from the tickle and from the emitted audience. 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.
|
||||
*
|
||||
* @returns {{push: number, emitted: boolean, bridged: boolean}} `emitted` says the
|
||||
* event reached the engine, NOT that anybody was mailed. Whether a mail goes out
|
||||
* is a rule's answer and arrives asynchronously through the outbox; a caller
|
||||
* that reported "3 emails sent" from here would be reporting a decision that has
|
||||
* not been taken yet. The old `emails` count is gone for that reason.
|
||||
*/
|
||||
async function forumPost({ team, threadId, threadTitle, type, authorUserId, authorName, bodyHtml }) {
|
||||
try {
|
||||
@@ -183,12 +252,30 @@ async function forumPost({ team, threadId, threadTitle, type, authorUserId, auth
|
||||
// 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 }
|
||||
if (!(await forumSettings.forumsEnabled())) return { push: 0, emitted: false, bridged: false }
|
||||
|
||||
const stream = type === 'announcement' ? STREAMS.ANNOUNCEMENT : STREAMS.FORUM_POST
|
||||
const announcement = type === 'announcement'
|
||||
const stream = announcement ? STREAMS.ANNOUNCEMENT : STREAMS.FORUM_POST
|
||||
const exclude = authorUserId ? [authorUserId] : []
|
||||
const recipientIds = await teamNotify.recipientIds(team.id, { exclude })
|
||||
const push = await tickle(stream, team, { ref: `team:${team.id}:thread:${threadId}`, exclude })
|
||||
const emails = await emailImmediate({ team, threadId, threadTitle, type, exclude, authorName, bodyHtml })
|
||||
|
||||
const emitted = emitTeamEvent(
|
||||
stream,
|
||||
team,
|
||||
{
|
||||
authorName: authorName || 'Someone',
|
||||
// The two triggers name this differently and deliberately: an
|
||||
// announcement has a `title`, a forum post belongs to a `threadTitle`.
|
||||
...(announcement ? { title: threadTitle } : { threadTitle }),
|
||||
excerpt: excerpt(bodyHtml),
|
||||
postUrl: threadPath(team, threadId),
|
||||
},
|
||||
recipientIds,
|
||||
// One post is one event however many times a retry re-runs this path.
|
||||
{ dedupeKey: `team:${team.id}:thread:${threadId}:${type || 'post'}` },
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -199,52 +286,13 @@ async function forumPost({ team, threadId, threadTitle, type, authorUserId, auth
|
||||
url: threadUrl(team, threadId),
|
||||
teamUrl: teamPageUrl(team),
|
||||
})
|
||||
return { push, emails, bridged }
|
||||
return { push, emitted, bridged }
|
||||
} catch (err) {
|
||||
log.warn('forum notification failed', { teamId: team && team.id, message: err.message })
|
||||
return { push: 0, emails: 0, bridged: false }
|
||||
return { push: 0, emitted: false, 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,
|
||||
@@ -253,10 +301,12 @@ module.exports = {
|
||||
// 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.
|
||||
teamPagePath,
|
||||
teamPageUrl,
|
||||
threadPath,
|
||||
threadUrl,
|
||||
unsubscribeUrl,
|
||||
unsubscribeApiUrl,
|
||||
scopeKey,
|
||||
excerpt,
|
||||
teamLabel,
|
||||
emitTeamEvent,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ── One-click unsubscribe tokens (TEAMS.md §6.4) ───────────────────────────
|
||||
// ── One-click unsubscribe tokens (TEAMS.md §6.4; generalized in ENGAGEMENT.md Phase 6) ──
|
||||
//
|
||||
// A stateless HMAC over (userId, teamId, version), not a row in a table.
|
||||
// A stateless HMAC over the thing being unsubscribed from, not a row in a table.
|
||||
//
|
||||
// **Why stateless.** The alternative is a `password_resets`-shaped token table,
|
||||
// and it is the wrong shape for this: an unsubscribe link sits in a mailbox for
|
||||
@@ -9,17 +9,32 @@
|
||||
// table would need pruning for a capability that never expires. Every property
|
||||
// that makes a reset token a row is absent here.
|
||||
//
|
||||
// **What the capability actually is.** Holding a token lets the holder set
|
||||
// `muted = 1` for ONE (user, Team) pair. It cannot read anything, cannot unmute,
|
||||
// cannot touch email mode, and names no other Team. So the honest threat model is:
|
||||
// someone who intercepts the mail can silence one Team's notifications for that
|
||||
// account, visibly and reversibly on the account screen. That is a smaller
|
||||
// capability than the mail itself already carries (it contains the content).
|
||||
// **v1 was `(userId, teamId)`; v2 is `(userId, channel, scopeKey)`, and BOTH
|
||||
// verify — permanently.** Phase 6 generalized the token because the thing being
|
||||
// unsubscribed from is no longer always a Team, but v1 tokens are already in
|
||||
// people's mailboxes and a link that stops working is a person who cannot
|
||||
// unsubscribe. A v1 token reads as `{ channel: 'email', scopeKey: 'team:<id>' }`:
|
||||
// it can only ever have arrived in an email, so naming that channel is a reading
|
||||
// of what it always meant rather than a guess.
|
||||
//
|
||||
// **What the capability actually is.** Holding a token lets the holder turn ONE
|
||||
// channel off for ONE scope for one account. It cannot read anything, cannot turn
|
||||
// anything back on, and names no other scope. So the honest threat model is:
|
||||
// someone who intercepts the mail can silence one Team's email for that account,
|
||||
// visibly and reversibly on the account screen. That is a smaller capability than
|
||||
// the mail itself already carries (it contains the content).
|
||||
//
|
||||
// **The narrowing from v1 is deliberate and is a live behaviour change.** A v1
|
||||
// token set `muted = 1`, which silenced that Team's push as well as its email —
|
||||
// a link labelled "stop these emails" quietly stopped notifications on somebody's
|
||||
// phone. From this phase a token turns off the channel it names and nothing else,
|
||||
// which is both what the link says and what RFC 8058 means by it. Settled by the
|
||||
// org lead 2026-08-29.
|
||||
//
|
||||
// **`v` is the version prefix, and it is what makes rotation possible at all.** A
|
||||
// stateless token cannot be revoked individually; bumping VERSION invalidates
|
||||
// every outstanding link at once, which is the only revocation a design with no
|
||||
// server-side state can offer, and it needs to exist before it is needed.
|
||||
// stateless token cannot be revoked individually; retiring a version invalidates
|
||||
// every outstanding link of it at once, which is the only revocation a design
|
||||
// with no server-side state can offer, and it needs to exist before it is needed.
|
||||
//
|
||||
// The key is SECRET_ENC_KEY, derived through the same dev fallback as
|
||||
// utils/secretBox — a separate label so an unsubscribe token can never be
|
||||
@@ -30,7 +45,20 @@ require('dotenv').config()
|
||||
|
||||
const log = require('./logger')('unsub-token')
|
||||
|
||||
const VERSION = 1
|
||||
// The version this deployment SIGNS with. Both are verified; see the header.
|
||||
const VERSION = 2
|
||||
const LEGACY_VERSION = 1
|
||||
|
||||
// `.` is the field separator, so neither field may contain one. The scope
|
||||
// vocabulary is `<kind>:<id>` (`team:12`) or '' for deployment-wide, and the
|
||||
// channel ids the registry accepts are `[a-z][a-z0-9_.-]*` — which DOES admit a
|
||||
// dot (`discord.dm` is the example §3.1 gives). So the channel is checked against
|
||||
// a dot-free subset here rather than against the registry's own pattern, and a
|
||||
// channel id containing a dot would need a signing format with a real escape
|
||||
// before it could carry an unsubscribe link. Refused loudly rather than signed
|
||||
// into a token that verifies as some other channel.
|
||||
const CHANNEL_RE = /^[a-z][a-z0-9_-]*$/
|
||||
const SCOPE_RE = /^[a-z0-9][a-z0-9:_-]*$/
|
||||
|
||||
function resolveKey() {
|
||||
const explicit = process.env.SECRET_ENC_KEY
|
||||
@@ -57,35 +85,79 @@ const key = () => {
|
||||
// client's own re-wrapping of a long URL without any of the three escaping it.
|
||||
const b64u = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
|
||||
function sign(userId, teamId) {
|
||||
const body = `${VERSION}.${Number(userId)}.${Number(teamId)}`
|
||||
const mac = crypto.createHmac('sha256', key()).update(body).digest()
|
||||
// Truncated to 16 bytes (128 bits). Full-length would double the URL for no
|
||||
// reachable gain: forging this buys one mute, and 128 bits is far past the
|
||||
// point where that is worth anyone's compute.
|
||||
return `${body}.${b64u(mac.subarray(0, 16))}`
|
||||
}
|
||||
// Truncated to 16 bytes (128 bits). Full-length would double the URL for no
|
||||
// reachable gain: forging this buys one unsubscribe, and 128 bits is far past the
|
||||
// point where that is worth anyone's compute.
|
||||
const mac = (body) => b64u(crypto.createHmac('sha256', key()).update(body).digest().subarray(0, 16))
|
||||
|
||||
const legacyBody = (userId, teamId) => `${LEGACY_VERSION}.${Number(userId)}.${Number(teamId)}`
|
||||
|
||||
/**
|
||||
* Verify a token. Returns { userId, teamId } or null — null for every failure
|
||||
* mode, deliberately, so a caller cannot accidentally report which part was wrong.
|
||||
* Sign a v2 token: turn `channel` off for `scopeKey` for this user.
|
||||
*
|
||||
* @param {number} userId
|
||||
* @param {string} channel a registered delivery-channel id, dot-free (see CHANNEL_RE)
|
||||
* @param {string} scopeKey '' for deployment-wide, or `<kind>:<id>` — a stable
|
||||
* IDENTIFIER, never a display name. A Team renamed
|
||||
* between the mail and the click must not orphan the
|
||||
* link in it, which is why this is not `subject_key`.
|
||||
*/
|
||||
function verify(token) {
|
||||
const parts = String(token || '').split('.')
|
||||
if (parts.length !== 4) return null
|
||||
const [v, uid, tid] = parts
|
||||
if (Number(v) !== VERSION) return null
|
||||
const userId = Number(uid)
|
||||
const teamId = Number(tid)
|
||||
if (!Number.isInteger(userId) || !Number.isInteger(teamId)) return null
|
||||
|
||||
const expected = sign(userId, teamId)
|
||||
const a = Buffer.from(expected)
|
||||
const b = Buffer.from(String(token))
|
||||
// Length-check first: timingSafeEqual throws on a length mismatch, and the
|
||||
// length of a token is not a secret.
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
|
||||
return { userId, teamId }
|
||||
function sign(userId, channel, scopeKey = '') {
|
||||
const id = Number(userId)
|
||||
if (!Number.isInteger(id) || id < 1) throw new Error('unsubscribeToken.sign: userId must be a positive integer')
|
||||
if (!CHANNEL_RE.test(String(channel || ''))) {
|
||||
throw new Error(`unsubscribeToken.sign: channel "${channel}" cannot be carried in a token`)
|
||||
}
|
||||
const scope = String(scopeKey || '')
|
||||
if (scope && !SCOPE_RE.test(scope)) {
|
||||
throw new Error(`unsubscribeToken.sign: scope "${scope}" cannot be carried in a token`)
|
||||
}
|
||||
const body = `${VERSION}.${id}.${channel}.${scope}`
|
||||
return `${body}.${mac(body)}`
|
||||
}
|
||||
|
||||
module.exports = { sign, verify, VERSION }
|
||||
/** Sign a v1 token. Kept only so a test can produce one; nothing else calls it. */
|
||||
const signLegacy = (userId, teamId) => `${legacyBody(userId, teamId)}.${mac(legacyBody(userId, teamId))}`
|
||||
|
||||
/**
|
||||
* Verify a token of either version.
|
||||
*
|
||||
* Returns `{ userId, channel, scopeKey, version }` or null — null for every
|
||||
* failure mode, deliberately, so a caller cannot accidentally report which part
|
||||
* was wrong.
|
||||
*/
|
||||
function verify(token) {
|
||||
const raw = String(token || '')
|
||||
const parts = raw.split('.')
|
||||
if (parts.length < 4) return null
|
||||
|
||||
if (Number(parts[0]) === LEGACY_VERSION) {
|
||||
if (parts.length !== 4) return null
|
||||
const userId = Number(parts[1])
|
||||
const teamId = Number(parts[2])
|
||||
if (!Number.isInteger(userId) || !Number.isInteger(teamId)) return null
|
||||
if (!equal(signLegacy(userId, teamId), raw)) return null
|
||||
// A v1 link can only ever have arrived in an email. See the header.
|
||||
return { userId, channel: 'email', scopeKey: `team:${teamId}`, version: LEGACY_VERSION }
|
||||
}
|
||||
|
||||
if (Number(parts[0]) !== VERSION || parts.length !== 5) return null
|
||||
const userId = Number(parts[1])
|
||||
const channel = parts[2]
|
||||
const scopeKey = parts[3]
|
||||
if (!Number.isInteger(userId) || userId < 1) return null
|
||||
if (!CHANNEL_RE.test(channel)) return null
|
||||
if (scopeKey && !SCOPE_RE.test(scopeKey)) return null
|
||||
if (!equal(sign(userId, channel, scopeKey), raw)) return null
|
||||
return { userId, channel, scopeKey, version: VERSION }
|
||||
}
|
||||
|
||||
function equal(expected, actual) {
|
||||
const a = Buffer.from(expected)
|
||||
const b = Buffer.from(actual)
|
||||
// Length-check first: timingSafeEqual throws on a length mismatch, and the
|
||||
// length of a token is not a secret.
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
module.exports = { sign, signLegacy, verify, VERSION, LEGACY_VERSION, CHANNEL_RE, SCOPE_RE }
|
||||
|
||||
Reference in New Issue
Block a user