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