first commit
This commit is contained in:
79
docs/communication/emails/suppression/README.md
Normal file
79
docs/communication/emails/suppression/README.md
Normal 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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
106
docs/communication/emails/suppression/src/utils/email-utils.md
Normal file
106
docs/communication/emails/suppression/src/utils/email-utils.md
Normal 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.
|
||||
Reference in New Issue
Block a user