Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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`