Files
suaveplan/openspec/changes/suppression-completion/proposal.md
2026-05-27 23:29:53 +01:00

84 lines
9.2 KiB
Markdown

# Suppression completion — bounce classifier, filter, manager, unsubscribe links, multi-channel
**Package**: `@suaveplan/suppression`
**Status**: PROPOSED
**Type**: feature + spec-conformance
## Why
`@suaveplan/suppression` (`packages/emails/suppression/`) currently ships only the basic store, JSON / CSV importer, and email-utility helpers. Five upstream surfaces are absent: the bounce classifier (hard / soft / spam-complaint detection from SMTP codes + message patterns), the multi-channel suppression filter, the high-level `SuppressionManager` orchestrator, the HMAC-signed one-click unsubscribe-link generator/verifier, and multi-channel (`email | sms | push`) support. Without them, every consumer (transactional-email service, marketing-email batch sender, SMS notifier) re-implements the bounce-handling pipeline from scratch with inconsistent classification and no unsubscribe-link verification.
All four legacy modules already exist (`packages/universal/suppression/src/core/bounce-classifier/bounce-classifier.ts` 167 L; `core/suppression-filter/suppression-filter.ts` 126 L; `core/suppression-manager/suppression-manager.ts` 191 L; `core/unsubscribe-link/unsubscribe-link.ts` 99 L) and pass the genesis quality bar — porting them closes the parity gap.
## What changes
### `@suaveplan/suppression`
- ADDED: `BounceClassifier` class — given an SMTP code (`enhancedStatus`, `replyCode`) and a bounce message body, classifies as `'hard' | 'soft' | 'spam-complaint' | 'unknown'`. Configurable rule registry with `addRule(rule)`, `removeRule(name)`, `classify({ enhancedStatus, replyCode, message })`. Default ruleset matches RFC 3463 enhanced status codes (`5.x.x` → hard, `4.x.x` → soft) plus message-pattern fallbacks for common providers (Gmail, Outlook, SendGrid).
- ADDED: `SuppressionFilter` class — given a list of `Contact`s and a `ChannelType`, returns the filtered subset of recipients who are NOT suppressed. Supports include-list / exclude-list / domain-block override semantics. Backed by an injectable `SuppressionStoreLike` (the existing `SuppressionStore` satisfies it).
- ADDED: `SuppressionManager` class extending `EventEmitter` — high-level façade combining store + classifier + filter + unsubscribe-link generator. Exposes `recordBounce(contact, bounceInfo)`, `recordComplaint(contact, reason)`, `recordUnsubscribe(contact, source)`, `filter(contacts, channel)`, `generateUnsubscribeUrl(contact, baseUrl)`, `verifyUnsubscribeUrl(url)`. Emits events: `'bounce:hard'`, `'bounce:soft'`, `'complaint'`, `'unsubscribe'`, `'filter:applied'`.
- ADDED: `UnsubscribeLinkGenerator` class — `generateUnsubscribeUrl(contact, baseUrl, secret, options?)` produces a URL with HMAC-signed query params (`?u=<contactId>&c=<channel>&t=<timestamp>&s=<sig>`). `verifyUnsubscribeUrl(url, secret, options?)` returns the parsed `UnsubscribePayload` on valid signature; throws `SuppressionError` with code `UNSUBSCRIBE_INVALID_SIGNATURE` / `UNSUBSCRIBE_EXPIRED` on failure. Configurable TTL (default 30 days).
- ADDED: Multi-channel support — `ChannelType = 'email' | 'sms' | 'push'`; `Contact` shape `{ id: string, channel: ChannelType, identifier: string }` (email address / phone number / push token); suppression entries scoped by `channel` so suppressing an email address does not block the same person's SMS or push notifications.
- ADDED: `Contact`, `BounceInfo`, `UnsubscribePayload`, `BounceClassification`, `BounceRule`, `SuppressionFilterOptions`, `SuppressionManagerOptions`, `UnsubscribeLinkOptions` types.
- ADDED: `SUPPRESSION_CODES` extensions — `BOUNCE_CLASSIFICATION_FAILED`, `UNSUBSCRIBE_INVALID_SIGNATURE`, `UNSUBSCRIBE_EXPIRED`, `UNSUBSCRIBE_MISSING_PARAM`, `FILTER_INVALID_CHANNEL`.
- ADDED: subpath exports `./core/bounce-classifier/bounce-classifier`, `./core/suppression-filter/suppression-filter`, `./core/suppression-manager/suppression-manager`, `./core/unsubscribe-link/unsubscribe-link`.
- MODIFIED: existing `SuppressionEntry` extended with `channel: ChannelType` field (default `"email"` for back-compat).
- MODIFIED: existing `SuppressionStore` gains a `findByChannel(channel, identifier)` method alongside the existing `find(email)` (which now defaults to `channel: "email"`).
## Out of scope
- **List-Unsubscribe RFC 8058 header formatting** — separate small change once the link generator surface is stable. The current change exposes the URL builder; the RFC 8058 mailto / one-click POST header is purely a serialization concern downstream.
- **Per-tenant suppression namespace** — out of scope. A single global suppression list serves the current use cases; multi-tenant isolation requires a store-adapter rework that this change does not undertake.
- **Provider-specific bounce parsers** (Postmark inbound JSON, SendGrid event webhook, SES SNS payload) — out of scope. Those parsers belong in dedicated provider packages; the classifier here accepts the normalised `{ enhancedStatus, replyCode, message }` triple and emits a `BounceClassification`.
- **Replay protection on unsubscribe URLs** — the TTL guard is sufficient for the current threat model. Nonce-store-backed one-time consumption is a separate change once `@suaveplan/idempotency` ships in this surface.
## Source mapping
This change derives from `../suaveplan/enterprise/suppression.md` and the upstream package source.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `BounceClassifier` class with rule registry | `packages/universal/suppression/src/core/bounce-classifier/bounce-classifier.ts` | L1-L167 | Verbatim port; default RFC 3463 rules at L84-L160 |
| `SuppressionFilter` class | `packages/universal/suppression/src/core/suppression-filter/suppression-filter.ts` | L1-L126 | Verbatim port |
| `SuppressionManager` class extending EventEmitter | `packages/universal/suppression/src/core/suppression-manager/suppression-manager.ts` | L1-L191 | Verbatim port; replace `crypto.randomUUID` with injectable `IdGenerator` (line 13) |
| `UnsubscribeLinkGenerator` class | `packages/universal/suppression/src/core/unsubscribe-link/unsubscribe-link.ts` | L1-L99 | Verbatim port; thread injectable `Clock` for TTL |
| `Contact`, `BounceInfo`, `UnsubscribePayload`, `BounceClassification`, `BounceRule` types | `packages/universal/suppression/src/types/suppression.types.ts` | L1-L105 | Verbatim port |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Injectable `Clock` for unsubscribe-URL TTL | AGENTS.md determinism rule. Upstream uses `Date.now()`. |
| Injectable `IdGenerator` in `SuppressionManager` | AGENTS.md §0 rule 5 ("shared types belong in `@suaveplan/types`"). Upstream calls `crypto.randomUUID` directly, breaking deterministic test fixtures. |
| `SUPPRESSION_CODES.UNSUBSCRIBE_*` codes | AGENTS.md §13 row 3. Upstream throws plain `Error`. |
| `channel` scoping on `SuppressionEntry` | Genesis-original. Upstream stores entries by raw email; multi-channel demands a `(channel, identifier)` key. |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta.
- `packages/emails/suppression/src/core/bounce-classifier/bounce-classifier.test.ts` — RFC 3463 status-code classification, message-pattern fallbacks, custom rule registration
- `packages/emails/suppression/src/core/suppression-filter/suppression-filter.test.ts` — multi-channel filter, include/exclude semantics, domain blocks
- `packages/emails/suppression/src/core/suppression-manager/suppression-manager.test.ts` — full pipeline (recordBounce → filter), event emission, all five event types
- `packages/emails/suppression/src/core/suppression-manager/suppression-manager.spec-conformance.test.ts` — injected Clock / IdGenerator deterministic, OTel spans
- `packages/emails/suppression/src/core/unsubscribe-link/unsubscribe-link.test.ts` — generate/verify round-trip, tampered-signature rejection, TTL expiry, missing-param rejection
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/suppression` to enumerate.
- No dependents (verified by `grep -rl "@suaveplan/suppression" /home/ubuntu/web/genesis/packages/ --include="package.json"` → only `packages/emails/suppression/package.json` itself).
The `SuppressionEntry` shape extension and `SuppressionStore.findByChannel` method are additive — existing single-arg `store.find(email)` calls continue to work via the `channel: "email"` default.
## Migration impact
Mostly additive — but two notable behavior-change items per AGENTS.md §13a:
1. **`SuppressionEntry.channel` is now part of the persisted shape.** Existing JSON exports loaded back through `importFromJson` will be migrated on read: entries without `channel` are stamped with `"email"` to preserve back-compat. CSV imports likewise default to `"email"` when no `channel` column is present. Documented in the new `core/suppression-manager/suppression-manager.md`.
2. **`SuppressionStore.find(email)` is unchanged in signature** but now delegates to `findByChannel("email", email)` internally. Behaviour identical for the single-channel happy path; the only difference is a new event in the OTel trace tree (`suppression.find.channel` span attribute).
Search command for downstream consumers: `grep -rln "from \"@suaveplan/suppression\"" packages/`. None exist in genesis today, so no downstream patches are required.