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

@@ -211,11 +211,31 @@ async function digestPostsSince(teamId, since, limit = 20) {
)
}
/**
* The preference rows for a set of users in one Team — the scope-preference
* provider's only query (ENGAGEMENT.md Phase 6).
*
* Returns only the rows that EXIST. Absence is answered by the caller, which is
* the same discipline the two recipient queries follow with their COALESCEs: the
* default lives in one place and it is the schema.
*/
async function prefsForTeam(userIds, teamId) {
const ids = userIds.filter(isUserId)
if (!ids.length) return []
return query(
`SELECT user_id, muted, email_mode
FROM team_notification_prefs
WHERE team_id = ? AND user_id IN (${ids.map(() => '?').join(',')})`,
[teamId, ...ids],
)
}
module.exports = {
recipientIds,
emailRecipients,
prefsForUser,
prefFor,
prefsForTeam,
setPref,
stampDigest,
teamsWithForumActivitySince,

View File

@@ -140,6 +140,8 @@ module.exports = {
// time, so the boundary is real.
recipientIds: (teamId, opts) => db.recipientIds(teamId, opts),
emailRecipients: (teamId, opts) => db.emailRecipients(teamId, opts),
prefsForTeam: (userIds, teamId) => db.prefsForTeam(userIds, teamId),
setEmailMode: (userId, teamId, emailMode) => db.setPref(userId, teamId, { emailMode }),
stampDigest: (userId, teamId, at) => db.stampDigest(userId, teamId, at),
teamsWithForumActivitySince: (since) => db.teamsWithForumActivitySince(since),
digestPostsSince: (teamId, since, limit) => db.digestPostsSince(teamId, since, limit),

View File

@@ -217,8 +217,23 @@ async function notifyRoster(team, { joined, promoted, demoted }) {
// The count rides along for the Discord bridge (§7.2), which has no app on
// the other end to pull the roster after a content-free nudge. The tickle
// itself is unchanged and still carries nothing.
if (joined.length > 0) await teamNotify.memberJoined(team, { count: joined.length })
if (promoted.length > 0 || demoted.length > 0) await teamNotify.leadershipChanged(team)
// `names` is the engagement engine's half (ENGAGEMENT.md Phase 6): the two
// triggers declare `memberName` / `leaderName` as required single values, so
// the fan-out emits one event per person while the tickle and the bridge stay
// one per run. A member the module reported without a display name is skipped
// rather than emitted as "someone" — a required variable filled with a
// placeholder is a mail that names nobody.
if (joined.length > 0) {
await teamNotify.memberJoined(team, {
count: joined.length,
names: joined.map((m) => m.display_name).filter(Boolean),
})
}
if (promoted.length > 0 || demoted.length > 0) {
await teamNotify.leadershipChanged(team, {
names: promoted.map((m) => m.display_name).filter(Boolean),
})
}
} catch (err) {
log.warn('roster notification not sent', { teamId: team.id, message: err.message })
}