Initial Commit
This commit is contained in:
79
packages/communication/emails/suppression/README.md
Normal file
79
packages/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
|
||||
102
packages/communication/emails/suppression/package.json
Normal file
102
packages/communication/emails/suppression/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { exportToCsv } from "./csv-exporter";
|
||||
export { exportToJson, importFromJson } from "./json-exporter";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { importFromCsv } from "./csv-importer";
|
||||
@@ -0,0 +1,3 @@
|
||||
export { exportToCsv, exportToJson, importFromJson } from "./exporter";
|
||||
export { importFromCsv } from "./importer";
|
||||
export { createSuppressionStore, SuppressionStore } from "./store";
|
||||
@@ -0,0 +1 @@
|
||||
export { createSuppressionStore, SuppressionStore } from "./suppression-store";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
SUPPRESSION_CODES,
|
||||
type SuppressionCode,
|
||||
SuppressionError,
|
||||
} from "./suppression-error";
|
||||
@@ -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,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";
|
||||
}
|
||||
}
|
||||
31
packages/communication/emails/suppression/src/index.ts
Normal file
31
packages/communication/emails/suppression/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
SuppressionEntry,
|
||||
SuppressionReason,
|
||||
SuppressionStats,
|
||||
SuppressionStoreOptions,
|
||||
SuppressionStoreSnapshot,
|
||||
} from "./suppression.types";
|
||||
@@ -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.
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { extractDomain, isValidEmail, normalizeEmail } from "./email-utils";
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
12
packages/communication/emails/suppression/tsconfig.lib.json
Normal file
12
packages/communication/emails/suppression/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
27
packages/communication/emails/suppression/vite.config.ts
Normal file
27
packages/communication/emails/suppression/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
25
packages/communication/emails/suppression/vitest.config.ts
Normal file
25
packages/communication/emails/suppression/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user