# 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=&c=&t=&s=`). `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.