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