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

11 KiB

Capability Spec Delta: suppression (suppression-completion)

Package: @suaveplan/suppression Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

ADDED Requirements

Requirement: BounceClassifier classifies SMTP responses

The package SHALL expose a BounceClassifier class with addRule(rule), removeRule(name), clearRules(), and classify({ enhancedStatus, replyCode, message }) methods. The classifier SHALL return one of 'hard' | 'soft' | 'spam-complaint' | 'unknown'. The default rule set SHALL match RFC 3463 enhanced status codes (5.x.x → hard, 4.x.x → soft) plus message-pattern fallbacks for common providers (Gmail, Outlook, SendGrid).

Scenario: 5.1.1 enhanced status classifies as hard

  • WHEN classify({ enhancedStatus: "5.1.1", replyCode: 550, message: "User unknown" }) is called
  • THEN the result is 'hard'

Scenario: 4.4.7 enhanced status classifies as soft

  • WHEN classify({ enhancedStatus: "4.4.7", replyCode: 451, message: "Message timeout" }) is called
  • THEN the result is 'soft'

Scenario: Gmail "spam complaint" message classifies as spam-complaint

  • WHEN classify({ enhancedStatus: undefined, replyCode: 550, message: "550 Mail content denied: spam complaint" }) is called
  • THEN the result is 'spam-complaint' (matched via the Gmail message-pattern fallback)

Scenario: custom rule registered

  • WHEN addRule({ name: "custom-greylist", priority: 100, match: ({ replyCode }) => replyCode === 421, classification: "soft" }) is called, then classify({ enhancedStatus: undefined, replyCode: 421, message: "" })
  • THEN the result is 'soft'

Scenario: invalid input rejected

  • WHEN classify({} as never) is called
  • THEN it throws SuppressionError with code SUPPRESSION.BOUNCE_CLASSIFICATION_FAILED

Verified by: packages/emails/suppression/src/core/bounce-classifier/bounce-classifier.test.ts::"classifies 5.1.1 as hard" AND packages/emails/suppression/src/core/bounce-classifier/bounce-classifier.test.ts::"classifies 4.4.7 as soft" AND packages/emails/suppression/src/core/bounce-classifier/bounce-classifier.test.ts::"matches gmail spam-complaint pattern" AND packages/emails/suppression/src/core/bounce-classifier/bounce-classifier.test.ts::"honours custom rule registration" AND packages/emails/suppression/src/core/bounce-classifier/bounce-classifier.test.ts::"throws BOUNCE_CLASSIFICATION_FAILED on invalid input"


Requirement: SuppressionFilter scopes by channel

The package SHALL expose a SuppressionFilter class with filter(contacts: Contact[], channel: ChannelType): Contact[] that returns the subset of contacts NOT suppressed for the given channel. Suppression entries scoped to one channel SHALL NOT block other channels for the same person. Include lists, exclude lists, and domain-block overrides SHALL be honoured per upstream semantics.

Scenario: email suppression does not block sms

  • WHEN (channel: "email", identifier: "alice@example.com") is suppressed AND filter([{ id, channel: "sms", identifier: "+15551234567" }, { id, channel: "email", identifier: "alice@example.com" }], "sms") is called
  • THEN the SMS contact is kept (not suppressed for SMS)

Scenario: email filter excludes suppressed email

  • WHEN (channel: "email", identifier: "alice@example.com") is suppressed AND filter([{ id, channel: "email", identifier: "alice@example.com" }, { id, channel: "email", identifier: "bob@example.com" }], "email") is called
  • THEN only the bob entry is returned

Scenario: domain block excludes whole domain

  • WHEN bouncer.com is in the domain-block list and the contact list contains alice@bouncer.com, bob@example.com
  • THEN only bob@example.com is returned

Scenario: unknown channel rejected

  • WHEN filter(contacts, "fax" as never) is called
  • THEN it throws SuppressionError with code SUPPRESSION.FILTER_INVALID_CHANNEL

Verified by: packages/emails/suppression/src/core/suppression-filter/suppression-filter.test.ts::"email suppression does not block sms" AND packages/emails/suppression/src/core/suppression-filter/suppression-filter.test.ts::"excludes suppressed emails on email channel" AND packages/emails/suppression/src/core/suppression-filter/suppression-filter.test.ts::"applies domain block" AND packages/emails/suppression/src/core/suppression-filter/suppression-filter.test.ts::"rejects unknown channel as FILTER_INVALID_CHANNEL"


Requirement: SuppressionManager orchestrates store + classifier + filter + unsubscribe

The package SHALL expose a SuppressionManager class extending EventEmitter that orchestrates the full pipeline: store + classifier + filter + unsubscribe-link generator. The manager SHALL expose recordBounce(contact, bounceInfo), recordComplaint(contact, reason), recordUnsubscribe(contact, source), filter(contacts, channel), generateUnsubscribeUrl(contact, baseUrl), verifyUnsubscribeUrl(url). The manager SHALL emit five events: 'bounce:hard', 'bounce:soft', 'complaint', 'unsubscribe', 'filter:applied'.

Scenario: recordBounce hard emits bounce:hard and adds to store

  • WHEN recordBounce(contact, { enhancedStatus: "5.1.1", replyCode: 550, message: "User unknown" }) is called
  • THEN the manager emits 'bounce:hard' once with the classified payload AND store.findByChannel(contact.channel, contact.identifier) returns a populated SuppressionEntry

Scenario: recordBounce soft does NOT add to store

  • WHEN recordBounce(contact, { enhancedStatus: "4.4.7", replyCode: 451, message: "Message timeout" }) is called
  • THEN the manager emits 'bounce:soft' once AND the store does NOT contain the contact (soft bounces are not auto-suppressed)

Scenario: recordBounce is idempotent

  • WHEN recordBounce is called twice with the same (contact, bounceInfo) for a hard bounce
  • THEN the store contains exactly one entry AND the manager emits 'bounce:hard' once on the first call only

Scenario: filter emits filter:applied with metrics

  • WHEN filter(contacts, "email") is called
  • THEN the manager emits 'filter:applied' with { channel: "email", inputCount, outputCount }

Scenario: generateUnsubscribeUrl round-trips through verifyUnsubscribeUrl

  • WHEN a URL produced by generateUnsubscribeUrl(contact, "https://app.example.com/unsubscribe") is passed back to verifyUnsubscribeUrl
  • THEN the result equals the original UnsubscribePayload

Verified by: packages/emails/suppression/src/core/suppression-manager/suppression-manager.test.ts::"hard bounce emits and stores" AND packages/emails/suppression/src/core/suppression-manager/suppression-manager.test.ts::"soft bounce emits but does not store" AND packages/emails/suppression/src/core/suppression-manager/suppression-manager.spec-conformance.test.ts::"recordBounce is idempotent" AND packages/emails/suppression/src/core/suppression-manager/suppression-manager.test.ts::"filter emits filter:applied with metrics" AND packages/emails/suppression/src/core/suppression-manager/suppression-manager.test.ts::"generate and verify unsubscribe URL round-trip"


Requirement: UnsubscribeLinkGenerator produces HMAC-signed one-click URLs

The package SHALL expose an UnsubscribeLinkGenerator class with generateUnsubscribeUrl(contact, baseUrl, secret, options?) and verifyUnsubscribeUrl(url, secret, options?) methods. The URL SHALL carry query parameters ?u=<contactId>&c=<channel>&t=<timestamp>&s=<signature>. The signature SHALL be HMAC-SHA256 of <u>.<c>.<t> using the provided secret. The TTL default SHALL be 30 days, configurable via options.ttlMs.

Scenario: generated URL carries expected params

  • WHEN generateUnsubscribeUrl(contact, "https://app.example.com/u", secret) is called
  • THEN the URL has query params u, c, t, s (all 4 present, in any order)

Scenario: tampered signature rejected

  • WHEN the URL is generated and the s query param is mutated, then passed to verifyUnsubscribeUrl
  • THEN it throws SuppressionError with code SUPPRESSION.UNSUBSCRIBE_INVALID_SIGNATURE

Scenario: TTL expiry rejected

  • WHEN the URL is generated at t=0 with ttlMs: 1000 and verified at t=1001 (via injected Clock)
  • THEN it throws SuppressionError with code SUPPRESSION.UNSUBSCRIBE_EXPIRED

Scenario: missing param rejected

  • WHEN verifyUnsubscribeUrl("https://app.example.com/u?u=alice", secret) is called (only u, no c/t/s)
  • THEN it throws SuppressionError with code SUPPRESSION.UNSUBSCRIBE_MISSING_PARAM

Scenario: deterministic timestamp from injected clock

  • WHEN a fake Clock returning 1_700_000_000_000 is injected and generateUnsubscribeUrl is called
  • THEN the resulting URL's t query param equals "1700000000000"

Verified by: packages/emails/suppression/src/core/unsubscribe-link/unsubscribe-link.test.ts::"generated URL carries u, c, t, s params" AND packages/emails/suppression/src/core/unsubscribe-link/unsubscribe-link.test.ts::"rejects tampered signature as UNSUBSCRIBE_INVALID_SIGNATURE" AND packages/emails/suppression/src/core/unsubscribe-link/unsubscribe-link.test.ts::"rejects expired TTL as UNSUBSCRIBE_EXPIRED" AND packages/emails/suppression/src/core/unsubscribe-link/unsubscribe-link.test.ts::"rejects missing param as UNSUBSCRIBE_MISSING_PARAM" AND packages/emails/suppression/src/core/unsubscribe-link/unsubscribe-link.test.ts::"timestamp comes from injected Clock"


Requirement: Multi-channel suppression via ChannelType

The package SHALL define ChannelType = 'email' | 'sms' | 'push' and a Contact = { id: string; channel: ChannelType; identifier: string } shape. SuppressionEntry SHALL carry channel: ChannelType. SuppressionStore SHALL expose findByChannel(channel, identifier); the existing find(email) method SHALL delegate to findByChannel("email", email).

Scenario: legacy find(email) delegates to findByChannel

  • WHEN an entry is added via add({ channel: "email", identifier: "a@b.com", ... }) and store.find("a@b.com") is called
  • THEN the result equals the entry (identical to store.findByChannel("email", "a@b.com"))

Scenario: same identifier on different channels stored separately

  • WHEN add({ channel: "email", identifier: "+15551234567", ... }) and add({ channel: "sms", identifier: "+15551234567", ... }) are both called
  • THEN findByChannel("email", "+15551234567") and findByChannel("sms", "+15551234567") return the respective entries; neither returns the other channel's entry

Scenario: legacy JSON import without channel defaults to email

  • WHEN importFromJson is called with entries lacking a channel field
  • THEN every imported entry is stamped with channel: "email"

Verified by: packages/emails/suppression/src/core/store/suppression-store.test.ts::"find delegates to findByChannel for email" AND packages/emails/suppression/src/core/store/suppression-store.test.ts::"same identifier on different channels stored separately" AND packages/emails/suppression/src/core/importer/json-importer.test.ts::"legacy entries default to channel: email"