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,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,102 @@
{
"name": "@suaveplan/suppression",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Email suppression list management: in-memory store, bulk import/export (CSV/JSON), domain-level suppression, and OTel instrumentation",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./store": {
"import": {
"types": "./dist/core/store/suppression-store.d.ts",
"default": "./dist/core/store/suppression-store.js"
},
"require": {
"types": "./dist/core/store/suppression-store.d.ts",
"default": "./dist/core/store/suppression-store.js"
}
},
"./importer": {
"import": {
"types": "./dist/core/importer/csv-importer.d.ts",
"default": "./dist/core/importer/csv-importer.js"
},
"require": {
"types": "./dist/core/importer/csv-importer.d.ts",
"default": "./dist/core/importer/csv-importer.js"
}
},
"./exporter": {
"import": {
"types": "./dist/core/exporter/csv-exporter.d.ts",
"default": "./dist/core/exporter/csv-exporter.js"
},
"require": {
"types": "./dist/core/exporter/csv-exporter.d.ts",
"default": "./dist/core/exporter/csv-exporter.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/suppression-error.d.ts",
"default": "./dist/errors/suppression-error.js"
},
"require": {
"types": "./dist/errors/suppression-error.d.ts",
"default": "./dist/errors/suppression-error.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SuppressionError } from "../../errors/suppression-error.js";
import type { SuppressionEntry } from "../../types/suppression.types.js";
import { exportToCsv } from "./csv-exporter.js";
const ENTRIES: SuppressionEntry[] = [
{
email: "alice@example.com",
reason: "unsubscribe",
suppressedAt: "2024-01-01T00:00:00Z",
source: "newsletter",
},
{
email: "bob@spam.io",
reason: "bounce-hard",
suppressedAt: "2024-01-02T00:00:00Z",
},
];
describe("exportToCsv", () => {
it("returns a header row as the first line", { timeout: 5000 }, () => {
const csv = exportToCsv(ENTRIES);
const lines = csv.split("\n");
expect(lines[0]).toBe("email,reason,suppressedAt,source");
});
it("returns one data row per entry", { timeout: 5000 }, () => {
const csv = exportToCsv(ENTRIES);
const lines = csv.split("\n");
expect(lines).toHaveLength(3); // header + 2 entries
});
it("encodes entry values correctly", { timeout: 5000 }, () => {
const csv = exportToCsv(ENTRIES);
const lines = csv.split("\n");
expect(lines[1]).toBe(
"alice@example.com,unsubscribe,2024-01-01T00:00:00Z,newsletter"
);
expect(lines[2]).toBe("bob@spam.io,bounce-hard,2024-01-02T00:00:00Z,");
});
it("returns only the header row for an empty array", {
timeout: 5000,
}, () => {
const csv = exportToCsv([]);
expect(csv).toBe("email,reason,suppressedAt,source");
});
it("quotes fields that contain commas", { timeout: 5000 }, () => {
const entries: SuppressionEntry[] = [
{
email: "user@example.com",
reason: "manual",
suppressedAt: "2024-01-01T00:00:00Z",
source: "hello, world",
},
];
const csv = exportToCsv(entries);
expect(csv).toContain('"hello, world"');
});
it("escapes double-quotes inside quoted fields", { timeout: 5000 }, () => {
const entries: SuppressionEntry[] = [
{
email: "user@example.com",
reason: "manual",
suppressedAt: "2024-01-01T00:00:00Z",
source: 'say "hi"',
},
];
const csv = exportToCsv(entries);
expect(csv).toContain('"say ""hi"""');
});
it("throws EXPORT_FAILED for an entry with no email", {
timeout: 5000,
}, () => {
const entries = [
{
email: "",
reason: "manual" as const,
suppressedAt: "2024-01-01T00:00:00Z",
},
];
expect(() => exportToCsv(entries)).toThrow(SuppressionError);
});
});

View File

@@ -0,0 +1,56 @@
import {
SUPPRESSION_CODES,
SuppressionError,
} from "../../errors/suppression-error.js";
import type { SuppressionEntry } from "../../types/suppression.types.js";
const CSV_HEADER = "email,reason,suppressedAt,source";
/**
* Escape a single CSV field value.
*
* The field is double-quoted when it contains a comma, double-quote, or
* newline character. Existing double-quote characters inside the value are
* escaped by doubling them (`"` → `""`).
*/
function escapeCsvField(value: string): string {
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
/**
* Serialise an array of `SuppressionEntry` objects to a CSV string.
*
* Output columns (always present): `email`, `reason`, `suppressedAt`, `source`
*
* The first row is a header row. When `entries` is empty the function returns
* only the header line. Fields containing commas or double-quote characters
* are double-quoted per RFC 4180.
*
* @param entries - Array of entries to serialise.
* @returns CSV string with LF line endings.
* @throws `SuppressionError` with code `EXPORT_FAILED` if any entry has a
* missing or empty `email` field.
*/
export function exportToCsv(entries: SuppressionEntry[]): string {
const rows: string[] = [CSV_HEADER];
for (const entry of entries) {
if (!entry.email) {
throw new SuppressionError(
SUPPRESSION_CODES["EXPORT_FAILED"],
"suppression.export_failed",
{ context: { reason: "entry has no email field" } }
);
}
const row = [
escapeCsvField(entry.email),
escapeCsvField(entry.reason),
escapeCsvField(entry.suppressedAt),
escapeCsvField(entry.source ?? ""),
].join(",");
rows.push(row);
}
return rows.join("\n");
}

View File

@@ -0,0 +1,2 @@
export { exportToCsv } from "./csv-exporter";
export { exportToJson, importFromJson } from "./json-exporter";

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,111 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SuppressionError } from "../../errors/suppression-error.js";
import { createSuppressionStore } from "../store/suppression-store.js";
import { exportToJson, importFromJson } from "./json-exporter.js";
describe("exportToJson + importFromJson round-trip", () => {
it("round-trips email entries", { timeout: 5000 }, () => {
const store = createSuppressionStore();
store.add("alice@example.com", "unsubscribe", "newsletter");
store.add("bob@example.com", "bounce-hard");
const json = exportToJson(store);
const store2 = createSuppressionStore();
importFromJson(json, store2);
expect(store2.isSuppressed("alice@example.com")).toBe(true);
expect(store2.isSuppressed("bob@example.com")).toBe(true);
expect(store2.get("alice@example.com")?.source).toBe("newsletter");
});
it("round-trips domain suppressions", { timeout: 5000 }, () => {
const store = createSuppressionStore({
domainSuppressionEnabled: true,
});
store.addDomain("spam.io", "complaint");
const json = exportToJson(store);
const store2 = createSuppressionStore({
domainSuppressionEnabled: true,
});
importFromJson(json, store2);
expect(store2.isSuppressed("anyone@spam.io")).toBe(true);
});
it("produces valid JSON", { timeout: 5000 }, () => {
const store = createSuppressionStore();
store.add("user@example.com", "manual");
const json = exportToJson(store);
expect(() => JSON.parse(json)).not.toThrow();
});
it("exported JSON contains entries and domains keys", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
store.add("user@example.com", "manual");
const parsed = JSON.parse(exportToJson(store)) as {
entries: unknown[];
domains: unknown[];
};
expect(Array.isArray(parsed.entries)).toBe(true);
expect(Array.isArray(parsed.domains)).toBe(true);
});
it("importFromJson replaces existing entries", { timeout: 5000 }, () => {
const store = createSuppressionStore();
store.add("old@example.com", "manual");
const emptyJson = JSON.stringify({ entries: [], domains: [] });
importFromJson(emptyJson, store);
expect(store.getAll()).toHaveLength(0);
});
it("importFromJson throws IMPORT_FAILED for invalid JSON string", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
expect(() => importFromJson("{not json}", store)).toThrow(
SuppressionError
);
});
it("importFromJson throws IMPORT_FAILED when entries key is missing", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
expect(() =>
importFromJson(JSON.stringify({ domains: [] }), store)
).toThrow(SuppressionError);
});
it("importFromJson tolerates missing domains key", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
store.add("user@example.com", "manual");
const json = JSON.stringify({
entries: [
{
email: "user@example.com",
reason: "manual",
suppressedAt: new Date().toISOString(),
},
],
});
expect(() => importFromJson(json, store)).not.toThrow();
expect(store.isSuppressed("user@example.com")).toBe(true);
});
it("exportToJson throws EXPORT_FAILED when toJSON raises", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
// Subclass to simulate a toJSON that throws
const badStore = Object.create(store) as SuppressionStore;
(badStore as unknown as { toJSON: () => unknown }).toJSON = () => {
throw new Error("circular");
};
expect(() => exportToJson(badStore)).toThrow(SuppressionError);
});
});

View File

@@ -0,0 +1,77 @@
import {
SUPPRESSION_CODES,
SuppressionError,
} from "../../errors/suppression-error.js";
import type { SuppressionStoreSnapshot } from "../../types/suppression.types.js";
import type { SuppressionStore } from "../store/suppression-store.js";
/**
* Serialise the full state of a `SuppressionStore` to a JSON string.
*
* The resulting string encodes both suppressed email entries and any
* domain-level suppressions, making it suitable for full-store persistence.
* It can be restored with `importFromJson`.
*
* @param store - The store to export.
* @returns A JSON string encoding the store snapshot.
* @throws `SuppressionError` with code `EXPORT_FAILED` if JSON serialisation
* fails for any reason.
*/
export function exportToJson(store: SuppressionStore): string {
try {
return JSON.stringify(store.toJSON());
} catch (cause) {
throw new SuppressionError(
SUPPRESSION_CODES["EXPORT_FAILED"],
"suppression.export_failed",
{
cause: cause as Error,
context: { reason: "JSON serialisation failed" },
}
);
}
}
/**
* Restore a `SuppressionStore` from a JSON string previously produced by
* `exportToJson`.
*
* The store's existing contents are *replaced* by the snapshot. The function
* validates the top-level structure before delegating to `store.fromJSON`.
*
* @param json - JSON string from `exportToJson`.
* @param store - Store instance to populate.
* @throws `SuppressionError` with code `IMPORT_FAILED` when the string is not
* valid JSON or does not contain the expected `entries` array.
*/
export function importFromJson(json: string, store: SuppressionStore): void {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch (cause) {
throw new SuppressionError(
SUPPRESSION_CODES["IMPORT_FAILED"],
"suppression.import_failed",
{ cause: cause as Error, context: { reason: "invalid JSON" } }
);
}
if (
typeof parsed !== "object" ||
parsed === null ||
!Array.isArray((parsed as Record<string, unknown>)["entries"])
) {
throw new SuppressionError(
SUPPRESSION_CODES["IMPORT_FAILED"],
"suppression.import_failed",
{ context: { reason: "snapshot must have an 'entries' array" } }
);
}
const snapshot = parsed as SuppressionStoreSnapshot;
if (!Array.isArray(snapshot.domains)) {
snapshot.domains = [];
}
store.fromJSON(snapshot);
}

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,138 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SuppressionError } from "../../errors/suppression-error.js";
import { importFromCsv } from "./csv-importer.js";
describe("importFromCsv", () => {
it("parses a two-row CSV with all columns", { timeout: 5000 }, () => {
const csv = [
"email,reason,suppressedAt",
"test@example.com,unsubscribe,2024-01-01T00:00:00Z",
"bad@spam.com,bounce-hard,2024-01-02T00:00:00Z",
].join("\n");
const entries = importFromCsv(csv);
expect(entries).toHaveLength(2);
expect(entries[0].email).toBe("test@example.com");
expect(entries[0].reason).toBe("unsubscribe");
expect(entries[0].suppressedAt).toBe("2024-01-01T00:00:00Z");
expect(entries[1].email).toBe("bad@spam.com");
expect(entries[1].reason).toBe("bounce-hard");
});
it("applies defaultReason when reason column is absent", {
timeout: 5000,
}, () => {
const csv = "email,suppressedAt\nuser@example.com,2024-01-01T00:00:00Z";
const entries = importFromCsv(csv, "complaint");
expect(entries[0].reason).toBe("complaint");
});
it("applies defaultReason when reason cell is empty", {
timeout: 5000,
}, () => {
const csv =
"email,reason,suppressedAt\nuser@example.com,,2024-01-01T00:00:00Z";
const entries = importFromCsv(csv, "manual");
expect(entries[0].reason).toBe("manual");
});
it("defaults reason to 'manual' when no defaultReason is supplied", {
timeout: 5000,
}, () => {
const csv = "email\nuser@example.com";
const entries = importFromCsv(csv);
expect(entries[0].reason).toBe("manual");
});
it("parses source column when present", { timeout: 5000 }, () => {
const csv =
"email,reason,suppressedAt,source\nuser@example.com,manual,2024-01-01T00:00:00Z,campaign-1";
const entries = importFromCsv(csv);
expect(entries[0].source).toBe("campaign-1");
});
it("skips rows with empty email", { timeout: 5000 }, () => {
const csv = "email,reason\n,manual\nuser@example.com,manual";
const entries = importFromCsv(csv);
expect(entries).toHaveLength(1);
});
it("handles CRLF line endings", { timeout: 5000 }, () => {
const csv = "email,reason\r\nuser@example.com,manual";
const entries = importFromCsv(csv);
expect(entries).toHaveLength(1);
});
it("handles double-quoted fields with commas inside", {
timeout: 5000,
}, () => {
const csv = `email,source\n"user@example.com","hello, world"`;
const entries = importFromCsv(csv);
expect(entries[0].email).toBe("user@example.com");
expect(entries[0].source).toBe("hello, world");
});
it("handles escaped double-quotes inside quoted fields", {
timeout: 5000,
}, () => {
const csv = `email,source\n"user@example.com","say ""hi"""`;
const entries = importFromCsv(csv);
expect(entries[0].source).toBe('say "hi"');
});
it("throws IMPORT_FAILED for an empty CSV string", {
timeout: 5000,
}, () => {
expect(() => importFromCsv("")).toThrow(SuppressionError);
});
it("throws IMPORT_FAILED for a CSV with no email header", {
timeout: 5000,
}, () => {
const csv = "reason,suppressedAt\nmanual,2024-01-01T00:00:00Z";
expect(() => importFromCsv(csv)).toThrow(SuppressionError);
});
it("throws IMPORT_FAILED for an unrecognised reason value", {
timeout: 5000,
}, () => {
const csv = "email,reason\nuser@example.com,not-a-reason";
expect(() => importFromCsv(csv)).toThrow(SuppressionError);
});
it("falls back suppressedAt to current time when cell is empty", {
timeout: 5000,
}, () => {
const csv = "email,reason,suppressedAt\nuser@example.com,manual,";
const entries = importFromCsv(csv);
expect(entries[0].suppressedAt).toBeTruthy();
expect(() => new Date(entries[0].suppressedAt)).not.toThrow();
});
it("returns empty array for header-only CSV", { timeout: 5000 }, () => {
const csv = "email,reason,suppressedAt";
const entries = importFromCsv(csv);
expect(entries).toHaveLength(0);
});
it("handles rows with fewer columns than header (email still present)", {
timeout: 5000,
}, () => {
// Row has only email, no reason/suppressedAt/source columns
const csv = "email,reason,suppressedAt,source\nuser@example.com";
const entries = importFromCsv(csv);
expect(entries).toHaveLength(1);
expect(entries[0].email).toBe("user@example.com");
expect(entries[0].reason).toBe("manual");
});
it("skips rows where email field is beyond row field count", {
timeout: 5000,
}, () => {
// email is at column index 2, but the data row only has 1 field
const csv = "reason,source,email\nmanual";
// row only has 1 field → fields[2] is undefined → ?? "" → skip
const entries = importFromCsv(csv);
expect(entries).toHaveLength(0);
});
});

View File

@@ -0,0 +1,154 @@
import {
SUPPRESSION_CODES,
SuppressionError,
} from "../../errors/suppression-error.js";
import type {
SuppressionEntry,
SuppressionReason,
} from "../../types/suppression.types.js";
const VALID_REASONS = new Set<SuppressionReason>([
"unsubscribe",
"bounce-hard",
"bounce-soft",
"complaint",
"manual",
"invalid",
]);
/**
* Parse a single CSV line respecting double-quoted fields.
*
* Fields may be optionally double-quoted. A doubled double-quote inside a
* quoted field (`""`) represents a literal `"`. Newlines inside quoted
* fields are not supported.
*/
function parseCsvLine(line: string): string[] {
const fields: string[] = [];
let current = "";
let inQuote = false;
let i = 0;
while (i < line.length) {
const ch = line.charAt(i);
if (inQuote) {
if (ch === '"') {
if (i + 1 < line.length && line.charAt(i + 1) === '"') {
current += '"';
i += 2;
continue;
}
inQuote = false;
} else {
current += ch;
}
} else {
if (ch === '"') {
inQuote = true;
} else if (ch === ",") {
fields.push(current);
current = "";
} else {
current += ch;
}
}
i++;
}
fields.push(current);
return fields;
}
/**
* Parse a CSV string into an array of `SuppressionEntry` objects.
*
* Expected CSV columns (first-row header required):
* - `email` — required
* - `reason` — optional; falls back to `defaultReason`
* - `suppressedAt` — optional; defaults to the current timestamp
* - `source` — optional
*
* Column names are case-insensitive. Extra columns are ignored.
* Rows missing the `email` column value are silently skipped.
*
* @param csv - Full CSV string including header row.
* @param defaultReason - Reason to assign when a row has no `reason` column or
* value. Defaults to `"manual"`.
* @returns Array of parsed entries.
* @throws `SuppressionError` with code `IMPORT_FAILED` when the CSV cannot be
* parsed (e.g. no header row, or a non-empty `reason` value is unrecognised).
*/
export function importFromCsv(
csv: string,
defaultReason: SuppressionReason = "manual"
): SuppressionEntry[] {
const lines = csv.split(/\r?\n/);
const nonEmpty = lines.filter((l) => l.trim() !== "");
if (nonEmpty.length === 0) {
throw new SuppressionError(
SUPPRESSION_CODES["IMPORT_FAILED"],
"suppression.import_failed",
{ context: { reason: "empty CSV" } }
);
}
const headers = parseCsvLine(nonEmpty[0]!).map((h) =>
h.trim().toLowerCase()
);
const emailIdx = headers.indexOf("email");
if (emailIdx < 0) {
throw new SuppressionError(
SUPPRESSION_CODES["IMPORT_FAILED"],
"suppression.import_failed",
{ context: { reason: "missing 'email' header column" } }
);
}
const reasonIdx = headers.indexOf("reason");
const suppressedAtIdx = headers.indexOf("suppressedat");
const sourceIdx = headers.indexOf("source");
const entries: SuppressionEntry[] = [];
const fallbackTime = new Date().toISOString();
for (let i = 1; i < nonEmpty.length; i++) {
const fields = parseCsvLine(nonEmpty[i]!);
const email = (fields[emailIdx] ?? "").trim();
if (!email) continue;
let reason: SuppressionReason = defaultReason;
if (reasonIdx >= 0) {
const rawReason = (fields[reasonIdx] ?? "").trim();
if (rawReason) {
if (!VALID_REASONS.has(rawReason as SuppressionReason)) {
throw new SuppressionError(
SUPPRESSION_CODES["IMPORT_FAILED"],
"suppression.import_failed",
{
context: {
reason: `unknown reason '${rawReason}' at row ${i + 1}`,
},
}
);
}
reason = rawReason as SuppressionReason;
}
}
const suppressedAt =
suppressedAtIdx >= 0
? (fields[suppressedAtIdx] ?? "").trim() || fallbackTime
: fallbackTime;
const source =
sourceIdx >= 0
? (fields[sourceIdx] ?? "").trim() || undefined
: undefined;
entries.push({
email,
reason,
suppressedAt,
...(source ? { source } : {}),
});
}
return entries;
}

View File

@@ -0,0 +1 @@
export { importFromCsv } from "./csv-importer";

View File

@@ -0,0 +1,3 @@
export { exportToCsv, exportToJson, importFromJson } from "./exporter";
export { importFromCsv } from "./importer";
export { createSuppressionStore, SuppressionStore } from "./store";

View File

@@ -0,0 +1 @@
export { createSuppressionStore, SuppressionStore } from "./suppression-store";

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,304 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SuppressionError } from "../../errors/suppression-error.js";
import {
createSuppressionStore,
SuppressionStore,
} from "./suppression-store.js";
describe("SuppressionStore – add + isSuppressed", () => {
it("suppresses an email after adding it", { timeout: 5000 }, () => {
const store = createSuppressionStore();
store.add("user@example.com", "unsubscribe");
expect(store.isSuppressed("user@example.com")).toBe(true);
});
it("returns false for an email that was not added", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
expect(store.isSuppressed("other@example.com")).toBe(false);
});
it("normalises email on add and lookup (case-insensitive)", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
store.add("USER@Example.COM", "manual");
expect(store.isSuppressed("user@example.com")).toBe(true);
expect(store.isSuppressed("USER@EXAMPLE.COM")).toBe(true);
});
it("stores the entry returned by add()", { timeout: 5000 }, () => {
const store = createSuppressionStore();
const entry = store.add(
"alice@example.com",
"bounce-hard",
"campaign-1"
);
expect(entry.email).toBe("alice@example.com");
expect(entry.reason).toBe("bounce-hard");
expect(entry.source).toBe("campaign-1");
expect(entry.suppressedAt).toBeTruthy();
});
it("throws SuppressionError for an invalid email", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
expect(() => store.add("not-an-email", "manual")).toThrow(
SuppressionError
);
});
it("overwrites an existing entry with a new reason", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
store.add("user@example.com", "manual");
store.add("user@example.com", "unsubscribe");
const entry = store.get("user@example.com");
expect(entry?.reason).toBe("unsubscribe");
});
});
describe("SuppressionStore – domain suppression", () => {
it("does not check domains when domainSuppressionEnabled is false (default)", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
store.addDomain("example.com", "bounce-hard");
expect(store.isSuppressed("anyone@example.com")).toBe(false);
});
it("suppresses any address whose domain is in the list when enabled", {
timeout: 5000,
}, () => {
const store = createSuppressionStore({
domainSuppressionEnabled: true,
});
store.addDomain("spam.example", "complaint");
expect(store.isSuppressed("alice@spam.example")).toBe(true);
expect(store.isSuppressed("bob@spam.example")).toBe(true);
});
it("does not suppress addresses on other domains", {
timeout: 5000,
}, () => {
const store = createSuppressionStore({
domainSuppressionEnabled: true,
});
store.addDomain("spam.example", "complaint");
expect(store.isSuppressed("carol@safe.example")).toBe(false);
});
it("removeDomain removes the domain from the block-list", {
timeout: 5000,
}, () => {
const store = createSuppressionStore({
domainSuppressionEnabled: true,
});
store.addDomain("spam.example", "manual");
expect(store.removeDomain("spam.example")).toBe(true);
expect(store.isSuppressed("alice@spam.example")).toBe(false);
});
it("removeDomain returns false when domain was not present", {
timeout: 5000,
}, () => {
const store = createSuppressionStore({
domainSuppressionEnabled: true,
});
expect(store.removeDomain("notpresent.example")).toBe(false);
});
});
describe("SuppressionStore – remove", () => {
it("remove returns true when entry exists and deletes it", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
store.add("user@example.com", "manual");
expect(store.remove("user@example.com")).toBe(true);
expect(store.isSuppressed("user@example.com")).toBe(false);
});
it("remove returns false when entry did not exist", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
expect(store.remove("ghost@example.com")).toBe(false);
});
});
describe("SuppressionStore – get", () => {
it("returns the entry for a suppressed email", { timeout: 5000 }, () => {
const store = createSuppressionStore();
store.add("user@example.com", "complaint", "promo");
const entry = store.get("user@example.com");
expect(entry).toBeDefined();
expect(entry?.reason).toBe("complaint");
expect(entry?.source).toBe("promo");
});
it("returns undefined for an address not in the store", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
expect(store.get("nobody@example.com")).toBeUndefined();
});
});
describe("SuppressionStore – getAll + getByReason", () => {
it("getAll returns all entries in insertion order", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
store.add("a@example.com", "manual");
store.add("b@example.com", "unsubscribe");
const all = store.getAll();
expect(all).toHaveLength(2);
expect(all[0].email).toBe("a@example.com");
expect(all[1].email).toBe("b@example.com");
});
it("getByReason filters correctly", { timeout: 5000 }, () => {
const store = createSuppressionStore();
store.add("a@example.com", "manual");
store.add("b@example.com", "unsubscribe");
store.add("c@example.com", "manual");
const manuals = store.getByReason("manual");
expect(manuals).toHaveLength(2);
expect(manuals.every((e) => e.reason === "manual")).toBe(true);
});
it("getByReason returns empty array when no entries match", {
timeout: 5000,
}, () => {
const store = createSuppressionStore();
store.add("a@example.com", "manual");
expect(store.getByReason("complaint")).toHaveLength(0);
});
});
describe("SuppressionStore – getStats", () => {
it("returns zero stats for an empty store", { timeout: 5000 }, () => {
const store = createSuppressionStore();
const stats = store.getStats();
expect(stats.total).toBe(0);
expect(stats.domains).toBe(0);
});
it("counts entries by reason", { timeout: 5000 }, () => {
const store = createSuppressionStore();
store.add("a@example.com", "manual");
store.add("b@example.com", "unsubscribe");
store.add("c@example.com", "manual");
const stats = store.getStats();
expect(stats.total).toBe(3);
expect(stats.byReason.manual).toBe(2);
expect(stats.byReason.unsubscribe).toBe(1);
expect(stats.byReason["bounce-hard"]).toBe(0);
});
it("counts unique domains from email entries", { timeout: 5000 }, () => {
const store = createSuppressionStore();
store.add("a@example.com", "manual");
store.add("b@example.com", "manual");
store.add("c@other.io", "complaint");
const stats = store.getStats();
expect(stats.domains).toBe(2);
});
});
describe("SuppressionStore – toJSON / fromJSON round-trip", () => {
it("round-trips entries and domains correctly", { timeout: 5000 }, () => {
const store = createSuppressionStore({
domainSuppressionEnabled: true,
});
store.add("alice@example.com", "unsubscribe", "newsletter");
store.addDomain("spam.io", "bounce-hard");
const snapshot = store.toJSON();
expect(snapshot.entries).toHaveLength(1);
expect(snapshot.domains).toContain("spam.io");
const store2 = createSuppressionStore({
domainSuppressionEnabled: true,
});
store2.fromJSON(snapshot);
expect(store2.isSuppressed("alice@example.com")).toBe(true);
expect(store2.isSuppressed("anyone@spam.io")).toBe(true);
});
it("fromJSON replaces existing store state", { timeout: 5000 }, () => {
const store = createSuppressionStore();
store.add("old@example.com", "manual");
store.fromJSON({ entries: [], domains: [] });
expect(store.getAll()).toHaveLength(0);
});
it("fromJSON skips invalid email entries", { timeout: 5000 }, () => {
const store = createSuppressionStore();
store.fromJSON({
entries: [
{
email: "valid@example.com",
reason: "manual",
suppressedAt: new Date().toISOString(),
},
{
email: "invalid-not-email",
reason: "manual",
suppressedAt: new Date().toISOString(),
},
],
domains: [],
});
expect(store.getAll()).toHaveLength(1);
expect(store.get("valid@example.com")).toBeDefined();
});
});
describe("SuppressionStore – clear", () => {
it("clear empties all entries and domains", { timeout: 5000 }, () => {
const store = createSuppressionStore({
domainSuppressionEnabled: true,
});
store.add("a@example.com", "manual");
store.addDomain("spam.io", "complaint");
store.clear();
expect(store.getAll()).toHaveLength(0);
expect(store.getStats().total).toBe(0);
expect(store.isSuppressed("anyone@spam.io")).toBe(false);
});
});
describe("createSuppressionStore", () => {
it("returns a SuppressionStore instance", { timeout: 5000 }, () => {
const store = createSuppressionStore();
expect(store).toBeInstanceOf(SuppressionStore);
});
});
describe("SuppressionStore – normalizeEmail: false", () => {
it("does not normalise emails when disabled", { timeout: 5000 }, () => {
const store = createSuppressionStore({ normalizeEmail: false });
store.add("USER@Example.COM", "manual");
// Without normalisation, the raw email is stored
expect(store.isSuppressed("USER@Example.COM")).toBe(true);
// Normalised lookup misses
expect(store.isSuppressed("user@example.com")).toBe(false);
});
});
describe("SuppressionStore – metadata", () => {
it("stores metadata when provided", { timeout: 5000 }, () => {
const store = createSuppressionStore();
const meta = { campaign: "promo-june", region: "us-east" };
const entry = store.add("user@example.com", "manual", undefined, meta);
expect(entry.metadata).toEqual(meta);
const fetched = store.get("user@example.com");
expect(fetched?.metadata).toEqual(meta);
});
});

View File

@@ -0,0 +1,291 @@
import type { Counter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import {
SUPPRESSION_CODES,
SuppressionError,
} from "../../errors/suppression-error.js";
import type {
SuppressionEntry,
SuppressionReason,
SuppressionStats,
SuppressionStoreOptions,
SuppressionStoreSnapshot,
} from "../../types/suppression.types.js";
import {
extractDomain,
isValidEmail,
normalizeEmail,
} from "../../utils/email-utils.js";
const ALL_REASONS: SuppressionReason[] = [
"unsubscribe",
"bounce-hard",
"bounce-soft",
"complaint",
"manual",
"invalid",
];
function buildInitialByReason(): Record<SuppressionReason, number> {
const map = {} as Record<SuppressionReason, number>;
for (const r of ALL_REASONS) map[r] = 0;
return map;
}
/**
* In-memory suppression list store.
*
* Stores email addresses (and optionally entire domains) that should not
* receive outgoing mail. Entries can be added with a typed reason, queried
* with `isSuppressed`, serialised to JSON for persistence, and rehydrated via
* `fromJSON`.
*
* OTel counters emitted:
* - `suppression.add.total` with attribute `reason`
* - `suppression.check.total` with attribute `result` (`"suppressed"` | `"allowed"`)
*/
export class SuppressionStore {
protected readonly shouldNormalize: boolean;
protected readonly domainEnabled: boolean;
protected readonly entries: Map<string, SuppressionEntry>;
protected readonly suppressedDomains: Set<string>;
private readonly addCounter: Counter;
private readonly checkCounter: Counter;
constructor(options: SuppressionStoreOptions = {}) {
this.shouldNormalize = options.normalizeEmail !== false;
this.domainEnabled = options.domainSuppressionEnabled === true;
this.entries = new Map();
this.suppressedDomains = new Set();
const meter = metrics.getMeter("@suaveplan/suppression");
this.addCounter = meter.createCounter("suppression.add.total", {
description:
"Total number of email addresses added to the suppression list",
});
this.checkCounter = meter.createCounter("suppression.check.total", {
description: "Total number of suppression checks performed",
});
}
/**
* Normalise the email address if enabled.
*/
protected applyNormalization(email: string): string {
return this.shouldNormalize ? normalizeEmail(email) : email;
}
/**
* Add an email address to the suppression list.
*
* @param email - The email address to suppress.
* @param reason - Why the address is being suppressed.
* @param source - Optional campaign/list identifier.
* @returns The created `SuppressionEntry`.
* @throws `SuppressionError` with `INVALID_EMAIL` if the address fails
* basic validation.
*/
add(
email: string,
reason: SuppressionReason,
source?: string,
metadata?: Record<string, string>
): SuppressionEntry {
const normalized = this.applyNormalization(email);
if (!isValidEmail(normalized)) {
throw new SuppressionError(
SUPPRESSION_CODES["INVALID_EMAIL"],
"suppression.invalid_email",
{ context: { email } }
);
}
const entry: SuppressionEntry = {
email: normalized,
reason,
suppressedAt: new Date().toISOString(),
...(source !== undefined ? { source } : {}),
...(metadata !== undefined ? { metadata } : {}),
};
this.entries.set(normalized, entry);
this.addCounter.add(1, { reason });
return entry;
}
/**
* Add an entire domain to the domain-suppression list.
*
* Only meaningful when the store was created with
* `domainSuppressionEnabled: true`.
*
* @param domain - The domain to suppress (e.g. `"spam.example.com"`).
* @param reason - Why the domain is being suppressed (logged but not stored
* in the domain set).
*/
addDomain(domain: string, reason: SuppressionReason): void {
const lower = domain.trim().toLowerCase();
this.suppressedDomains.add(lower);
this.addCounter.add(1, { reason, type: "domain" });
}
/**
* Check whether an email address is currently suppressed.
*
* When `domainSuppressionEnabled` is `true`, addresses whose domain appears
* in the domain-suppression list also return `true`.
*
* @param email - Address to check.
* @returns `true` if the address (or its domain) is suppressed.
*/
isSuppressed(email: string): boolean {
const normalized = this.applyNormalization(email);
if (this.entries.has(normalized)) {
this.checkCounter.add(1, { result: "suppressed" });
return true;
}
if (this.domainEnabled) {
try {
const domain = extractDomain(normalized);
if (this.suppressedDomains.has(domain)) {
this.checkCounter.add(1, { result: "suppressed" });
return true;
}
} catch {
// extractDomain throws for invalid emails — treat as not suppressed
}
}
this.checkCounter.add(1, { result: "allowed" });
return false;
}
/**
* Retrieve the suppression entry for a specific email address.
*
* @param email - Address to look up.
* @returns The `SuppressionEntry`, or `undefined` if not suppressed.
*/
get(email: string): SuppressionEntry | undefined {
const normalized = this.applyNormalization(email);
return this.entries.get(normalized);
}
/**
* Remove an email address from the suppression list.
*
* @param email - Address to remove.
* @returns `true` if the entry was present and removed, `false` otherwise.
*/
remove(email: string): boolean {
const normalized = this.applyNormalization(email);
return this.entries.delete(normalized);
}
/**
* Remove a domain from the domain-suppression list.
*
* @param domain - Domain to remove.
* @returns `true` if the domain was present and removed, `false` otherwise.
*/
removeDomain(domain: string): boolean {
const lower = domain.trim().toLowerCase();
return this.suppressedDomains.delete(lower);
}
/**
* Return all suppression entries as an array.
*
* The order is insertion order (Map semantics).
*/
getAll(): SuppressionEntry[] {
return [...this.entries.values()];
}
/**
* Return all entries that match a specific suppression reason.
*
* @param reason - The reason to filter by.
* @returns Array of matching entries.
*/
getByReason(reason: SuppressionReason): SuppressionEntry[] {
return [...this.entries.values()].filter((e) => e.reason === reason);
}
/**
* Compute aggregate statistics for the store.
*
* `domains` counts unique domain names derived from the *email-level*
* entries (not the domain-suppression list).
*/
getStats(): SuppressionStats {
const byReason = buildInitialByReason();
const uniqueDomains = new Set<string>();
for (const entry of this.entries.values()) {
byReason[entry.reason]++;
try {
uniqueDomains.add(extractDomain(entry.email));
} catch {
// skip unparseable entries
}
}
return {
total: this.entries.size,
byReason,
domains: uniqueDomains.size,
};
}
/**
* Remove all entries and domain suppressions from the store.
*/
clear(): void {
this.entries.clear();
this.suppressedDomains.clear();
}
/**
* Serialise the store to a plain JSON-compatible object.
*
* The result can be persisted to disk or a database and later restored with
* `fromJSON`.
*/
toJSON(): SuppressionStoreSnapshot {
return {
entries: [...this.entries.values()],
domains: [...this.suppressedDomains],
};
}
/**
* Restore the store from a serialised snapshot.
*
* Existing entries and domains are *replaced* by the snapshot contents.
* Invalid entries (failing the email validator) are skipped.
*
* @param data - Snapshot produced by `toJSON`.
*/
fromJSON(data: SuppressionStoreSnapshot): void {
this.entries.clear();
this.suppressedDomains.clear();
for (const entry of data.entries) {
if (isValidEmail(entry.email)) {
this.entries.set(entry.email, entry);
}
}
for (const domain of data.domains) {
this.suppressedDomains.add(domain.trim().toLowerCase());
}
}
}
/**
* Factory function for creating a `SuppressionStore` instance.
*
* Preferred over the constructor when dependency-injection patterns are in use.
*
* @param options - Store configuration options.
* @returns A new `SuppressionStore`.
*/
export function createSuppressionStore(
options?: SuppressionStoreOptions
): SuppressionStore {
return new SuppressionStore(options);
}

View File

@@ -0,0 +1,5 @@
export {
SUPPRESSION_CODES,
type SuppressionCode,
SuppressionError,
} from "./suppression-error";

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,47 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/suppression`.
*
* All codes are prefixed with `SUPPRESSION.` so they sort and filter cleanly
* alongside other `@suaveplan/*` package registries.
*/
export const SUPPRESSION_CODES = defineCodes("SUPPRESSION", [
"INVALID_EMAIL",
"IMPORT_FAILED",
"EXPORT_FAILED",
"NOT_FOUND",
] as const);
export type SuppressionCode =
(typeof SUPPRESSION_CODES)[keyof typeof SUPPRESSION_CODES];
/**
* Typed error class for all `@suaveplan/suppression` failures.
*
* Extends `SuaveplanError` with a strongly-typed `code` drawn from
* `SUPPRESSION_CODES`, enabling `instanceof` discrimination and exhaustive
* code-based handling without inspecting the message string.
*
* @example
* ```ts
* import { SuppressionError, SUPPRESSION_CODES } from "@suaveplan/suppression/errors";
*
* throw new SuppressionError(
* SUPPRESSION_CODES.INVALID_EMAIL,
* "suppression.invalid_email",
* { context: { email: "not-an-email" } }
* );
* ```
*/
export class SuppressionError extends SuaveplanError<SuppressionCode> {
public constructor(
code: SuppressionCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "SuppressionError";
}
}

View File

@@ -0,0 +1,31 @@
// Types
// Exporter
export { exportToCsv } from "./core/exporter/csv-exporter.js";
export { exportToJson, importFromJson } from "./core/exporter/json-exporter.js";
// Importer
export { importFromCsv } from "./core/importer/csv-importer.js";
// Store
export {
createSuppressionStore,
SuppressionStore,
} from "./core/store/suppression-store.js";
export type { SuppressionCode } from "./errors/suppression-error.js";
// Errors
export {
SUPPRESSION_CODES,
SuppressionError,
} from "./errors/suppression-error.js";
export type {
SuppressionEntry,
SuppressionReason,
SuppressionStats,
SuppressionStoreOptions,
SuppressionStoreSnapshot,
} from "./types/suppression.types.js";
// Utils
export {
extractDomain,
isValidEmail,
normalizeEmail,
} from "./utils/email-utils.js";

View File

@@ -0,0 +1,7 @@
export type {
SuppressionEntry,
SuppressionReason,
SuppressionStats,
SuppressionStoreOptions,
SuppressionStoreSnapshot,
} from "./suppression.types";

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,59 @@
/**
* Core types for `@suaveplan/suppression`.
*
* All public types are defined here to keep implementation files focused on
* behaviour. Types are pure data shapes with no runtime footprint.
*/
/** Reason a given email address was added to the suppression list. */
export type SuppressionReason =
| "unsubscribe"
| "bounce-hard"
| "bounce-soft"
| "complaint"
| "manual"
| "invalid";
/** A single suppression list entry. */
export interface SuppressionEntry {
/** The (optionally normalised) email address. */
email: string;
/** Why this address was suppressed. */
reason: SuppressionReason;
/** ISO 8601 timestamp of when suppression was recorded. */
suppressedAt: string;
/** Identifier of the campaign or list that triggered this suppression. */
source?: string;
/** Arbitrary key/value metadata from the triggering system. */
metadata?: Record<string, string>;
}
/** Aggregate statistics about a suppression store. */
export interface SuppressionStats {
/** Total number of suppressed email addresses. */
total: number;
/** Count broken down by suppression reason. */
byReason: Record<SuppressionReason, number>;
/** Number of unique domains suppressed (email-level entries only). */
domains: number;
}
/** Construction options for `SuppressionStore`. */
export interface SuppressionStoreOptions {
/**
* When `true` (the default), email addresses are lowercased and trimmed
* before storage and lookup.
*/
normalizeEmail?: boolean;
/**
* When `true`, calling `isSuppressed()` also matches any address whose
* domain appears in the domain-suppression list. Defaults to `false`.
*/
domainSuppressionEnabled?: boolean;
}
/** Wire format used by `toJSON` / `fromJSON`. */
export interface SuppressionStoreSnapshot {
entries: SuppressionEntry[];
domains: string[];
}

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.

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SuppressionError } from "../errors/suppression-error.js";
import { extractDomain, isValidEmail, normalizeEmail } from "./email-utils.js";
describe("normalizeEmail", () => {
it("lowercases the email address", { timeout: 5000 }, () => {
expect(normalizeEmail("USER@EXAMPLE.COM")).toBe("user@example.com");
});
it("trims leading and trailing whitespace", { timeout: 5000 }, () => {
expect(normalizeEmail(" user@example.com ")).toBe("user@example.com");
});
it("trims and lowercases together", { timeout: 5000 }, () => {
expect(normalizeEmail(" ALICE@Example.COM ")).toBe(
"alice@example.com"
);
});
it("leaves already-normalised email unchanged", { timeout: 5000 }, () => {
expect(normalizeEmail("user@example.com")).toBe("user@example.com");
});
});
describe("extractDomain", () => {
it("extracts domain from a simple email", { timeout: 5000 }, () => {
expect(extractDomain("user@example.com")).toBe("example.com");
});
it("lowercases the extracted domain", { timeout: 5000 }, () => {
expect(extractDomain("user@EXAMPLE.COM")).toBe("example.com");
});
it("handles subdomains", { timeout: 5000 }, () => {
expect(extractDomain("user@mail.subdomain.example.com")).toBe(
"mail.subdomain.example.com"
);
});
it("uses last @ when multiple @ signs appear", { timeout: 5000 }, () => {
// Technically invalid but robust behaviour expected
expect(extractDomain("a@b@example.com")).toBe("example.com");
});
it("throws SuppressionError for a string with no @ sign", {
timeout: 5000,
}, () => {
expect(() => extractDomain("not-an-email")).toThrow(SuppressionError);
});
});
describe("isValidEmail", () => {
it("accepts a standard valid email", { timeout: 5000 }, () => {
expect(isValidEmail("user@example.com")).toBe(true);
});
it("accepts email with plus-tag", { timeout: 5000 }, () => {
expect(isValidEmail("user+tag@example.com")).toBe(true);
});
it("accepts email with subdomain", { timeout: 5000 }, () => {
expect(isValidEmail("alice@mail.example.co.uk")).toBe(true);
});
it("rejects address with no @", { timeout: 5000 }, () => {
expect(isValidEmail("userexample.com")).toBe(false);
});
it("rejects address with @ at position 0", { timeout: 5000 }, () => {
expect(isValidEmail("@example.com")).toBe(false);
});
it("rejects address with multiple @ signs", { timeout: 5000 }, () => {
expect(isValidEmail("a@b@example.com")).toBe(false);
});
it("rejects address with no domain dot", { timeout: 5000 }, () => {
expect(isValidEmail("user@localhost")).toBe(false);
});
it("rejects address with domain starting with dot", {
timeout: 5000,
}, () => {
expect(isValidEmail("user@.example.com")).toBe(false);
});
it("rejects address with domain ending with dot", { timeout: 5000 }, () => {
expect(isValidEmail("user@example.com.")).toBe(false);
});
it("rejects address with domain starting with hyphen", {
timeout: 5000,
}, () => {
expect(isValidEmail("user@-example.com")).toBe(false);
});
it("rejects address exceeding 254 characters total", {
timeout: 5000,
}, () => {
const long = `${"a".repeat(245)}@example.com`;
expect(long.length).toBeGreaterThan(254);
expect(isValidEmail(long)).toBe(false);
});
it("rejects local part exceeding 64 characters", { timeout: 5000 }, () => {
const local = "a".repeat(65);
expect(isValidEmail(`${local}@example.com`)).toBe(false);
});
it("rejects empty string", { timeout: 5000 }, () => {
expect(isValidEmail("")).toBe(false);
});
it("rejects address with whitespace inside local part", {
timeout: 5000,
}, () => {
expect(isValidEmail("us er@example.com")).toBe(false);
});
it("rejects address with whitespace in domain", {
timeout: 5000,
}, () => {
expect(isValidEmail("user@exam ple.com")).toBe(false);
});
it("rejects address with empty domain (user@)", {
timeout: 5000,
}, () => {
expect(isValidEmail("user@")).toBe(false);
});
it("rejects non-string input via type cast", {
timeout: 5000,
}, () => {
expect(isValidEmail(null as unknown as string)).toBe(false);
expect(isValidEmail(42 as unknown as string)).toBe(false);
});
});

View File

@@ -0,0 +1,79 @@
import {
SUPPRESSION_CODES,
SuppressionError,
} from "../errors/suppression-error.js";
/**
* Normalise an email address by trimming surrounding whitespace and converting
* all characters to lower-case.
*
* This is the canonical form used for storage and equality checks throughout
* `@suaveplan/suppression`.
*
* @param email - Raw email string to normalise.
* @returns Trimmed, lower-cased email address.
*/
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
/**
* Extract the domain portion from an email address.
*
* The domain is defined as everything after the last `@` character (there must
* be exactly one). No DNS resolution is performed — this is purely a string
* operation.
*
* @param email - A (possibly not yet normalised) email address.
* @returns The domain string, e.g. `"example.com"`.
* @throws `SuppressionError` with code `INVALID_EMAIL` when the address
* does not contain an `@` separator.
*/
export function extractDomain(email: string): string {
const atIndex = email.lastIndexOf("@");
if (atIndex < 0) {
throw new SuppressionError(
SUPPRESSION_CODES["INVALID_EMAIL"],
"suppression.invalid_email",
{ context: { email } }
);
}
return email.slice(atIndex + 1).toLowerCase();
}
/**
* Perform a basic RFC 5321-compatible validity check on an email address.
*
* The check is intentionally lightweight — it confirms:
* - Exactly one `@` separator
* - Non-empty local part (before `@`)
* - Non-empty domain with at least one `.`
* - No leading or trailing whitespace in either part
* - Maximum local-part length of 64 characters
* - Maximum total length of 254 characters
*
* Full RFC 5321/5322 compliance (quoted strings, IP literals, internationalised
* domains) is out of scope; edge-case addresses that are technically valid may
* be rejected.
*
* @param email - Email string to validate.
* @returns `true` if the address passes all checks, `false` otherwise.
*/
export function isValidEmail(email: string): boolean {
if (typeof email !== "string") return false;
if (email.length > 254) return false;
const atIndex = email.indexOf("@");
if (atIndex < 1) return false;
if (email.indexOf("@", atIndex + 1) !== -1) return false;
const local = email.slice(0, atIndex);
const domain = email.slice(atIndex + 1);
if (local.length === 0 || local.length > 64) return false;
if (domain.length === 0) return false;
if (!domain.includes(".")) return false;
// No whitespace anywhere in either part
if (/\s/.test(local) || /\s/.test(domain)) return false;
// Domain must not start/end with a dot or hyphen
if (domain.startsWith(".") || domain.endsWith(".")) return false;
if (domain.startsWith("-") || domain.endsWith("-")) return false;
return true;
}

View File

@@ -0,0 +1 @@
export { extractDomain, isValidEmail, normalizeEmail } from "./email-utils";

View File

@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("@suaveplan/* runtime deps are exactly error + types", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith("@suaveplan/"))
.sort();
expect(runtimeScoped).toEqual(["@suaveplan/error", "@suaveplan/types"]);
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(peerScoped).toEqual([]);
});
it("runtime deps are exactly the expected set (sorted)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual([
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
]);
});
});

View File

@@ -0,0 +1,41 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = join(__dirname, "..", "src");
const BANNED: ReadonlyArray<RegExp> = [
/\bnode:fs\b/,
/\bnode:path\b/,
/\bnode:url\b/,
/\bprocess\./,
/\bBuffer\b/,
/\bdocument\b/,
/\bwindow\b/,
];
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) yield* walk(full);
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
}
}
describe("no-disallowed", () => {
it("src/ does not reference Node or browser built-ins", {
timeout: 5000,
}, () => {
const hits: string[] = [];
for (const file of walk(SRC)) {
const content = readFileSync(file, "utf8");
for (const pattern of BANNED) {
if (pattern.test(content)) {
hits.push(`${file} matched ${pattern}`);
}
}
}
expect(hits).toEqual([]);
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/suppression",
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
external: [
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});