8e03497eb320f6c74ba8c7c3bb658e44d0fe899d
3 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| c208543044 |
feat(engagement): deliverability — suppression, bounces and the verification gate
ENGAGEMENT.md Phase 9, closing gap G16. Two mechanisms decide that somebody in a
rule's audience does not get the mail, and they sit at deliberately different
points in the pipeline.
`engagement_suppressions` is checked at DELIVERY: an outbox row can sit through a
rule's `delay_seconds` grace window and an address can bounce inside it, so the
only correct check is the one taken immediately before the transport call — which
is also what produces the `status='suppressed'` row with no transport call at all.
The Phase 1b verification gate is applied at ENQUEUE, through a new optional
`registerDeliveryChannel({ eligible })` that only `email` declares. Filtering the
shared audience would have silenced the wrong sink: a rule spanning email and
in-app must still put an item in an unverified user's inbox. The excluded counts
reach `summary.ineligible` and the admin reach preview, which until now reported
an audience size that was never the number of people who would be mailed.
`bounceClassify.js` is the only thing that may write a `bounce` row, and it is
deliberately NOT `mailer.PERMANENT_CODES`. That set answers "is retrying
pointless?" and contains EAUTH and 554 — an auth failure and a relay-wide policy
refusal, neither of which is a fact about the recipient. Reusing it would mean one
stale SMTP password suppressing every address the worker touched, silently. The
classifier reads the RFC 3463 enhanced status first, falls back to a phrase match
only past a veto list and only for 550/551/553, and does not suppress anything it
is unsure about.
Scope is engagement rules only: resets, invites, verification and the contact form
still attempt, matching the posture passwordReset.controller.js already stated.
Found on the live rig, against a real MariaDB and a real SMTP conversation: a hard
bounce was being recorded as `failed`, so the Send Log's "Bounced" filter — a
status `engagement_sends` has carried since §4.5 — matched nothing and always
would have. It is now its own outcome; the outbox row stays `failed`, since that
ENUM has no `bounced` and a bounced row is one that finished unsuccessfully.
`address_masked` is this phase's one addition to §4.5's DDL. A hash-only table
cannot be operated — an operator cannot tell three typos from a whole domain
refusing mail — and the domain survives while the local part is destroyed, so the
column can never be read back as an address book.
- schema: `engagement_suppressions` (+ `address_masked`, `created_by`)
- `GET/POST/DELETE /api/v1/admin/engagement/suppressions`, and Admin → Engagement
→ Suppressions, the only way out of the list
- `sendNotification` returns `smtp: { code, responseCode, response }`
- 26 new tests; swagger, routes manifest and guards regenerated
Docs: RunicGateway/docs#191.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 065bec7ad8 |
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>
|
|||
| b13ffd584f |
feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3)
`notification_subscriptions` answers one question — which streams a user wants
PUSHED — because that is the only question the shipped Android client can ask.
This adds the general one: which subscribable ids, on which channel, in which
mode. The old table becomes the push projection of the new one and keeps its
exact wire shape, so the shipped APK needs no update and no delivery path is
touched.
What lands:
- `engagement/channels.js` — `registerDeliveryChannel` (ENGAGEMENT.md §3.1), the
declarative half only: id, label, `carriesContent`, `defaultMode`,
`supportsDigest`. `addressFor`/`render`/`deliver` wait for Phases 6 and 7, for
the reason `transports/index.js` deferred this file at all. `coreChannels.js`
declares push / email / inapp through the subsystem's one door.
- `notification_channel_prefs` + a replay-safe `INSERT IGNORE … SELECT` backfill,
copying the `announce_jobs → announce_job_legs` precedent.
- `GET · PUT /auth/me/notifications/channels`. The PUT is SPARSE — only the
`(id, channel)` pairs named are written — deliberately unlike the two whole-set
PUTs beside it. `off` is a mode rather than an omission, so this endpoint has
no empty-array case and the kotlinx DTO gotcha cannot arise here.
Three decisions the org lead settled before any code, and one corrects the
phase's own acceptance criterion: push's `defaultMode` is `off`, not `instant`.
The plan borrowed "push is opt-OUT" from `team_notification_prefs`, where no row
does mean notified — but stream subscriptions have never worked that way, so
`instant` would have projected the whole catalog into the legacy GET for every
existing user and switched every toggle on in the shipped app after an upgrade
nobody asked for. A test pins the legacy GET at `{streams:[]}` for a fresh user.
One thing not named by the phase, and it is a G24 consequence rather than scope
creep: a trigger ceilinged at `staff` can never reach a non-staff user, so
offering the toggle would be offering a dead control AND disclosing the event
exists — `uo.cheat.detected` would otherwise appear in every player's screen the
moment Phase 11 declared it. Filtered from the catalog and gated on write. That
gave the `staff` label its first consumer, now written down as
`ceilings.STAFF_CEILING_ROLES` (the admin tier's three, deliberately not
`teamGrants.STAFF_ROLES`, which answers a different question).
15 new tests; swagger, route manifest and guards regenerated. No web or app
surface — those are Phases 7 and 8, where a preference governs something visible.
Refs: docs/website/ENGAGEMENT.md Phase 3, §3.1, §4.5
Co-Authored-By: Claude <noreply@anthropic.com>
|