# 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=&c=&t=&s=` 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/-suppression-completion/` - [ ] 14.3 Refresh `MANIFEST.md`