161 lines
11 KiB
Markdown
161 lines
11 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|