Initial Commit
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# OpenSpec Change: enterprise-suppression
|
||||
|
||||
- **Change ID**: enterprise-suppression
|
||||
- **Package**: `@suaveplan/suppression`
|
||||
- **Category**: universal
|
||||
- **Target path**: `universal/email/suppression`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-04-19
|
||||
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/suppression.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Email suppression list manager — classifies bounces, records unsubscribes, filters outgoing sends, and generates signed one-click unsubscribe links per RFC 8058. Pluggable store.
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
Package does not exist yet — fresh build.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspace: `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`
|
||||
- External (via catalog): `zod`, `tslib`, `@opentelemetry/api`
|
||||
- Dev (via catalog): `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`
|
||||
|
||||
## Preserved public surface (star-exported from main)
|
||||
|
||||
- `BounceClassifier`, `classifyBounce`, `BounceType`
|
||||
- `SuppressionFilter` — filters an outgoing send list against the store.
|
||||
- `SuppressionManager` — CRUD over suppressions with reason tagging.
|
||||
- `UnsubscribeLink` — `generateLink`, `verifyLink`, `rotateSecret`.
|
||||
- `SuppressionStore` interface + `MemorySuppressionStore`.
|
||||
- Types: `SuppressionReason`, `SuppressionEntry`, `BounceReport`, `FilterResult`, `LinkOptions`.
|
||||
|
||||
## New genesis additions
|
||||
|
||||
- Codes: `SUPPRESSION.INVALID_ADDRESS`, `SUPPRESSION.STORE_ERROR`, `SUPPRESSION.LINK_EXPIRED`, `SUPPRESSION.LINK_INVALID_SIGNATURE`.
|
||||
- Zod schemas for every type.
|
||||
- OTel counter `suppression.filter.total{outcome}`, `suppression.suppressed.total{reason}`.
|
||||
- Property tests: every classified hard bounce remains suppressed; round-trip of `generateLink → verifyLink`.
|
||||
|
||||
## Impact
|
||||
|
||||
- Adds one new package: `universal/email/suppression`
|
||||
- Adds one new spec capability: `suppression`
|
||||
@@ -0,0 +1,37 @@
|
||||
# Capability Spec: suppression
|
||||
|
||||
**Package**: `@suaveplan/suppression`
|
||||
**Version**: 0.0.0 (genesis)
|
||||
**Status**: ADDED
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### SUP-01 — Bounce classification
|
||||
|
||||
`classifyBounce(report)` MUST map SMTP enhanced status codes (RFC 3463) to `BounceType` (`'hard' | 'soft' | 'technical' | 'policy' | 'unknown'`). Hard and policy bounces MUST add permanent suppressions; soft bounces MUST increment a soft-bounce counter and auto-suppress after the configured threshold.
|
||||
|
||||
### SUP-02 — Store contract
|
||||
|
||||
`SuppressionStore` MUST provide `add`, `remove`, `has`, `get`, `list`, `purgeExpired`. `MemorySuppressionStore` MUST implement all methods synchronously. Alternative stores (DB, Redis) adhere to the same contract via async variants.
|
||||
|
||||
### SUP-03 — Manager API
|
||||
|
||||
`SuppressionManager` MUST normalise email addresses (lowercase, trim) before storage. Invalid addresses MUST surface as `SUPPRESSION.INVALID_ADDRESS`. Entries MUST carry `{ address, reason: SuppressionReason, bounceType?, createdAt, expiresAt?, source? }`.
|
||||
|
||||
### SUP-04 — Filter contract
|
||||
|
||||
`SuppressionFilter.filter(addresses)` MUST return `FilterResult { allowed, suppressed }`. Addresses MUST be looked up in a single batched pass.
|
||||
|
||||
### SUP-05 — Unsubscribe links
|
||||
|
||||
`UnsubscribeLink.generateLink({ address, list, ttlSeconds })` MUST produce a URL carrying an HMAC-SHA256 signature over `address|list|expiry`. `verifyLink(url, now?)` MUST reject expired or tampered links with `SUPPRESSION.LINK_EXPIRED` / `SUPPRESSION.LINK_INVALID_SIGNATURE`. `rotateSecret(newSecret, gracePeriod?)` MUST accept both old and new signatures during the grace period.
|
||||
|
||||
### SUP-06 — RFC 8058 one-click payload
|
||||
|
||||
The link MUST include the List-Unsubscribe-Post header form (`List-Unsubscribe=One-Click`) and accept a POST that honours the unsubscribe without further user interaction.
|
||||
|
||||
### SUP-07 — Observability
|
||||
|
||||
Every filter call MUST record `suppression.filter.total{outcome}`. Every suppression MUST record `suppression.suppressed.total{reason}`.
|
||||
38
openspec/archive/2026-04-19-enterprise-suppression/tasks.md
Normal file
38
openspec/archive/2026-04-19-enterprise-suppression/tasks.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Tasks: enterprise-suppression
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] 1.1 `universal/email/suppression/`; 100/100/100/100; guard tests; CLAUDE/README/FOLDERS.
|
||||
|
||||
## 2. Errors + schemas
|
||||
|
||||
- [x] 2.1 `src/errors/suppression-error.ts` — `SUPPRESSION.*` codes.
|
||||
- [x] 2.2 Zod schemas.
|
||||
|
||||
## 3. Bounce classifier
|
||||
|
||||
- [x] 3.1 `src/core/bounce-classifier/bounce-classifier.ts` — RFC 3463 status-code mapper → hard/soft/undetermined.
|
||||
|
||||
## 4. Store
|
||||
|
||||
- [x] 4.1 `src/stores/suppression.store.ts` — `SuppressionStore` interface + `MemorySuppressionStore`.
|
||||
|
||||
## 5. Manager + filter
|
||||
|
||||
- [x] 5.1 `src/core/suppression-manager/suppression-manager.ts` — CRUD over suppressions with reasons.
|
||||
- [x] 5.2 `src/core/suppression-filter/suppression-filter.ts` — `filter(addresses) → FilterResult`.
|
||||
- [x] 5.3 OTel counters.
|
||||
|
||||
## 6. Unsubscribe links
|
||||
|
||||
- [x] 6.1 `src/core/unsubscribe-link/unsubscribe-link.ts` — HMAC-SHA256 signed link + TTL; RFC 8058 one-click payload.
|
||||
|
||||
## 7. Docs / barrel / subpath exports / README
|
||||
|
||||
## 8. Benchmarks
|
||||
|
||||
- [x] 8.1 `filter.bench.ts` on 100 k-entry store.
|
||||
|
||||
## 9. Ship gate
|
||||
|
||||
- [x] 9.1 Verification sequence; fold + archive.
|
||||
Reference in New Issue
Block a user