Initial Commit
This commit is contained in:
83
openspec/changes/suppression-completion/proposal.md
Normal file
83
openspec/changes/suppression-completion/proposal.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 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 `Contact`s 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.
|
||||
@@ -0,0 +1,160 @@
|
||||
<!-- 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 -->
|
||||
123
openspec/changes/suppression-completion/tasks.md
Normal file
123
openspec/changes/suppression-completion/tasks.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Tasks — Suppression completion
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## Audit Note (2026-05-02)
|
||||
|
||||
Status: **DEFERRED**. `packages/emails/suppression/src/core/` only contains `exporter` and `importer` — none of `bounce-classifier`, `suppression-filter`, `suppression-manager`, or `unsubscribe-link` exist. Multi-channel `ChannelType` and the `BOUNCE_CLASSIFICATION_FAILED` / `UNSUBSCRIBE_*` error codes are also absent. 0% of work is done.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `../suaveplan/enterprise/suppression.md` end-to-end
|
||||
- [ ] 1.2 Read `openspec/specs/suppression/spec.md` (canonical capability spec; this change introduces it if absent)
|
||||
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 13, 13a, and 14
|
||||
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [ ] 1.5 Fill `## Source mapping` table with upstream line ranges
|
||||
- [ ] 1.6 Draft `specs/suppression/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [ ] 1.7 Every requirement carries a `**Verified by:**` line
|
||||
- [ ] 1.8 `openspec change validate suppression-completion` exits with no errors
|
||||
|
||||
## 2. Implementation — `BounceClassifier`
|
||||
|
||||
- [ ] 2.1 Port `BounceClassifier` from `packages/universal/suppression/src/core/bounce-classifier/bounce-classifier.ts` (L1-L167) into `packages/emails/suppression/src/core/bounce-classifier/bounce-classifier.ts`
|
||||
- [ ] 2.2 Default rule set: RFC 3463 enhanced status `5.x.x` → hard, `4.x.x` → soft (verbatim from L84-L160)
|
||||
- [ ] 2.3 Message-pattern fallbacks for Gmail, Outlook, SendGrid (verbatim from upstream defaults)
|
||||
- [ ] 2.4 Add `addRule(rule)`, `removeRule(name)`, `clearRules()`, `classify({ enhancedStatus, replyCode, message })` methods
|
||||
- [ ] 2.5 Throw `SuppressionError` with code `BOUNCE_CLASSIFICATION_FAILED` if input fails Zod validation
|
||||
- [ ] 2.6 Sibling `.md` (≥200 words, basic + advanced examples)
|
||||
|
||||
## 3. Implementation — `SuppressionFilter`
|
||||
|
||||
- [ ] 3.1 Port `SuppressionFilter` from `packages/universal/suppression/src/core/suppression-filter/suppression-filter.ts` (L1-L126) into `packages/emails/suppression/src/core/suppression-filter/suppression-filter.ts`
|
||||
- [ ] 3.2 Filter API: `filter(contacts: Contact[], channel: ChannelType): Contact[]`
|
||||
- [ ] 3.3 Honour include / exclude / domain-block override semantics from upstream
|
||||
- [ ] 3.4 Throw `SuppressionError` with code `FILTER_INVALID_CHANNEL` if channel is unknown
|
||||
- [ ] 3.5 Sibling `.md`
|
||||
|
||||
## 4. Implementation — `SuppressionManager`
|
||||
|
||||
- [ ] 4.1 Port `SuppressionManager` from `packages/universal/suppression/src/core/suppression-manager/suppression-manager.ts` (L1-L191) into `packages/emails/suppression/src/core/suppression-manager/suppression-manager.ts`
|
||||
- [ ] 4.2 Replace `crypto.randomUUID` (upstream L13) with injectable `IdGenerator` from `@suaveplan/types`
|
||||
- [ ] 4.3 Replace `Date.now()` with injectable `Clock`
|
||||
- [ ] 4.4 Public API: `recordBounce(contact, bounceInfo)`, `recordComplaint(contact, reason)`, `recordUnsubscribe(contact, source)`, `filter(contacts, channel)`, `generateUnsubscribeUrl(contact, baseUrl)`, `verifyUnsubscribeUrl(url)`
|
||||
- [ ] 4.5 Emit events `'bounce:hard'`, `'bounce:soft'`, `'complaint'`, `'unsubscribe'`, `'filter:applied'` (extends `EventEmitter`)
|
||||
- [ ] 4.6 Sibling `.md`
|
||||
|
||||
## 5. Implementation — `UnsubscribeLinkGenerator`
|
||||
|
||||
- [ ] 5.1 Port `UnsubscribeLinkGenerator` from `packages/universal/suppression/src/core/unsubscribe-link/unsubscribe-link.ts` (L1-L99) into `packages/emails/suppression/src/core/unsubscribe-link/unsubscribe-link.ts`
|
||||
- [ ] 5.2 `generateUnsubscribeUrl(contact, baseUrl, secret, options?)` — produce URL `?u=<contactId>&c=<channel>&t=<timestamp>&s=<sig>` with HMAC-SHA256 signature
|
||||
- [ ] 5.3 `verifyUnsubscribeUrl(url, secret, options?)` — return `UnsubscribePayload` on valid signature; throw `SuppressionError` `UNSUBSCRIBE_INVALID_SIGNATURE` / `UNSUBSCRIBE_EXPIRED` / `UNSUBSCRIBE_MISSING_PARAM` on failure
|
||||
- [ ] 5.4 Configurable TTL (default 30 days); use injectable `Clock` for `now()` and TTL comparison
|
||||
- [ ] 5.5 Sibling `.md`
|
||||
|
||||
## 6. Multi-channel support
|
||||
|
||||
- [ ] 6.1 Extend `SuppressionEntry` in `packages/emails/suppression/src/types/suppression.types.ts` with `channel: ChannelType` field
|
||||
- [ ] 6.2 Add `ChannelType = 'email' | 'sms' | 'push'` type
|
||||
- [ ] 6.3 Add `Contact = { id: string, channel: ChannelType, identifier: string }` type
|
||||
- [ ] 6.4 Extend `SuppressionStore` with `findByChannel(channel, identifier)` method
|
||||
- [ ] 6.5 Existing `find(email)` delegates to `findByChannel("email", email)`
|
||||
- [ ] 6.6 JSON / CSV import paths default to `channel: "email"` for entries without an explicit channel field (back-compat)
|
||||
|
||||
## 7. Error codes
|
||||
|
||||
- [ ] 7.1 Extend `SUPPRESSION_CODES` in `packages/emails/suppression/src/errors/suppression-error.ts` with: `BOUNCE_CLASSIFICATION_FAILED`, `UNSUBSCRIBE_INVALID_SIGNATURE`, `UNSUBSCRIBE_EXPIRED`, `UNSUBSCRIBE_MISSING_PARAM`, `FILTER_INVALID_CHANNEL`
|
||||
- [ ] 7.2 Update `errors/suppression-error.md` to document each new code
|
||||
|
||||
## 8. Types and Zod schemas
|
||||
|
||||
- [ ] 8.1 Add `BounceInfo`, `BounceClassification`, `BounceRule`, `UnsubscribePayload`, `SuppressionFilterOptions`, `SuppressionManagerOptions`, `UnsubscribeLinkOptions` to `types/suppression.types.ts`
|
||||
- [ ] 8.2 Add Zod schemas `BounceInfoSchema`, `ContactSchema`, `UnsubscribePayloadSchema` to `schemas/suppression.schema.ts`
|
||||
- [ ] 8.3 Sibling `.md` for the schema module
|
||||
|
||||
## 9. Wiring
|
||||
|
||||
- [ ] 9.1 Update `packages/emails/suppression/src/index.ts` to re-export the four new modules and their types
|
||||
- [ ] 9.2 Add subpath exports `./core/bounce-classifier/bounce-classifier`, `./core/suppression-filter/suppression-filter`, `./core/suppression-manager/suppression-manager`, `./core/unsubscribe-link/unsubscribe-link` to `packages/emails/suppression/package.json`
|
||||
- [ ] 9.3 Update root `README.md` `## Modules` section
|
||||
|
||||
## 10. Tests
|
||||
|
||||
- [ ] 10.1 `packages/emails/suppression/src/core/bounce-classifier/bounce-classifier.test.ts` — `5.1.1` → hard, `4.4.7` → soft, message-pattern fallback for Gmail bounce; custom rule registration; `BOUNCE_CLASSIFICATION_FAILED` on invalid input
|
||||
- [ ] 10.2 `packages/emails/suppression/src/core/suppression-filter/suppression-filter.test.ts` — multi-channel filter (email vs sms vs push); include / exclude lists; domain block; `FILTER_INVALID_CHANNEL` on unknown channel
|
||||
- [ ] 10.3 `packages/emails/suppression/src/core/suppression-manager/suppression-manager.test.ts` — `recordBounce` (hard) emits `bounce:hard` event AND adds to store; `recordComplaint` emits `complaint`; `recordUnsubscribe` emits `unsubscribe`; `filter` emits `filter:applied`; `generateUnsubscribeUrl` round-trips through `verifyUnsubscribeUrl`
|
||||
- [ ] 10.4 `packages/emails/suppression/src/core/suppression-manager/suppression-manager.spec-conformance.test.ts` — injected Clock / IdGenerator deterministic; OTel `suppression.manager.record_total` counter; injected store survives interface contract
|
||||
- [ ] 10.5 `packages/emails/suppression/src/core/unsubscribe-link/unsubscribe-link.test.ts` — generate / verify round-trip; tampered-signature → `UNSUBSCRIBE_INVALID_SIGNATURE`; expired TTL → `UNSUBSCRIBE_EXPIRED`; missing `s` param → `UNSUBSCRIBE_MISSING_PARAM`
|
||||
- [ ] 10.6 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 11. Documentation
|
||||
|
||||
- [ ] 11.1 Sibling `.md` for every new implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [ ] 11.2 README links new `.md`s under `## Modules`
|
||||
- [ ] 11.3 Add `## Migration` section to `README.md` covering the back-compat default `channel: "email"` for legacy import paths
|
||||
|
||||
## 12. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 12.1 `bun run check` — Biome 0/0/0 across the monorepo
|
||||
- [ ] 12.2 `bunx turbo lint typecheck test --filter=@suaveplan/suppression` — green
|
||||
- [ ] 12.3 `bunx turbo test:coverage --filter=@suaveplan/suppression` — 100/100/100/100
|
||||
- [ ] 12.4 `bun scripts/openspec-conformance.ts @suaveplan/suppression` — exit 0, no `error` findings
|
||||
- [ ] 12.5 `bunx turbo build --filter=@suaveplan/suppression` — green
|
||||
|
||||
## 13. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [ ] 13.1 Generics threaded; `expectTypeOf` on `ChannelType` and `BounceClassification` literal unions
|
||||
- [ ] 13.2 Zod schemas wired at every public boundary (`BounceInfoSchema`, `ContactSchema`, `UnsubscribePayloadSchema`, `SuppressionManagerOptionsSchema`)
|
||||
- [ ] 13.3 Every new error code thrown from at least one production path
|
||||
- [ ] 13.4 Strict-mode tested: TTL boundary cases (exactly at expiry, one second before, one second after)
|
||||
- [ ] 13.5 OTel emitted: `suppression.manager.record_total` (tagged by event type), `suppression.filter.applied_total` (tagged by channel), `suppression.classifier.classify_duration_ms`; asserted with a real in-memory `MeterProvider`
|
||||
- [ ] 13.6 Lifecycle dedup: `recordBounce` for the same `(channel, identifier)` is idempotent — second call does NOT add a duplicate store entry; emits an `'idempotent'` debug event instead
|
||||
- [ ] 13.7 No retroactive tick — every box reflects work done in this change
|
||||
|
||||
## 14. Archive
|
||||
|
||||
- [ ] 14.1 Fold spec delta into `openspec/specs/suppression/spec.md` (creating it if absent)
|
||||
- [ ] 14.2 Move `openspec/changes/suppression-completion/` → `openspec/archive/<yyyy-mm-dd>-suppression-completion/`
|
||||
- [ ] 14.3 Refresh `MANIFEST.md`
|
||||
Reference in New Issue
Block a user