first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
# @suaveplan/suppression - Email suppression list management: in-memory store, bulk import/export (CSV/JSON), domain-level suppression, and OTel instrumentation
## Summary
Email suppression list management for Suaveplan. Stores and checks email addresses that should not receive outgoing mail — unsubscribes, hard/soft bounces, complaints, manual suppressions, and invalid addresses. Supports domain-level blocking, bulk CSV/JSON import and export, OTel instrumentation, and full serialisation round-trips.
<!-- begin:auto-docs -->
## Documentation tree
- [csv-exporter](./src/core/exporter/csv-exporter.md) — `exportToCsv` converts an array of `SuppressionEntry` objects into a properly-formatted CSV string suitable for downloa…
- [json-exporter](./src/core/exporter/json-exporter.md) — `exportToJson` and `importFromJson` provide full-store persistence for `SuppressionStore` instances. They wrap the stor…
- [importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [csv-importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [suppression-store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [suppression-error](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [email-utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [csv-importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_- [store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-error](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [csv-importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_- [store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-error](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [csv-importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_- [store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-error](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [csv-importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_- [store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_ - [suppression-store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [suppression-error](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ - [email-utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_
## Packages
| Module | Description |
|---|---|
| [`src/core/store/suppression-store.ts`](src/core/store/suppression-store.md) | In-memory suppression store with CRUD, domain blocking, stats, and serialisation |
| [`src/core/importer/csv-importer.ts`](src/core/importer/csv-importer.md) | Bulk import from CSV with header-driven column mapping |
| [`src/core/exporter/csv-exporter.ts`](src/core/exporter/csv-exporter.md) | Export entries to RFC 4180-compliant CSV |
| [`src/core/exporter/json-exporter.ts`](src/core/exporter/json-exporter.md) | Full-store JSON serialisation and deserialisation |
| [`src/utils/email-utils.ts`](src/utils/email-utils.md) | Email normalisation, domain extraction, and validation helpers |
| [`src/errors/suppression-error.ts`](src/errors/suppression-error.md) | Typed error class with stable code registry |
## Quick Start
```ts
import { createSuppressionStore } from "@suaveplan/suppression/store";
import { importFromCsv } from "@suaveplan/suppression/importer";
import { exportToCsv, exportToJson } from "@suaveplan/suppression/exporter";
// Create a store
const store = createSuppressionStore({ domainSuppressionEnabled: true });
// Add individual entries
store.add("user@example.com", "unsubscribe", "newsletter-june");
store.addDomain("spam.example", "bounce-hard");
// Check before sending
if (!store.isSuppressed(recipient)) {
await sendEmail(recipient);
}
// Bulk import from ESP export
const entries = importFromCsv(csvString);
store.fromJSON({ entries, domains: [] });
// Persist
const json = exportToJson(store);
await db.set("suppression_snapshot", json);
```
## Installation
```bash
bun add @suaveplan/suppression
```
## Suppression Reasons
| Reason | Description |
|---|---|
| `unsubscribe` | User explicitly opted out |
| `bounce-hard` | Permanent delivery failure |
| `bounce-soft` | Temporary delivery failure |
| `complaint` | Spam complaint recorded |
| `manual` | Manually added by operator |
| `invalid` | Structurally invalid address |
## License
MIT

View File

@@ -0,0 +1,77 @@
# csv-exporter
## Purpose
`exportToCsv` converts an array of `SuppressionEntry` objects into a properly-formatted CSV string suitable for downloading, archiving, or importing into a third-party ESP (Email Service Provider) suppression list. It is the counterpart to `importFromCsv` and shares the same four-column layout: `email`, `reason`, `suppressedAt`, `source`.
The function is intentionally stateless — it operates on plain arrays and does not require a `SuppressionStore` instance, making it easy to use with filtered subsets of entries.
## Features
- Four-column output: `email`, `reason`, `suppressedAt`, `source`
- Always emits a header row for round-trip compatibility with `importFromCsv`
- RFC 4180-compliant field quoting: commas, double-quotes, and newlines inside values are escaped
- Returns only the header row when the input array is empty
- Throws `SuppressionError` (`EXPORT_FAILED`) for entries with missing email values
- LF line endings throughout (compatible with all major ESP import tools)
- No external dependencies
## Usage: Basic Example
```ts
import { exportToCsv } from "@suaveplan/suppression/exporter";
import type { SuppressionEntry } from "@suaveplan/suppression";
const entries: SuppressionEntry[] = [
{ email: "alice@example.com", reason: "unsubscribe", suppressedAt: "2024-01-01T00:00:00Z" },
{ email: "bob@spam.io", reason: "bounce-hard", suppressedAt: "2024-01-02T00:00:00Z", source: "campaign-1" },
];
const csv = exportToCsv(entries);
console.log(csv);
// email,reason,suppressedAt,source
// alice@example.com,unsubscribe,2024-01-01T00:00:00Z,
// bob@spam.io,bounce-hard,2024-01-02T00:00:00Z,campaign-1
```
## Usage: Advanced Example
```ts
import { exportToCsv } from "@suaveplan/suppression/exporter";
import { importFromCsv } from "@suaveplan/suppression/importer";
import { createSuppressionStore } from "@suaveplan/suppression/store";
const store = createSuppressionStore();
store.add("user@example.com", "complaint", "promo-may");
store.add("other@example.com", "manual");
// Export only complaint entries
const complainers = store.getByReason("complaint");
const csv = exportToCsv(complainers);
// Round-trip: re-import the exported CSV
const reimported = importFromCsv(csv);
console.log(reimported[0].email); // user@example.com
```
## API Reference
### `exportToCsv(entries: SuppressionEntry[]): string`
Serialises entries to a CSV string.
**Parameters:**
- `entries: SuppressionEntry[]` — entries to serialise; may be empty
**Returns:** `string` — CSV with header row and LF line endings
**Throws:** `SuppressionError` with code `EXPORT_FAILED` when any entry contains a falsy `email` field.
## Implementation Notes
Field escaping follows RFC 4180: a field is double-quoted if and only if it contains a comma, double-quote, or newline. Double-quotes inside a field are doubled (`"` → `""`). This approach keeps clean fields unquoted, reducing file size for typical suppression exports that contain only plain email addresses.
LF (`\n`) line endings are used (not CRLF) because all modern ESP import tools accept LF, and LF files are smaller and display correctly in Unix-based tooling. If CRLF is required by a downstream system, callers can replace `\n` with `\r\n` on the output string.
The `source` column defaults to an empty string when not present on an entry, so the column count is always consistent across rows.

View File

@@ -0,0 +1,93 @@
# json-exporter
## Purpose
`exportToJson` and `importFromJson` provide full-store persistence for `SuppressionStore` instances. They wrap the store's own `toJSON` / `fromJSON` methods with validation and structured error handling, producing a well-known `SuppressionStoreSnapshot` JSON format that is safe to write to disk, a database JSONB column, or an S3 object.
Together with `exportToCsv` and `importFromCsv`, they form the complete serialisation surface of `@suaveplan/suppression`.
## Features
- `exportToJson(store)` — full serialisation including both email entries and domain blocks
- `importFromJson(json, store)` — replaces store contents; validates top-level structure before delegating
- Structured error wrapping: `EXPORT_FAILED` for serialisation errors, `IMPORT_FAILED` for parse and structural failures
- Tolerates snapshots with missing `domains` array (defaults to empty)
- No external dependencies
## Usage: Basic Example
```ts
import { exportToJson, importFromJson } from "@suaveplan/suppression/exporter";
import { createSuppressionStore } from "@suaveplan/suppression/store";
const store = createSuppressionStore({ domainSuppressionEnabled: true });
store.add("user@example.com", "unsubscribe");
store.addDomain("spam.example", "bounce-hard");
const json = exportToJson(store);
// Later, in a fresh process:
const restored = createSuppressionStore({ domainSuppressionEnabled: true });
importFromJson(json, restored);
console.log(restored.isSuppressed("user@example.com")); // true
console.log(restored.isSuppressed("anything@spam.example")); // true
```
## Usage: Advanced Example
```ts
import { exportToJson, importFromJson } from "@suaveplan/suppression/exporter";
import { createSuppressionStore } from "@suaveplan/suppression/store";
import { SuppressionError, SUPPRESSION_CODES } from "@suaveplan/suppression/errors";
async function loadFromDatabase(db: { get(key: string): Promise<string | null> }) {
const raw = await db.get("suppression_snapshot");
const store = createSuppressionStore();
if (raw) {
try {
importFromJson(raw, store);
} catch (err) {
if (err instanceof SuppressionError && err.code === SUPPRESSION_CODES.IMPORT_FAILED) {
console.warn("Suppression snapshot corrupted, starting fresh", err.context);
} else {
throw err;
}
}
}
return store;
}
```
## API Reference
### `exportToJson(store: SuppressionStore): string`
Serialises the store to a JSON string via `store.toJSON()`.
**Parameters:**
- `store` — any `SuppressionStore` instance
**Returns:** JSON string encoding `{ entries: SuppressionEntry[], domains: string[] }`
**Throws:** `SuppressionError` with code `EXPORT_FAILED` if `JSON.stringify` throws (e.g. circular reference injected by a subclass).
### `importFromJson(json: string, store: SuppressionStore): void`
Parses `json` and replaces the store's contents via `store.fromJSON(snapshot)`.
**Parameters:**
- `json` — JSON string from `exportToJson`
- `store` — target store to populate
**Throws:** `SuppressionError` with code `IMPORT_FAILED` when:
- The string is not valid JSON
- The parsed value is not an object with an `entries` array
## Implementation Notes
`importFromJson` coerces a missing `domains` array to `[]` so that snapshots serialised from older store versions (before domain suppression was added) can be loaded without error. All other structural validation is delegated to `store.fromJSON`, which skips invalid entries rather than throwing, keeping partial imports resilient.
Both functions wrap native `JSON.parse` / `JSON.stringify` in try/catch to ensure that any platform-level error surfaces as a typed `SuppressionError` rather than a raw `SyntaxError`, maintaining consistent error taxonomy across the package.

View File

@@ -0,0 +1,85 @@
# csv-importer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`importFromCsv` is the bulk-import entry point for loading existing suppression lists into a `SuppressionStore`. It parses a UTF-8 CSV string (with a mandatory header row) into an array of `SuppressionEntry` objects that can then be fed to `store.fromJSON` or added individually via `store.add`.
The function implements a minimal but correct CSV parser — it handles double-quoted fields, escaped double-quotes inside fields (`""`), and CRLF/LF line endings without depending on an external CSV library, keeping the package's dependency surface small.
## Features
- Header-row-driven column mapping: `email`, `reason`, `suppressedAt`, `source`
- Column names are case-insensitive (`SuppressedAt`, `suppressedat`, and `SUPPRESSEDAT` all work)
- Missing columns default gracefully (`reason` falls back to `defaultReason`, timestamps default to now)
- Double-quoted fields with embedded commas and `""` escapes supported
- Rows with an empty `email` field are silently skipped
- Throws `SuppressionError` (`IMPORT_FAILED`) for an empty CSV or unrecognised `reason` values
- No external parsing dependencies
## Usage: Basic Example
```ts
import { importFromCsv } from "@suaveplan/suppression/importer";
import { createSuppressionStore } from "@suaveplan/suppression/store";
const csv = `email,reason,suppressedAt
alice@example.com,unsubscribe,2024-01-01T00:00:00Z
bob@spam.io,bounce-hard,2024-01-02T00:00:00Z`;
const entries = importFromCsv(csv);
const store = createSuppressionStore();
store.fromJSON({ entries, domains: [] });
console.log(store.isSuppressed("alice@example.com")); // true
```
## Usage: Advanced Example
```ts
import { importFromCsv } from "@suaveplan/suppression/importer";
// CSV without a reason column — defaultReason applied to every row
const csv = `email,suppressedAt,source
bulk1@example.com,2024-06-01T12:00:00Z,gdpr-cleanup
bulk2@example.com,2024-06-01T12:00:00Z,gdpr-cleanup`;
const entries = importFromCsv(csv, "manual");
console.log(entries[0].reason); // "manual"
console.log(entries[0].source); // "gdpr-cleanup"
// CSV with quoted fields containing commas
const withQuotes = `email,source
"user+tag@example.com","campaign,2024"`;
const q = importFromCsv(withQuotes);
console.log(q[0].email); // user+tag@example.com
console.log(q[0].source); // campaign,2024
```
## API Reference
### `importFromCsv(csv, defaultReason?): SuppressionEntry[]`
Parses a CSV string into suppression entries.
**Parameters:**
- `csv: string` — full CSV text including header row
- `defaultReason?: SuppressionReason` — reason applied to rows with no `reason` column or empty reason value; defaults to `"manual"`
**Returns:** `SuppressionEntry[]` — array of parsed entries; empty rows are excluded
**Throws:** `SuppressionError` with code `IMPORT_FAILED` when:
- The CSV is empty (no non-blank lines)
- The header row contains no `email` column
- A row contains a non-empty `reason` value that is not a valid `SuppressionReason`
## Implementation Notes
The inline CSV parser uses a character-by-character state machine (`inQuote` flag) rather than a regex to handle `""` escapes correctly. This approach avoids edge cases caused by greedy regex quantifiers and is easy to trace under debugging.
CRLF line endings are normalised via `split(/\r?\n/)` before processing. Newlines embedded inside quoted fields are not supported — this is an intentional limitation to keep the implementation simple; production CSV exports from mail platforms do not embed newlines in suppression records.
The function returns plain objects rather than class instances to simplify serialisation and allow easy aggregation with entries produced by other means (e.g. `importFromJson`).

View File

@@ -0,0 +1,136 @@
# suppression-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SuppressionStore` is the core data structure of `@suaveplan/suppression`. It maintains an in-memory Map of suppressed email addresses indexed by their normalised form, plus an optional Set of suppressed domains for domain-wide blocking. The store is designed to be the single source of truth for all suppression decisions, with a straightforward serialisation contract (`toJSON` / `fromJSON`) that enables persistence to any backing store (database, Redis, filesystem).
The class is intentionally free of I/O — it operates entirely in memory, which makes it fast, testable, and portable across server, edge, and serverless runtimes.
## Features
- `add(email, reason, source?)` — validate, normalise, and record a new suppression entry
- `addDomain(domain, reason)` — add an entire domain to the block-list
- `isSuppressed(email)` — O(1) lookup with optional domain-level check
- `get(email)` — retrieve the full `SuppressionEntry` for a given address
- `remove(email)` / `removeDomain(domain)` — remove individual entries
- `getAll()` / `getByReason(reason)` — enumerate entries
- `getStats()` — aggregate counts by reason plus unique domain count
- `clear()` — wipe all entries and domain blocks
- `toJSON()` / `fromJSON(snapshot)` — lossless round-trip serialisation
- `createSuppressionStore(options?)` — factory for DI-friendly construction
- OTel counters: `suppression.add.total{reason}`, `suppression.check.total{result}`
## Usage: Basic Example
```ts
import { createSuppressionStore } from "@suaveplan/suppression/store";
const store = createSuppressionStore();
store.add("user@example.com", "unsubscribe", "newsletter-june");
console.log(store.isSuppressed("user@example.com")); // true
console.log(store.isSuppressed("other@example.com")); // false
```
## Usage: Advanced Example
```ts
import { createSuppressionStore } from "@suaveplan/suppression/store";
const store = createSuppressionStore({ domainSuppressionEnabled: true });
// Suppress a single address
store.add("alice@spam.io", "complaint");
// Suppress entire domain
store.addDomain("malicious.example", "bounce-hard");
console.log(store.isSuppressed("anyone@malicious.example")); // true (domain match)
console.log(store.isSuppressed("alice@spam.io")); // true (entry match)
// Persistence round-trip
const snapshot = store.toJSON();
const store2 = createSuppressionStore({ domainSuppressionEnabled: true });
store2.fromJSON(snapshot);
console.log(store2.isSuppressed("anyone@malicious.example")); // true
// Statistics
const stats = store.getStats();
console.log(stats.total); // 1
console.log(stats.byReason.complaint); // 1
console.log(stats.domains); // 1 (spam.io)
```
## API Reference
### `class SuppressionStore`
**Constructor:** `new SuppressionStore(options?: SuppressionStoreOptions)`
- `options.normalizeEmail` — default `true`; lowercase + trim before storage
- `options.domainSuppressionEnabled` — default `false`; enables domain-level blocking
#### `add(email, reason, source?, metadata?): SuppressionEntry`
Validates, normalises, and records the address. Overwrites any existing entry for the same normalised address. Increments `suppression.add.total{reason}`.
**Throws:** `SuppressionError` (`INVALID_EMAIL`) for structurally invalid addresses.
#### `addDomain(domain, reason): void`
Adds the lower-cased, trimmed domain string to the domain block-list.
#### `isSuppressed(email): boolean`
Returns `true` if the email exists in the store, or (when `domainSuppressionEnabled` is `true`) the extracted domain is in the domain block-list. Increments `suppression.check.total{result}`.
#### `get(email): SuppressionEntry | undefined`
Returns the entry or `undefined`.
#### `remove(email): boolean`
Returns `true` if the entry was deleted.
#### `removeDomain(domain): boolean`
Returns `true` if the domain was in the block-list and was removed.
#### `getAll(): SuppressionEntry[]`
Returns all entries in insertion order.
#### `getByReason(reason): SuppressionEntry[]`
Filters entries by reason.
#### `getStats(): SuppressionStats`
Returns `{ total, byReason, domains }`.
#### `clear(): void`
Clears all entries and domain blocks.
#### `toJSON(): SuppressionStoreSnapshot`
Serialises to `{ entries: SuppressionEntry[], domains: string[] }`.
#### `fromJSON(data): void`
Replaces store contents with snapshot. Skips invalid entries (those failing `isValidEmail`).
### `createSuppressionStore(options?): SuppressionStore`
Factory function. Equivalent to `new SuppressionStore(options)`.
## Implementation Notes
Email addresses are stored under their normalised key (when `normalizeEmail` is `true`) using a `Map<string, SuppressionEntry>`, which provides O(1) average-case lookup and deletion. Domain checks use a `Set<string>` for the same reason.
OTel instrumentation uses the global `metrics.getMeter` singleton so counter instances are shared with the application's configured provider; no provider setup is required in the package itself — instrumentation is a no-op when no provider is registered.
`fromJSON` intentionally replaces rather than merges state to avoid phantom entries after a reload. Callers that need incremental loading should call `add()` directly in a loop.

View File

@@ -0,0 +1,94 @@
# suppression-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SuppressionError` is the typed error class for the entire `@suaveplan/suppression` package. It extends the project-wide `SuaveplanError` base class with a narrow `SuppressionCode` type parameter, enabling callers to discriminate error origins via `instanceof` and switch exhaustively on the `code` property. All four codes cover the realistic failure surface: invalid input, import/export failures, and missing entries.
Centralising error definitions in the frozen `SUPPRESSION_CODES` registry means consumers can handle every failure mode without consulting source code, and the registry value is tree-shakeable — unused codes have zero runtime cost.
## Features
- Inherits the full `SuaveplanError` surface: `code`, `context`, `timestamp`, `traceId`, `cause`
- Four stable error codes covering invalid email, import failure, export failure, and missing entry
- `defineCodes` freezes the registry and infers the exact union type at build time
- Compatible with `walkCauses`, `toSpanEvent`, and the `@suaveplan/error` wire format
- `name` is always `"SuppressionError"` for reliable `instanceof`-free identification in serialised payloads
## Usage: Basic Example
```ts
import { SuppressionError, SUPPRESSION_CODES } from "@suaveplan/suppression/errors";
throw new SuppressionError(
SUPPRESSION_CODES.INVALID_EMAIL,
"suppression.invalid_email",
{ context: { email: "not-valid" } }
);
```
## Usage: Advanced Example
```ts
import { SuppressionError, SUPPRESSION_CODES } from "@suaveplan/suppression/errors";
import { walkCauses } from "@suaveplan/error";
function guardImport(raw: string): void {
try {
JSON.parse(raw);
} catch (cause) {
throw new SuppressionError(
SUPPRESSION_CODES.IMPORT_FAILED,
"suppression.import_failed",
{ cause: cause as Error, context: { bytes: String(raw.length) } }
);
}
}
try {
guardImport("{bad json}");
} catch (err) {
if (err instanceof SuppressionError) {
for (const e of walkCauses(err)) {
console.error(e.message);
}
}
}
```
## API Reference
### `SUPPRESSION_CODES`
`Record<"INVALID_EMAIL" | "IMPORT_FAILED" | "EXPORT_FAILED" | "NOT_FOUND", string>`
Frozen registry of all suppression error codes. Values are fully-qualified strings of the form `SUPPRESSION.<KEY>`.
### `SuppressionCode`
`type SuppressionCode = (typeof SUPPRESSION_CODES)[keyof typeof SUPPRESSION_CODES]`
Union of all valid suppression error code strings.
### `class SuppressionError extends SuaveplanError<SuppressionCode>`
**Constructor:** `new SuppressionError(code: SuppressionCode, messageKey: string, options?: SuaveplanErrorOptions)`
- `code` — one of the values in `SUPPRESSION_CODES`
- `messageKey` — i18n key passed to the `resolveMessage` hook (if configured)
- `options` — optional `{ cause, context, clock, traceId }`
**Inherited properties:** `code`, `context`, `timestamp`, `traceId`, `cause`, `name`, `stack`
## Implementation Notes
The four-code registry was designed to cover all realistic suppression operations without over-engineering:
- `INVALID_EMAIL` guards email normalisation and validation paths
- `IMPORT_FAILED` wraps any CSV or JSON parse error during bulk import
- `EXPORT_FAILED` wraps serialisation errors during export operations
- `NOT_FOUND` is used when a caller requests a specific entry that does not exist
The `defineCodes` helper ensures the object is frozen at module load time, preventing accidental mutation in long-lived processes. The explicit `this.name = "SuppressionError"` assignment makes the class identifiable even after cross-realm transport where `instanceof` may fail.

View File

@@ -0,0 +1,128 @@
# suppression.types
## Purpose
`suppression.types` defines the complete public type surface for `@suaveplan/suppression`. All types are pure TypeScript interfaces and type aliases with zero runtime footprint — they exist solely to provide static guarantees to consumers and to ensure that the store, importers, exporters, and error classes speak the same data language.
Centralising types in a single file avoids the circular-import hazard that arises when implementation modules import types from each other, and makes the package's data contract immediately visible without reading implementation code.
## Features
- `SuppressionReason` — exhaustive union of six suppression classifications
- `SuppressionEntry` — single suppression record with email, reason, ISO timestamp, optional source, and optional metadata
- `SuppressionStats` — aggregate counts by reason plus unique domain count
- `SuppressionStoreOptions` — constructor options controlling email normalisation and domain-suppression behaviour
- `SuppressionStoreSnapshot` — wire format for `toJSON` / `fromJSON` persistence
## Usage: Basic Example
```ts
import type {
SuppressionReason,
SuppressionEntry,
} from "@suaveplan/suppression";
const reason: SuppressionReason = "bounce-hard";
const entry: SuppressionEntry = {
email: "user@example.com",
reason,
suppressedAt: new Date().toISOString(),
source: "campaign-june",
};
```
## Usage: Advanced Example
```ts
import type {
SuppressionStoreOptions,
SuppressionStoreSnapshot,
SuppressionStats,
} from "@suaveplan/suppression";
const options: SuppressionStoreOptions = {
normalizeEmail: true,
domainSuppressionEnabled: true,
};
// Snapshot used for database persistence
const snapshot: SuppressionStoreSnapshot = {
entries: [
{ email: "a@example.com", reason: "unsubscribe", suppressedAt: "2024-01-01T00:00:00Z" },
],
domains: ["spam.example"],
};
// Stats returned by store.getStats()
const stats: SuppressionStats = {
total: 1,
byReason: {
unsubscribe: 1,
"bounce-hard": 0,
"bounce-soft": 0,
complaint: 0,
manual: 0,
invalid: 0,
},
domains: 1,
};
```
## API Reference
### `SuppressionReason`
`type SuppressionReason = "unsubscribe" | "bounce-hard" | "bounce-soft" | "complaint" | "manual" | "invalid"`
Classifies why an address was added to the suppression list:
- `unsubscribe` — explicit opt-out request
- `bounce-hard` — permanent delivery failure (mailbox does not exist)
- `bounce-soft` — transient delivery failure (mailbox temporarily unavailable)
- `complaint` — spam complaint via FBL
- `manual` — operator-initiated suppression
- `invalid` — structurally invalid address rejected at validation time
### `SuppressionEntry`
Interface representing a single suppression record:
| Field | Type | Required | Description |
|---|---|---|---|
| `email` | `string` | yes | Normalised email address |
| `reason` | `SuppressionReason` | yes | Classification |
| `suppressedAt` | `string` | yes | ISO 8601 timestamp |
| `source` | `string` | no | Campaign or list identifier |
| `metadata` | `Record<string, string>` | no | Arbitrary key/value pairs |
### `SuppressionStats`
Aggregate store statistics:
| Field | Type | Description |
|---|---|---|
| `total` | `number` | Total entries in the store |
| `byReason` | `Record<SuppressionReason, number>` | Per-reason counts |
| `domains` | `number` | Unique email-level domain count |
### `SuppressionStoreOptions`
| Field | Type | Default | Description |
|---|---|---|---|
| `normalizeEmail` | `boolean` | `true` | Lowercase + trim before storage |
| `domainSuppressionEnabled` | `boolean` | `false` | Enable domain-level blocking |
### `SuppressionStoreSnapshot`
Wire format for `toJSON` / `fromJSON`:
| Field | Type | Description |
|---|---|---|
| `entries` | `SuppressionEntry[]` | All suppressed email records |
| `domains` | `string[]` | Suppressed domain strings |
## Implementation Notes
All types use TypeScript interfaces rather than classes so that serialisation (JSON round-trips) works naturally without any `instanceof` checks or prototype restoration. The `SuppressionStoreSnapshot` type mirrors the output of `SuppressionStore.toJSON()` exactly, making it the canonical interchange format between in-memory state and persistent storage.
`SuppressionReason` is a string union rather than an enum to ensure that serialised values are human-readable in JSON payloads and that the set can be extended in a non-breaking way in future minor versions.

View File

@@ -0,0 +1,106 @@
# email-utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`email-utils` provides three pure, side-effect-free helper functions used throughout `@suaveplan/suppression` to normalise, decompose, and validate email addresses. They exist as shared utilities so that the store, importer, and exporter all apply identical normalisation semantics — preventing duplicates caused by case differences or leading/trailing spaces.
All functions operate on string values only; no external libraries or Node.js builtins are required, keeping the module universally usable in both server and edge environments.
## Features
- `normalizeEmail` — trim + lowercase canonical form used for all storage and equality checks
- `extractDomain` — isolate the domain segment after the last `@`
- `isValidEmail` — lightweight RFC 5321 structure check (length limits, unique `@`, non-empty parts, dot-containing domain)
- Throws `SuppressionError` with code `SUPPRESSION.INVALID_EMAIL` on malformed input (where applicable)
- Zero external dependencies; safe in edge/browser runtimes
## Usage: Basic Example
```ts
import {
normalizeEmail,
extractDomain,
isValidEmail,
} from "@suaveplan/suppression";
console.log(normalizeEmail(" USER@Example.COM ")); // "user@example.com"
console.log(extractDomain("user@example.com")); // "example.com"
console.log(isValidEmail("user@example.com")); // true
console.log(isValidEmail("not-an-email")); // false
```
## Usage: Advanced Example
```ts
import { normalizeEmail, isValidEmail, extractDomain } from "@suaveplan/suppression";
import { SuppressionError, SUPPRESSION_CODES } from "@suaveplan/suppression/errors";
function safeNormalise(raw: string): string {
if (!isValidEmail(raw.trim())) {
throw new SuppressionError(
SUPPRESSION_CODES.INVALID_EMAIL,
"suppression.invalid_email",
{ context: { raw } }
);
}
return normalizeEmail(raw);
}
// Domain grouping
const emails = ["Alice@Example.com", "bob@example.com", "carol@other.io"];
const byDomain = Map.groupBy(
emails.map(normalizeEmail),
(e) => extractDomain(e)
);
console.log([...byDomain.keys()]); // ["example.com", "other.io"]
```
## API Reference
### `normalizeEmail(email: string): string`
Trims leading/trailing whitespace and converts the entire string to lower-case. Does not validate the result — use `isValidEmail` if validation is needed.
**Parameters:**
- `email` — raw email string
**Returns:** normalised email string
### `extractDomain(email: string): string`
Returns the substring after the last `@` character, converted to lower-case.
**Parameters:**
- `email` — email address (need not be normalised)
**Returns:** domain string (e.g. `"example.com"`)
**Throws:** `SuppressionError` with code `SUPPRESSION.INVALID_EMAIL` when no `@` is found.
### `isValidEmail(email: string): boolean`
Performs a lightweight structural validity check:
1. Must be a string of at most 254 characters
2. Exactly one `@` character, not at position 0
3. Local part: 1–64 characters, no leading/trailing whitespace
4. Domain: non-empty, contains at least one `.`, no leading/trailing dot or hyphen
**Parameters:**
- `email` — string to test
**Returns:** `true` if all checks pass, `false` otherwise. Never throws.
## Implementation Notes
`normalizeEmail` is intentionally minimal — it does not attempt Unicode NFKC normalisation or address parsing (angle-bracket stripping, comment removal). Production email pipelines should apply RFC 5321 full parsing upstream before calling this function.
`extractDomain` uses `lastIndexOf("@")` rather than `indexOf` to correctly handle the rare-but-valid case of multiple `@` characters in quoted local parts, then lowercases the result for consistent domain matching.
`isValidEmail` deliberately avoids regular expressions to keep the logic readable and auditable. The length limits (254 total, 64 local) derive from RFC 5321 §4.5.3. IP-literal domains (`[192.168.1.1]`) and quoted local parts are not supported, matching the practical subset of addresses used in transactional email.