Files
2026-05-27 23:29:53 +01:00

9.8 KiB

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 .mds 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