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

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: 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 Contacts 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.