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