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:
@@ -1891,3 +1891,64 @@ CREATE TABLE IF NOT EXISTS engagement_templates (
|
||||
INDEX idx_engt_trigger (trigger_id, channel, status),
|
||||
INDEX idx_engt_seed (seed_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── The email channel on the engine (ENGAGEMENT.md §4.2b — Phase 6) ─────────
|
||||
|
||||
-- The scope an event is ABOUT, opaque to core and distinct from `subject_key`.
|
||||
--
|
||||
-- They are two different things and Phase 6 is where that stopped being
|
||||
-- theoretical. `subject_key` is what a COOLDOWN is keyed on and comes from the
|
||||
-- trigger's declared `subjectKey` — for the four Team triggers that is `teamName`,
|
||||
-- a display string. `scope_key` is what a PREFERENCE and an UNSUBSCRIBE are keyed
|
||||
-- on, and it has to be a stable identifier: `team:12` survives a rename, and a
|
||||
-- Team renamed between the mail and the click must not orphan the unsubscribe
|
||||
-- link in it. Same vocabulary as engagement_digest_state.scope_key below.
|
||||
ALTER TABLE engagement_outbox ADD COLUMN IF NOT EXISTS scope_key VARCHAR(190) NULL;
|
||||
|
||||
-- §4.2b: digest state, and DELIBERATELY not a digest queue.
|
||||
--
|
||||
-- The generic engine enqueues an outbox row per (rule, user, channel) at emit
|
||||
-- time, carrying a snapshot of the payload. That is right for an instant send and
|
||||
-- wrong for a digest, and `teamDigestWorker`'s header says why in three
|
||||
-- properties: a deployment down for two days sends ONE digest rather than two
|
||||
-- days of replay; a post a moderator hid after it was written is not in the
|
||||
-- query so it is not in the mail; and a user who lost forum access between the
|
||||
-- post and the send is no longer in the recipient set. The third is a security
|
||||
-- property, and all three are properties of RE-DERIVING the content at send time.
|
||||
-- A snapshot taken at emit time has none of them.
|
||||
--
|
||||
-- So a digest-mode recipient gets NO outbox row (see engine.js), and what
|
||||
-- generalizes is this: the state the worker keeps, lifted out of
|
||||
-- team_notification_prefs.last_digest_at so that a second digest — on another
|
||||
-- channel, or over another scope — needs no second column on somebody's
|
||||
-- preferences table.
|
||||
CREATE TABLE IF NOT EXISTS engagement_digest_state (
|
||||
user_id INT NOT NULL,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
-- '' is deployment-wide; 'team:12' is one Team. NOT NULL with a '' default
|
||||
-- because this is a PRIMARY KEY column and MariaDB coerces a nullable one
|
||||
-- anyway — the same workaround team_integration_config and teams.active_key
|
||||
-- both carry, and the trap Part 4's preamble flags.
|
||||
scope_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
last_digest_at DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, channel, scope_key),
|
||||
CONSTRAINT fk_engd_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- The worker's driving question is "whose email digest is due?", which is a
|
||||
-- range scan of this index rather than of every digest ever sent.
|
||||
INDEX idx_engd_due (channel, last_digest_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Carry the Team digest windows across, once. Replay-safe by construction: an
|
||||
-- INSERT IGNORE against the primary key, so the second and every later boot
|
||||
-- writes nothing, and a window the new worker has since MOVED is not dragged
|
||||
-- backwards by the next restart.
|
||||
--
|
||||
-- Rows with a NULL last_digest_at are copied too, and that is deliberate rather
|
||||
-- than incidental: `clampSince` treats a missing row and a NULL stamp the same
|
||||
-- way (reach back one interval, not to the floor), so the copy is faithful — and
|
||||
-- copying only the stamped rows would make the backfill's own idempotence depend
|
||||
-- on which rows happened to have fired.
|
||||
INSERT IGNORE INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at)
|
||||
SELECT user_id, 'email', CONCAT('team:', team_id), last_digest_at
|
||||
FROM team_notification_prefs;
|
||||
|
||||
Reference in New Issue
Block a user