9.2 KiB
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:
BounceClassifierclass — given an SMTP code (enhancedStatus,replyCode) and a bounce message body, classifies as'hard' | 'soft' | 'spam-complaint' | 'unknown'. Configurable rule registry withaddRule(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:
SuppressionFilterclass — given a list ofContacts and aChannelType, returns the filtered subset of recipients who are NOT suppressed. Supports include-list / exclude-list / domain-block override semantics. Backed by an injectableSuppressionStoreLike(the existingSuppressionStoresatisfies it). - ADDED:
SuppressionManagerclass extendingEventEmitter— high-level façade combining store + classifier + filter + unsubscribe-link generator. ExposesrecordBounce(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:
UnsubscribeLinkGeneratorclass —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 parsedUnsubscribePayloadon valid signature; throwsSuppressionErrorwith codeUNSUBSCRIBE_INVALID_SIGNATURE/UNSUBSCRIBE_EXPIREDon failure. Configurable TTL (default 30 days). - ADDED: Multi-channel support —
ChannelType = 'email' | 'sms' | 'push';Contactshape{ id: string, channel: ChannelType, identifier: string }(email address / phone number / push token); suppression entries scoped bychannelso suppressing an email address does not block the same person's SMS or push notifications. - ADDED:
Contact,BounceInfo,UnsubscribePayload,BounceClassification,BounceRule,SuppressionFilterOptions,SuppressionManagerOptions,UnsubscribeLinkOptionstypes. - ADDED:
SUPPRESSION_CODESextensions —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
SuppressionEntryextended withchannel: ChannelTypefield (default"email"for back-compat). - MODIFIED: existing
SuppressionStoregains afindByChannel(channel, identifier)method alongside the existingfind(email)(which now defaults tochannel: "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 aBounceClassification. - 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/idempotencyships 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 registrationpackages/emails/suppression/src/core/suppression-filter/suppression-filter.test.ts— multi-channel filter, include/exclude semantics, domain blockspackages/emails/suppression/src/core/suppression-manager/suppression-manager.test.ts— full pipeline (recordBounce → filter), event emission, all five event typespackages/emails/suppression/src/core/suppression-manager/suppression-manager.spec-conformance.test.ts— injected Clock / IdGenerator deterministic, OTel spanspackages/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"→ onlypackages/emails/suppression/package.jsonitself).
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:
SuppressionEntry.channelis now part of the persisted shape. Existing JSON exports loaded back throughimportFromJsonwill be migrated on read: entries withoutchannelare stamped with"email"to preserve back-compat. CSV imports likewise default to"email"when nochannelcolumn is present. Documented in the newcore/suppression-manager/suppression-manager.md.SuppressionStore.find(email)is unchanged in signature but now delegates tofindByChannel("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.channelspan attribute).
Search command for downstream consumers: grep -rln "from \"@suaveplan/suppression\"" packages/. None exist in genesis today, so no downstream patches are required.