11 KiB
Capability Spec Delta: suppression (suppression-completion)
Package: @suaveplan/suppression
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 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, thenclassify({ enhancedStatus: undefined, replyCode: 421, message: "" }) - THEN the result is
'soft'
Scenario: invalid input rejected
- WHEN
classify({} as never)is called - THEN it throws
SuppressionErrorwith codeSUPPRESSION.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 ANDfilter([{ 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 ANDfilter([{ 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.comis in the domain-block list and the contact list containsalice@bouncer.com,bob@example.com - THEN only
bob@example.comis returned
Scenario: unknown channel rejected
- WHEN
filter(contacts, "fax" as never)is called - THEN it throws
SuppressionErrorwith codeSUPPRESSION.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 ANDstore.findByChannel(contact.channel, contact.identifier)returns a populatedSuppressionEntry
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
recordBounceis 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 toverifyUnsubscribeUrl - 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
squery param is mutated, then passed toverifyUnsubscribeUrl - THEN it throws
SuppressionErrorwith codeSUPPRESSION.UNSUBSCRIBE_INVALID_SIGNATURE
Scenario: TTL expiry rejected
- WHEN the URL is generated at
t=0withttlMs: 1000and verified att=1001(via injectedClock) - THEN it throws
SuppressionErrorwith codeSUPPRESSION.UNSUBSCRIBE_EXPIRED
Scenario: missing param rejected
- WHEN
verifyUnsubscribeUrl("https://app.example.com/u?u=alice", secret)is called (onlyu, noc/t/s) - THEN it throws
SuppressionErrorwith codeSUPPRESSION.UNSUBSCRIBE_MISSING_PARAM
Scenario: deterministic timestamp from injected clock
- WHEN a fake
Clockreturning1_700_000_000_000is injected andgenerateUnsubscribeUrlis called - THEN the resulting URL's
tquery 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", ... })andstore.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", ... })andadd({ channel: "sms", identifier: "+15551234567", ... })are both called - THEN
findByChannel("email", "+15551234567")andfindByChannel("sms", "+15551234567")return the respective entries; neither returns the other channel's entry
Scenario: legacy JSON import without channel defaults to email
- WHEN
importFromJsonis called with entries lacking achannelfield - 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"