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:
@@ -182,6 +182,29 @@ test('a variable carrying a javascript: url never becomes an href', () => {
|
||||
assert.match(out.html, /Press me/) // inert, but not silently vanished
|
||||
})
|
||||
|
||||
// **A defect until Phase 6, and the phase that put a rule-driven variable in a
|
||||
// button is the one that could see it.** `email.image` and `email.itemList` both
|
||||
// absolutize; `email.button` did not. A trigger's `url` variables are validated
|
||||
// site-RELATIVE by construction (`engagementEmit.RELATIVE_URL`), so every
|
||||
// rule-driven CTA interpolated to `/guilds/x` — a path a mail client has no
|
||||
// origin to resolve, i.e. a dead link in every notification the engine sends.
|
||||
test('a relative url in a button is absolutized, in both parts', () => {
|
||||
const ctx = emailBlocks.buildContext({ values: { link: '/guilds/silver-anvil?thread=7' }, baseUrl: BASE })
|
||||
const block = { id: 'b', type: 'email.button', props: { label: 'Read it', url: '{{link}}' } }
|
||||
const out = emailBlocks.renderBlocks([block], ctx)
|
||||
assert.equal(out.html.includes(`href="${BASE}/guilds/silver-anvil?thread=7"`), true)
|
||||
assert.equal(out.text.includes(`${BASE}/guilds/silver-anvil?thread=7`), true)
|
||||
})
|
||||
|
||||
test('an absolute url in a button is left exactly as it is', () => {
|
||||
const ctx = emailBlocks.buildContext({ values: { link: 'https://elsewhere.test/x' }, baseUrl: BASE })
|
||||
const out = emailBlocks.renderBlocks(
|
||||
[{ id: 'b', type: 'email.button', props: { label: 'Go', url: '{{link}}' } }],
|
||||
ctx,
|
||||
)
|
||||
assert.match(out.html, /href="https:\/\/elsewhere\.test\/x"/)
|
||||
})
|
||||
|
||||
test('a literal unsafe url is refused at save, and a tokened one is allowed through', () => {
|
||||
const bad = emailBlocks.validateEmailBlocks([
|
||||
{ id: 'b', type: 'email.button', props: { label: 'x', url: 'javascript:alert(1)' } },
|
||||
|
||||
Reference in New Issue
Block a user