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:
62
server/src/model/engagement/engagementDigest.db.js
Normal file
62
server/src/model/engagement/engagementDigest.db.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// ── engagement_digest_state (ENGAGEMENT.md §4.2b, Phase 6) ─────────────────
|
||||
//
|
||||
// The state a digest keeps, and deliberately the ONLY state a digest keeps. What
|
||||
// goes IN a digest is re-derived from the source tables when the mail is about to
|
||||
// go out; this table answers one question — "what window does this person's next
|
||||
// digest cover?" — and nothing else.
|
||||
//
|
||||
// Lifted out of `team_notification_prefs.last_digest_at`, where it was a worker's
|
||||
// column sitting on a user's preferences row. Keyed (user, channel, scope) so a
|
||||
// second digest — on another channel, or over another scope — needs no second
|
||||
// column on somebody else's table.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* The stamps for a set of users in one scope, as a Map.
|
||||
*
|
||||
* Returns only the rows that exist. Absence is the CALLER's to interpret, and it
|
||||
* matters that it is: `clampSince` treats a missing row and a NULL stamp
|
||||
* identically (reach back one interval, not to the seven-day floor), so a person
|
||||
* who has never had a digest and a person whose row was written by the backfill
|
||||
* get the same first window.
|
||||
*/
|
||||
async function stampsFor(userIds, channel, scopeKey = '') {
|
||||
const ids = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT user_id, last_digest_at FROM engagement_digest_state
|
||||
WHERE channel = ? AND scope_key = ? AND user_id IN (${ids.map(() => '?').join(',')})`,
|
||||
[channel, scopeKey, ...ids],
|
||||
)
|
||||
return new Map(rows.map((r) => [Number(r.user_id), r.last_digest_at]))
|
||||
}
|
||||
|
||||
/** One user's stamp, or undefined. */
|
||||
async function stampFor(userId, channel, scopeKey = '') {
|
||||
const rows = await query(
|
||||
`SELECT last_digest_at FROM engagement_digest_state
|
||||
WHERE user_id = ? AND channel = ? AND scope_key = ?`,
|
||||
[Number(userId), channel, scopeKey],
|
||||
)
|
||||
return rows.length ? rows[0].last_digest_at : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a digest as delivered.
|
||||
*
|
||||
* Written ONLY after a successful send, which is the property the old
|
||||
* `stampDigest` had and the one worth restating: stamping first would silently
|
||||
* eat a day of somebody's notifications every time the mail provider has a bad
|
||||
* minute.
|
||||
*/
|
||||
async function stamp(userId, channel, scopeKey, at) {
|
||||
await query(
|
||||
`INSERT INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE last_digest_at = VALUES(last_digest_at)`,
|
||||
[Number(userId), channel, scopeKey || '', at],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { stampsFor, stampFor, stamp }
|
||||
@@ -22,14 +22,18 @@ const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, {}) }
|
||||
async function enqueue(row) {
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO engagement_outbox
|
||||
(rule_id, trigger_id, user_id, channel, subject_key, payload, dedupe_key, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(rule_id, trigger_id, user_id, channel, subject_key, scope_key, payload, dedupe_key, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.rule_id,
|
||||
row.trigger_id,
|
||||
row.user_id,
|
||||
row.channel,
|
||||
row.subject_key || '',
|
||||
// NULL, not '', for an unscoped event: '' is a scope key that means
|
||||
// "deployment-wide" in engagement_digest_state, and this column has to be
|
||||
// able to say "no scope at all" as well.
|
||||
row.scope_key ?? null,
|
||||
JSON.stringify(row.payload || {}),
|
||||
row.dedupe_key ?? null,
|
||||
row.due_at,
|
||||
|
||||
@@ -122,4 +122,29 @@ const storedModes = async (userIds, streamId, channel) => {
|
||||
return new Map(rows.map((r) => [Number(r.user_id), r.mode]))
|
||||
}
|
||||
|
||||
module.exports = { active, staff, subscribers, filterActive, storedModes, MAX_AUDIENCE }
|
||||
/**
|
||||
* One user's mailable address, or null — the email channel's `addressFor`
|
||||
* (Phase 6).
|
||||
*
|
||||
* `status = 'active'` is re-checked here even though every audience query already
|
||||
* filtered on it, and the gap it closes is real rather than theoretical: an
|
||||
* outbox row can sit through a `delay_seconds` grace window, so a user banned
|
||||
* between the emit and the send is exactly the case this catches. The cost is one
|
||||
* primary-key lookup on a path that is about to open an SMTP conversation.
|
||||
*
|
||||
* **It does not gate on `email_verified`.** Whether an unverified address may
|
||||
* receive opt-in mail is §7.1 Q1's narrower half, and it is a Phase 9 decision
|
||||
* with the suppression list in front of it; deciding it here by accident would
|
||||
* mean every deployment that upgraded before verifying its users stopped mailing
|
||||
* them.
|
||||
*/
|
||||
const addressFor = async (userId) => {
|
||||
const rows = await query(
|
||||
`SELECT email FROM users
|
||||
WHERE id = ? AND status = 'active' AND email IS NOT NULL AND email <> ''`,
|
||||
[Number(userId)],
|
||||
)
|
||||
return rows.length ? { address: rows[0].email } : null
|
||||
}
|
||||
|
||||
module.exports = { active, staff, subscribers, filterActive, storedModes, addressFor, MAX_AUDIENCE }
|
||||
|
||||
@@ -41,4 +41,27 @@ async function offPushExcept(userId, keep) {
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { listByUser, upsert, offPushExcept }
|
||||
/**
|
||||
* Turn one channel off for every named stream — the deployment-wide unsubscribe
|
||||
* (ENGAGEMENT.md Phase 6).
|
||||
*
|
||||
* It WRITES a row per stream rather than updating the rows that happen to exist,
|
||||
* and the difference is the same one `offPushExcept` argues: absence means the
|
||||
* channel's `defaultMode`, so updating only what is there would leave a user
|
||||
* unsubscribed today and re-subscribed the day a channel ships a non-off default.
|
||||
* An unsubscribe has to be a statement, not the absence of one.
|
||||
*/
|
||||
async function offForChannel(userId, channel, streamIds) {
|
||||
const ids = [...new Set(streamIds)].filter((s) => typeof s === 'string' && s)
|
||||
if (!ids.length) return null
|
||||
const values = ids.map(() => '(?, ?, ?, ?)').join(', ')
|
||||
const params = ids.flatMap((streamId) => [userId, streamId, channel, 'off'])
|
||||
return query(
|
||||
`INSERT INTO notification_channel_prefs (user_id, stream_id, channel, mode)
|
||||
VALUES ${values}
|
||||
ON DUPLICATE KEY UPDATE mode = VALUES(mode)`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { listByUser, upsert, offPushExcept, offForChannel }
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user