feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 11m9s

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:
2026-08-29 20:11:54 -05:00
parent e2dad3104f
commit 065bec7ad8
44 changed files with 2531 additions and 428 deletions

View 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 }