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>
65 lines
3.0 KiB
JavaScript
65 lines
3.0 KiB
JavaScript
// ── 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 }
|