Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
46
plugins/document-navigator/docs/communication/README.md
Normal file
46
plugins/document-navigator/docs/communication/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# `communication`
|
||||
|
||||
Email delivery, messaging channels, and webhook clients.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Subcategories
|
||||
|
||||
3 subcategories, 16 packages.
|
||||
|
||||
### `emails`
|
||||
|
||||
Email delivery, DMARC parsing, templates, campaigns, and suppression lists.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/campaigns`](./campaigns/README.md) | Universal campaign orchestration. |
|
||||
| [`@suaveplan/delivery-tracking`](./delivery-tracking/README.md) | Channel-agnostic delivery event tracking for campaigns and messages: lifecycle dedupe, in-memory + SurrealDB stores, time-series aggregation, tracking pixel an… |
|
||||
| [`@suaveplan/dmarc`](./dmarc/README.md) | DMARC report parser for `@suaveplan` — parses aggregate (RUA) and forensic (RUF) DMARC XML reports into validated, strongly-typed structures. Accepts raw XML s… |
|
||||
| [`@suaveplan/domain-normalizer`](./domain-normalizer/README.md) | Normalisation, matching, and alias-canonicalisation for internet domain names. Given heterogeneous URL or domain input — including common mobile/CDN subdomains… |
|
||||
| [`@suaveplan/email-processing`](./email-processing/README.md) | Universal MIME parsing and email processing for the Suaveplan . Wraps `postal-mime` and `libmime` with structured errors, OpenTelemetry instrumentation, a hook… |
|
||||
| [`@suaveplan/email-templates`](./email-templates/README.md) | Universal, React-free email template engine. Produces email-safe HTML strings with inline styles via VNode factories — runs identically in Bun, Node, and the b… |
|
||||
| [`@suaveplan/emailer`](./emailer/README.md) | Universal email service with pluggable transports. Ships `EmailService`, three transports (Dummy / SMTP via injectable `SmtpClientLike` / Gmail REST via inject… |
|
||||
| [`@suaveplan/suppression`](./suppression/README.md) | Email suppression list management for Suaveplan. Stores and checks email addresses that should not receive outgoing mail — unsubscribes, hard/soft bounces, com… |
|
||||
|
||||
### `messaging`
|
||||
|
||||
Messaging adapters — Discord, Slack, Telegram, SMS, iMessage, and notifications.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/discord-client`](./discord-client/README.md) | Generic Discord API client for webhooks, bot messaging, and embeds. Runtime-agnostic via injectable `fetch` and `clock`. Maps every documented Discord failure… |
|
||||
| [`@suaveplan/imessage-adapter`](./imessage-adapter/README.md) | macOS iMessage SQLite chat.db watcher and `MessageSourceAdapter` implementation. Polls Apple's `~/Library/Messages/chat.db`, decodes Apple-epoch / nanosecond t… |
|
||||
| [`@suaveplan/notification-channel`](./notification-channel/README.md) | Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentati… |
|
||||
| [`@suaveplan/slack-client`](./slack-client/README.md) | Typed, deterministic Slack client — Web API + incoming-webhook surface — with Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable `Clock… |
|
||||
| [`@suaveplan/sms`](./sms/README.md) | Universal SMS core: provider-agnostic send pipeline with strict E.164 validation, Zod boundaries, template rendering, opt-out registry, token-bucket rate limit… |
|
||||
| [`@suaveplan/sms-providers`](./sms-providers/README.md) | Universal SMS provider implementations for @suaveplan/sms. Real HTTP clients for Twilio (Basic auth + form-encoded + X-Twilio-Signature HMAC-SHA1), Vonage (HMAC-SHA256 webhook), MessageBird (AccessKey + JWT HS256 webhook), Plivo (Basic auth + X-Plivo-Signature-V3 HMAC-SHA256), and AWS SNS (inline SigV4 + RSA-SHA1 SNS notification verifier). Ships ProviderRegistry, httpRequest over injectable FetchLike, composeAbortSignal, SMS_PROVIDERS_CODES + SmsProvidersError, per-provider mapStatus, parseInbound, parseOptOutKeyword, and five OTel instruments. No provider SDK deps; every HTTP call flows through an injectable FetchLike seam. |
|
||||
| [`@suaveplan/telegram-client`](./telegram-client/README.md) | Telegram Bot API client: send/edit/delete messages, six media variants, inline + custom keyboards, typed errors, injectable `fetch` + `clock`, and OpenTelemetr… |
|
||||
|
||||
### `webhook-client`
|
||||
|
||||
Webhook delivery client with retry logic and HMAC verification.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/webhook-client`](./webhook-client/README.md) | Webhook delivery client with retry logic, HMAC signature generation/verification, payload transformation, and delivery tracking. Runtime-agnostic (injectable f… |
|
||||
<!-- end:auto-docs -->
|
||||
3
plugins/document-navigator/docs/communication/_intro.md
Normal file
3
plugins/document-navigator/docs/communication/_intro.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `communication`
|
||||
|
||||
Email delivery, messaging channels, and webhook clients.
|
||||
@@ -0,0 +1,20 @@
|
||||
# `emails`
|
||||
|
||||
Email delivery, DMARC parsing, templates, campaigns, and suppression lists.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `emails`
|
||||
|
||||
8 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/campaigns`](./campaigns/README.md) | Universal campaign orchestration. |
|
||||
| [`@suaveplan/delivery-tracking`](./delivery-tracking/README.md) | Channel-agnostic delivery event tracking for campaigns and messages: lifecycle dedupe, in-memory + SurrealDB stores, time-series aggregation, tracking pixel an… |
|
||||
| [`@suaveplan/dmarc`](./dmarc/README.md) | DMARC report parser for `@suaveplan` — parses aggregate (RUA) and forensic (RUF) DMARC XML reports into validated, strongly-typed structures. Accepts raw XML s… |
|
||||
| [`@suaveplan/domain-normalizer`](./domain-normalizer/README.md) | Normalisation, matching, and alias-canonicalisation for internet domain names. Given heterogeneous URL or domain input — including common mobile/CDN subdomains… |
|
||||
| [`@suaveplan/email-processing`](./email-processing/README.md) | Universal MIME parsing and email processing for the Suaveplan . Wraps `postal-mime` and `libmime` with structured errors, OpenTelemetry instrumentation, a hook… |
|
||||
| [`@suaveplan/email-templates`](./email-templates/README.md) | Universal, React-free email template engine. Produces email-safe HTML strings with inline styles via VNode factories — runs identically in Bun, Node, and the b… |
|
||||
| [`@suaveplan/emailer`](./emailer/README.md) | Universal email service with pluggable transports. Ships `EmailService`, three transports (Dummy / SMTP via injectable `SmtpClientLike` / Gmail REST via inject… |
|
||||
| [`@suaveplan/suppression`](./suppression/README.md) | Email suppression list management for Suaveplan. Stores and checks email addresses that should not receive outgoing mail — unsubscribes, hard/soft bounces, com… |
|
||||
<!-- end:auto-docs -->
|
||||
@@ -0,0 +1 @@
|
||||
Email delivery, DMARC parsing, templates, campaigns, and suppression lists.
|
||||
@@ -0,0 +1,94 @@
|
||||
# @suaveplan/campaigns - Universal campaign orchestration — engine, state machine, batch planner, workflow, and pluggable store with injectable Clock/IdGenerator/TimersLike/LoggerLike, CAMPAIGNS_CODES error registry, and OTel self-telemetry
|
||||
|
||||
Universal campaign orchestration.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [batch-planner](./src/batch-planner/batch-planner.md) — Deterministic batch-planning engine for campaign sends. Splits a contact list into sized batches with throttle-aware sc…
|
||||
- [batch-planner](./src/batch-planner/batch-planner.md) — Deterministic batch-planning engine for campaign sends. Splits a contact list into sized batches with throttle-aware sc…
|
||||
- [engine](./src/engine/engine.md) — Top-level orchestrator for campaign CRUD and lifecycle. `CampaignEngine` accepts an injectable `CampaignStore` and expo…
|
||||
- [engine](./src/engine/engine.md) — Top-level orchestrator for campaign CRUD and lifecycle. `CampaignEngine` accepts an injectable `CampaignStore` and expo…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, machine-readable error namespace for `@suaveplan/campaigns`. Every failure path in the package — fr…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, machine-readable error namespace for `@suaveplan/campaigns`. Every failure path in the package — fr…
|
||||
- [state-machine](./src/state-machine/state-machine.md) — Strictly typed lifecycle state machine for campaigns. Owns the set of nine legal transitions between the seven `Campaig…
|
||||
- [state-machine](./src/state-machine/state-machine.md) — Strictly typed lifecycle state machine for campaigns. Owns the set of nine legal transitions between the seven `Campaig…
|
||||
- [store](./src/store/store.md) — Defines the `CampaignStore` persistence contract and ships an `InMemoryCampaignStore` reference implementation. Every s…
|
||||
- [store](./src/store/store.md) — Defines the `CampaignStore` persistence contract and ships an `InMemoryCampaignStore` reference implementation. Every s…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OTel self-telemetry for `@suaveplan/campaigns`. Exposes a small set of counters and histograms that are emitted from ev…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OTel self-telemetry for `@suaveplan/campaigns`. Exposes a small set of counters and histograms that are emitted from ev…
|
||||
- [workflow](./src/workflow/workflow.md) — Composable multi-step send pipeline for campaigns. `CampaignWorkflow.buildSendPipeline` returns a canonical six-step pi…
|
||||
- [workflow](./src/workflow/workflow.md) — Composable multi-step send pipeline for campaigns. `CampaignWorkflow.buildSendPipeline` returns a canonical six-step pi…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Modules
|
||||
|
||||
- [`engine/`](./src/engine/engine.md) — `CampaignEngine`: CRUD + lifecycle methods over an injectable `CampaignStore`.
|
||||
- [`state-machine/`](./src/state-machine/state-machine.md) — `CampaignStateMachine`: nine canonical transitions, typed event union.
|
||||
- [`batch-planner/`](./src/batch-planner/batch-planner.md) — `BatchPlanner`: throttle-aware splitter + duration estimator.
|
||||
- [`workflow/`](./src/workflow/workflow.md) — `CampaignWorkflow`: composable 6-step send pipeline + sequential runner.
|
||||
- [`store/`](./src/store/store.md) — `CampaignStore` interface + `InMemoryCampaignStore` reference.
|
||||
- [`error-codes/`](./src/error-codes/error-codes.md) — `CAMPAIGNS_CODES` + `CampaignsError`.
|
||||
- [`telemetry/`](./src/telemetry/telemetry.md) — OTel meter, counters, histograms.
|
||||
- [`types/`](./src/types/) — shared types.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/campaigns
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
const engine = new CampaignEngine(new InMemoryCampaignStore());
|
||||
|
||||
const campaign = await engine.create({
|
||||
tenantId: "tenant-1",
|
||||
name: "Spring Sale Announcement",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: {
|
||||
groupIds: ["all-subscribers"],
|
||||
excludeGroupIds: ["unsubscribed"],
|
||||
},
|
||||
templateId: "spring-sale-v1",
|
||||
});
|
||||
|
||||
await engine.schedule(campaign.id, {
|
||||
type: "scheduled",
|
||||
sendAt: new Date("2026-05-01T09:00:00Z"),
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
|
||||
await engine.send(campaign.id);
|
||||
```
|
||||
|
||||
## Throttle-aware batch planning
|
||||
|
||||
```ts
|
||||
import { BatchPlanner, type ThrottleConfig } from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner();
|
||||
const throttle: ThrottleConfig = { messagesPerSecond: 50 };
|
||||
const batches = planner.planBatches(contactIds, { batchSize: 200 }, throttle);
|
||||
const etaMs = planner.calculateTotalDuration(contactIds.length, { batchSize: 200 }, throttle);
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CampaignEngine ──┬── CampaignStateMachine (validation)
|
||||
├── CampaignStore (persistence)
|
||||
├── BatchPlanner (throttle-aware planning)
|
||||
└── CampaignWorkflow (6-step pipeline)
|
||||
```
|
||||
|
||||
All callers see `CampaignsError` with codes from `CAMPAIGNS_CODES`. All operations record OTel counters/histograms via the `telemetry/` module.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# batch-planner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic batch-planning engine for campaign sends. Splits a contact list into sized batches with throttle-aware scheduled timestamps, so a downstream dispatcher can read each `Batch.scheduledAt` and either dispatch immediately (when `undefined`) or schedule a delayed dispatch. The planner is the single source of truth for "how fast can we send this campaign without violating throttle limits", which keeps dispatch loops and reporting consistent.
|
||||
|
||||
The planner takes an injectable `Clock` so its output is reproducible in tests — pinning `t=0` to a fixed wall-clock value lets us assert exact timestamps without flaky `Date.now()` reads.
|
||||
|
||||
## Features
|
||||
|
||||
- `planBatches(contactIds, config?, throttle?)` returns a `Batch[]` keyed by `batchIndex`.
|
||||
- Throttle-aware delay calculation: picks the most restrictive of `messagesPerSecond`, `messagesPerMinute`, `messagesPerHour`, falling back to `delayBetweenBatches`.
|
||||
- Empty input short-circuits to `[]` while still recording the 0-size plan in telemetry.
|
||||
- `calculateTotalDuration(totalContacts, config?, throttle?)` estimates the total send-window length in milliseconds.
|
||||
- All inputs (`BatchPlannerOptions`, `BatchConfig`, `ThrottleConfig`) are Zod-validated at the boundary; bad values throw `CampaignsError(INVALID_BATCH_CONFIG|INVALID_THROTTLE_CONFIG|INVALID_OPTIONS)`.
|
||||
- Emits `campaigns_batches_planned_total`, `campaigns_batch_plan_size`, and `campaigns_send_duration_ms` instruments.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BatchPlanner } from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner();
|
||||
const batches = planner.planBatches(contactIds, { batchSize: 200 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pin the clock for reproducible tests and use the duration estimator to drive ETA labels:
|
||||
|
||||
```ts
|
||||
import { BatchPlanner, type ThrottleConfig } from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner({ clock: () => 1_700_000_000_000 });
|
||||
const throttle: ThrottleConfig = { messagesPerSecond: 50 };
|
||||
const batches = planner.planBatches(contactIds, { batchSize: 250 }, throttle);
|
||||
const etaMs = planner.calculateTotalDuration(contactIds.length, { batchSize: 250 }, throttle);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BatchPlannerOptions` — `{ clock?: Clock }`.
|
||||
- `Batch` — `{ batchIndex; contactIds; scheduledAt? }`.
|
||||
- `BatchPlanner.planBatches(contactIds, config?, throttle?): Batch[]`.
|
||||
- `BatchPlanner.calculateTotalDuration(totalContacts, config?, throttle?): number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The first batch (`batchIndex === 0`) always has `scheduledAt === undefined`, signalling "send immediately". Each subsequent batch's `scheduledAt` is `clock() + batchIndex * effectiveDelay`. `resolveDelay` deliberately reads only the first set throttle field (`messagesPerSecond` wins over `Minute`, which wins over `Hour`) — combining them would require a more sophisticated model and is left for an OpenSpec change. The duration estimator returns `0` for zero or negative contact counts so it can be called speculatively from UI layers without guard rails.
|
||||
@@ -0,0 +1,60 @@
|
||||
# engine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Top-level orchestrator for campaign CRUD and lifecycle. `CampaignEngine` accepts an injectable `CampaignStore` and exposes one method per high-level operation — `create`, `schedule`, `send`, `pause`, `resume`, `cancel`, `complete`, `fail`, `duplicate`, `updateStats`, `getById`, `list`. Lifecycle methods delegate validation to `CampaignStateMachine`; persistence delegates to the store. The engine itself contains no transition rules and no storage details, which keeps it stable across plugins (in-memory, SurrealDB, durable-object) and across UI surfaces.
|
||||
|
||||
All inputs cross a Zod boundary before they touch the store, so bad payloads surface as `CampaignsError(INVALID_INPUT|INVALID_OPTIONS)` rather than leaking through to the persistence layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `Clock`, `IdGenerator`, and `LoggerLike` so the engine is deterministic under test.
|
||||
- Zod validation on construction options and `CreateCampaignInput`.
|
||||
- Delegates state validation to `CampaignStateMachine`, never duplicates rules.
|
||||
- Records OTel instruments on every meaningful event (created / sent / failed / duplicate).
|
||||
- `duplicate(id)` produces a draft copy with reset stats, blanked `sentAt`/`completedAt`, and the original name suffixed with `(copy)`.
|
||||
- `fail(id, reason?)` stores the optional reason under `metadata.failureReason` before transitioning to the terminal `failed` state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
const engine = new CampaignEngine(new InMemoryCampaignStore());
|
||||
const campaign = await engine.create({
|
||||
tenantId: "t-1",
|
||||
name: "Spring Sale",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["subscribers"] },
|
||||
});
|
||||
await engine.send(campaign.id);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject deterministic dependencies for tests:
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
let id = 0;
|
||||
const engine = new CampaignEngine(new InMemoryCampaignStore(), {
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: () => `cmp-${++id}`,
|
||||
logger: pinoCompatibleLogger,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignEngineOptions` — `{ clock?; idGenerator?; logger? }`.
|
||||
- `CampaignEngine.create(input)`, `.schedule(id, schedule)`, `.send(id)`, `.pause(id)`, `.resume(id)`, `.cancel(id)`, `.complete(id)`, `.fail(id, reason?)`, `.duplicate(id)`, `.updateStats(id, stats)`, `.getById(id)`, `.list(query)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default id generator uses `cmp_<timestamp>_<rand>`. Inject a deterministic generator in tests. `requireCampaign` is the single helper that produces `CAMPAIGN_NOT_FOUND` — every lifecycle method funnels through it, which keeps the error path uniform. `fail` writes the reason before the status transition so that observers seeing the `failed` status are guaranteed to also see the reason in metadata. `duplicate` deliberately copies `audienceConfig` and other config by reference; downstream callers that need true isolation should clone before mutating.
|
||||
@@ -0,0 +1,63 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable, machine-readable error namespace for `@suaveplan/campaigns`. Every failure path in the package — from invalid options at construction time, through state-machine violations during a send, to missing records and store contract breaches — produces a `CampaignsError` whose `code` is drawn from the `CAMPAIGNS_CODES` registry.
|
||||
|
||||
Centralising the registry here keeps the error namespace decoupled from any one feature module. Downstream consumers can pattern-match on the code constants instead of importing concrete error classes, which keeps deserialised errors (over the wire, across worker boundaries) just as switchable as in-process ones.
|
||||
|
||||
## Features
|
||||
|
||||
- `CAMPAIGNS_CODES` literal map produced by `defineCodes("CAMPAIGNS", [...])`.
|
||||
- `CampaignsCode` discriminating union literal type derived from the registry.
|
||||
- `CampaignsError extends SuaveplanError<CampaignsCode>` with an explicit public constructor so `new CampaignsError(...)` is allowed at any call site.
|
||||
- Eight initial codes covering construction, input validation, state transitions, missing records, store breaches, batch + throttle config validation, and workflow failures.
|
||||
- Codes are namespaced under the `CAMPAIGNS.` prefix so they never clash with codes from sibling packages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "@suaveplan/campaigns";
|
||||
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
|
||||
"Campaign not found: c-42",
|
||||
{ context: { id: "c-42" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Discriminating in consumer code without an `instanceof` chain:
|
||||
|
||||
```ts
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "@suaveplan/campaigns";
|
||||
|
||||
try {
|
||||
await engine.send(id);
|
||||
} catch (err) {
|
||||
if (!(err instanceof CampaignsError)) throw err;
|
||||
switch (err.code) {
|
||||
case CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND:
|
||||
return { kind: "not-found" as const };
|
||||
case CAMPAIGNS_CODES.INVALID_TRANSITION:
|
||||
return { kind: "conflict" as const };
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CAMPAIGNS_CODES` — readonly literal map of `KEY -> "CAMPAIGNS.KEY"` strings.
|
||||
- `CampaignsCode` — `typeof CAMPAIGNS_CODES[keyof typeof CAMPAIGNS_CODES]`.
|
||||
- `CampaignsError(code, message, options?)` — public constructor delegating to `SuaveplanError`. `options.cause` chains an underlying error; `options.context` attaches a structured payload.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SuaveplanError`'s base constructor is protected, so without the explicit `public constructor` here, `new CampaignsError(...)` would fail with TS2674. The constructor sets `this.name = "CampaignsError"` so stack traces are self-describing. The `code` field narrows against `CampaignsCode`, which means consumers get exhaustiveness checking on `switch` statements with `noFallthroughCasesInSwitch` enabled.
|
||||
@@ -0,0 +1,59 @@
|
||||
# state-machine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Strictly typed lifecycle state machine for campaigns. Owns the set of nine legal transitions between the seven `CampaignStatus` values and rejects every other combination with a typed `CampaignsError`. The state machine is consumed by `CampaignEngine` for every status-changing operation (`schedule`, `send`, `pause`, `resume`, `cancel`, `complete`, `fail`) and by UI layers that need to enumerate the available next-step actions for the user.
|
||||
|
||||
By centralising the transition rules here we keep the rest of the package free of conditional `if (status === ...)` ladders; new lifecycle events are added by appending to the `TRANSITIONS` table.
|
||||
|
||||
## Features
|
||||
|
||||
- Nine canonical transitions covering draft, scheduled, sending, paused, plus the three terminal states (completed, cancelled, failed).
|
||||
- `transition(currentStatus, event)` returns the next status or throws `CampaignsError(INVALID_TRANSITION)` with the offending `{from, event}` in `context`.
|
||||
- `canTransition(currentStatus, event)` boolean predicate for UI affordances.
|
||||
- `getAvailableEvents(currentStatus)` lists every legal event for the current status — terminal states return `[]`.
|
||||
- Records a `campaigns_transitions_total` counter point per successful transition tagged with `from`, `to`, and `event` labels.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignStateMachine } from "@suaveplan/campaigns";
|
||||
|
||||
const sm = new CampaignStateMachine();
|
||||
const next = sm.transition("draft", "send"); // "sending"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive UI buttons from the available-events list:
|
||||
|
||||
```ts
|
||||
import { CampaignStateMachine, type CampaignEvent } from "@suaveplan/campaigns";
|
||||
|
||||
const sm = new CampaignStateMachine();
|
||||
const available: CampaignEvent[] = sm.getAvailableEvents(campaign.status);
|
||||
return available.map((event) => (
|
||||
<Button
|
||||
key={event}
|
||||
disabled={!sm.canTransition(campaign.status, event)}
|
||||
onClick={() => onEvent(event)}
|
||||
>
|
||||
{event}
|
||||
</Button>
|
||||
));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignEvent` — `"schedule" | "send" | "start" | "cancel" | "pause" | "complete" | "fail" | "resume"`.
|
||||
- `CampaignStateMachine.transition(currentStatus, event): CampaignStatus` — throws `CampaignsError` with code `CAMPAIGNS.INVALID_TRANSITION` if the combination is illegal.
|
||||
- `CampaignStateMachine.canTransition(currentStatus, event): boolean`.
|
||||
- `CampaignStateMachine.getAvailableEvents(currentStatus): CampaignEvent[]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `TRANSITIONS` table is a `readonly Transition[]` — frozen at module load. Lookups are linear (n=9), which is faster in practice than a nested `Record<Status, Record<Event, Status>>` lookup for this size. Adding a new event means appending to the array; removing one is a public API change and requires an OpenSpec proposal. The telemetry record is fired only on success, so the counter never double-counts a rejected transition.
|
||||
@@ -0,0 +1,56 @@
|
||||
# store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `CampaignStore` persistence contract and ships an `InMemoryCampaignStore` reference implementation. Every store responsibility — create, read, update, delete, list with filters, mutate stats, mutate status — is captured here. The engine and workflow modules consume only the contract, never the concrete class, so production deployments can swap in a SurrealDB-, Postgres-, or Durable-Object-backed store without touching the orchestration layer.
|
||||
|
||||
The in-memory implementation is the default for unit tests, prototype workloads, and single-process scenarios. It is byte-for-byte compatible with the SurrealDB plugin's expected surface so workflows written against it port unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
- `CampaignStore` interface with seven methods: `create`, `getById`, `update`, `delete`, `list`, `updateStats`, `updateStatus`.
|
||||
- `InMemoryCampaignStore` backed by `Map<string, Campaign>` with defensive cloning on every read/write so callers cannot tamper with internal records by reference.
|
||||
- Injectable `Clock` on the in-memory implementation so `updatedAt` is reproducible in tests.
|
||||
- `list(query)` filters by `tenantId`, `status`, `type`, `channel`, plus `limit`/`offset` pagination (defaults: 50 / 0).
|
||||
- Missing-record paths throw `CampaignsError(CAMPAIGN_NOT_FOUND)` with the offending id in `context`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
const store = new InMemoryCampaignStore();
|
||||
const engine = new CampaignEngine(store);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Implement a custom store backed by a database:
|
||||
|
||||
```ts
|
||||
import type { CampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
class SurrealCampaignStore implements CampaignStore {
|
||||
async create(c) { /* INSERT */ }
|
||||
async getById(id) { /* SELECT */ }
|
||||
async update(id, input) { /* UPDATE */ }
|
||||
async delete(id) { /* DELETE */ }
|
||||
async list(q) { /* SELECT WHERE */ }
|
||||
async updateStats(id, stats) { /* UPDATE stats */ }
|
||||
async updateStatus(id, status) { /* UPDATE status */ }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignStore` — interface with seven async methods.
|
||||
- `InMemoryCampaignStoreOptions` — `{ clock?: Clock }`.
|
||||
- `InMemoryCampaignStore` — reference implementation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The defensive clones use `{ ...campaign }` which is a shallow copy. Nested objects (`audienceConfig`, `metadata`, `stats`) are shared by reference; the engine never mutates them in place, but custom store implementations should preserve the same contract. `list()` paginates after filtering — a future change can swap in a database-side `LIMIT/OFFSET`. The default page size of 50 mirrors the upstream API plugin's expectation.
|
||||
@@ -0,0 +1,58 @@
|
||||
# telemetry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OTel self-telemetry for `@suaveplan/campaigns`. Exposes a small set of counters and histograms that are emitted from every meaningful operation in the package — campaign creation, lifecycle transitions, send dispatches, batch planning, and failures. The instruments resolve against the globally registered `MeterProvider` via `@opentelemetry/api`; when no provider is registered, the OTel API returns no-op meters, so importing this module is always safe in production code that has not opted into telemetry.
|
||||
|
||||
Centralising the meter and record helpers in one module keeps the instrumentation surface explicit and prevents feature modules from drifting into ad-hoc, untagged metrics.
|
||||
|
||||
## Features
|
||||
|
||||
- Five counters: `campaigns_created_total`, `campaigns_transitions_total`, `campaigns_sends_total`, `campaigns_batches_planned_total`, `campaigns_failures_total`.
|
||||
- Two histograms: `campaigns_batch_plan_size` (recipient count per plan) and `campaigns_send_duration_ms` (estimated total send-window duration in ms).
|
||||
- Record helpers (`recordCampaignCreated`, `recordTransition`, `recordSend`, `recordBatchesPlanned`, `recordFailure`, `recordBatchPlanSize`, `recordSendDuration`) so call sites stay readable.
|
||||
- Lazy meter resolution via `metrics.getMeter("@suaveplan/campaigns")` so the module has zero side effects beyond the OTel API itself.
|
||||
- Per-instrument descriptions and units (`ms`, `{contact}`) so dashboards can label axes correctly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordCampaignCreated, recordSend } from "@suaveplan/campaigns";
|
||||
|
||||
recordCampaignCreated("broadcast", "email");
|
||||
recordSend("email");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pair the histogram with the planner output for percentile dashboards:
|
||||
|
||||
```ts
|
||||
import {
|
||||
BatchPlanner,
|
||||
recordBatchPlanSize,
|
||||
recordSendDuration,
|
||||
} from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner();
|
||||
const contacts = await audience.resolve(campaign.audienceConfig);
|
||||
const batches = planner.planBatches(contacts, { batchSize: 250 });
|
||||
// planBatches() already records both metrics; helpers are exported so
|
||||
// callers can mirror the recording from custom dispatch loops too.
|
||||
recordBatchPlanSize(contacts.length);
|
||||
recordSendDuration(planner.calculateTotalDuration(contacts.length));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `campaignsCreatedCounter`, `campaignTransitionsCounter`, `campaignSendsCounter`, `batchesPlannedCounter`, `campaignFailuresCounter` — OTel `Counter` instances.
|
||||
- `batchPlanSizeHistogram`, `sendDurationHistogram` — OTel `Histogram` instances.
|
||||
- `recordCampaignCreated(type, channel)`, `recordTransition(from, to, event)`, `recordSend(channel)`, `recordBatchesPlanned(count)`, `recordFailure(channel)`, `recordBatchPlanSize(count)`, `recordSendDuration(ms)` — record helpers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter is acquired once at module load time. Because `getMeter` is idempotent and the OTel API guarantees safe no-op behaviour when no provider is registered, the helpers can be called unconditionally from hot paths. Counters carry low-cardinality labels (`type`, `channel`, `from`, `to`, `event`) — never per-tenant or per-campaign identifiers, which would explode metric series. Add such labels via downstream OTel views if needed.
|
||||
@@ -0,0 +1,70 @@
|
||||
# campaign.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Canonical shared types for `@suaveplan/campaigns`. Every cross-module data shape lives here — `Campaign`, `CampaignStatus`, `CampaignType`, `AudienceConfig`, `CampaignSchedule`, `ThrottleConfig`, `ABTestConfig`, `ABTestVariant`, `CampaignStats`, `CreateCampaignInput`, `UpdateCampaignInput`, `CampaignQuery`, and `BatchConfig` — plus the `DEFAULT_BATCH_CONFIG` constant. Centralising these types here breaks circular dependencies between the engine, state machine, batch planner, workflow, and store modules.
|
||||
|
||||
The types are intentionally framework-agnostic and serialisation-safe. There are no class instances, no `Map`s, no `Set`s — only plain object shapes whose JSON representation round-trips cleanly through any persistence or transport layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven `CampaignStatus` values modelling the full lifecycle (`draft`, `scheduled`, `sending`, `paused`, `completed`, `cancelled`, `failed`).
|
||||
- Four `CampaignType` values (`broadcast`, `triggered`, `ab_test`, `drip`).
|
||||
- `AudienceConfig` with inclusion (`groupIds`), exclusion (`excludeGroupIds`, `excludeContactIds`), segment-driven targeting (`segmentIds`), and explicit suppression-list references (`suppressionListIds`).
|
||||
- `CampaignSchedule` covering immediate, scheduled, and recurring sends.
|
||||
- `ThrottleConfig` with per-second / per-minute / per-hour limits and optional ramp-up duration.
|
||||
- `ABTestConfig` + `ABTestVariant` with even or weighted splits, optional test duration, and the standard `open_rate` / `click_rate` / `conversion` winner criteria.
|
||||
- `Campaign` generic over an arbitrary `TMeta` metadata shape, with reset-safe `stats: CampaignStats`.
|
||||
- Input shapes (`CreateCampaignInput`, `UpdateCampaignInput`, `CampaignQuery`) that mirror the engine's public surface.
|
||||
- `BatchConfig` + `DEFAULT_BATCH_CONFIG` (`{ batchSize: 100, delayBetweenBatches: 0 }`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { Campaign, CampaignStatus } from "@suaveplan/campaigns";
|
||||
|
||||
function isTerminal(status: CampaignStatus): boolean {
|
||||
return (
|
||||
status === "completed" ||
|
||||
status === "cancelled" ||
|
||||
status === "failed"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Parameterise the metadata shape per tenant:
|
||||
|
||||
```ts
|
||||
import type { Campaign, CreateCampaignInput } from "@suaveplan/campaigns";
|
||||
|
||||
interface AcmeMeta {
|
||||
readonly source: "api" | "ui" | "webhook";
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
const input: CreateCampaignInput<AcmeMeta> = {
|
||||
tenantId: "acme",
|
||||
name: "Quarterly newsletter",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["all"] },
|
||||
metadata: { source: "api", correlationId: "corr-1" },
|
||||
};
|
||||
|
||||
declare const campaign: Campaign<AcmeMeta>;
|
||||
campaign.metadata.correlationId; // strongly typed
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignStatus`, `CampaignType` — string-literal unions.
|
||||
- `AudienceConfig`, `CampaignSchedule`, `ThrottleConfig`, `ABTestConfig`, `ABTestVariant`, `CampaignStats` — shape interfaces.
|
||||
- `Campaign<TMeta>` — main aggregate.
|
||||
- `CreateCampaignInput<TMeta>`, `UpdateCampaignInput<TMeta>`, `CampaignQuery` — engine input shapes.
|
||||
- `BatchConfig`, `DEFAULT_BATCH_CONFIG` — batch sizing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`AudienceConfig` uses `readonly` arrays so call sites cannot mutate config in place. `Campaign.stats` is mutable on purpose — the engine reads-modifies-writes it during `updateStats`. Adding a new `CampaignStatus` or `CampaignType` is a public API change and requires an OpenSpec proposal because the state machine, the API plugin, and persistence schemas all switch on these literal unions.
|
||||
@@ -0,0 +1,60 @@
|
||||
# workflow
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Composable multi-step send pipeline for campaigns. `CampaignWorkflow.buildSendPipeline` returns a canonical six-step pipeline — `resolve-audience`, `apply-suppression`, `plan-batches`, `send-batches`, `update-stats`, `complete` — that downstream integrations can wrap, instrument, or replace step-by-step. `CampaignWorkflow.run` executes the supplied step list against an initial `WorkflowContext`, threading the result of each step into the next, and wraps any step-level error in a typed `CampaignsError` so callers can pattern-match on the canonical registry.
|
||||
|
||||
The workflow is intentionally minimal — it owns ordering and error handling, not business logic. Each step is a plain async function that returns the next `WorkflowContext`, which keeps swapping in real audience resolvers, suppression services, and dispatchers a one-line override.
|
||||
|
||||
## Features
|
||||
|
||||
- Canonical six-step send pipeline returned by `buildSendPipeline(campaign)`.
|
||||
- `run(steps, initial)` executes every step in order, threading context through.
|
||||
- Errors raised by a step are wrapped in `CampaignsError(WORKFLOW_STEP_FAILED)` with the failing step id in `context.stepId` and the original error preserved on `cause`.
|
||||
- Injectable `LoggerLike` for debug-level tracing of step start/end events.
|
||||
- `WorkflowStep` carries `id`, `name`, `description`, and an async `execute` — descriptions surface in UI-driven progress panels.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignWorkflow } from "@suaveplan/campaigns";
|
||||
|
||||
const workflow = new CampaignWorkflow();
|
||||
const steps = workflow.buildSendPipeline(campaign);
|
||||
const result = await workflow.run(steps, { campaign });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Override individual steps with real integrations:
|
||||
|
||||
```ts
|
||||
import { CampaignWorkflow, type WorkflowStep } from "@suaveplan/campaigns";
|
||||
|
||||
const workflow = new CampaignWorkflow({ logger });
|
||||
const defaults = workflow.buildSendPipeline(campaign);
|
||||
const overrides: WorkflowStep[] = defaults.map((step) =>
|
||||
step.id === "resolve-audience"
|
||||
? { ...step, execute: async (ctx) => ({ ...ctx, contactIds: await audience.resolve(campaign) }) }
|
||||
: step.id === "send-batches"
|
||||
? { ...step, execute: async (ctx) => dispatch(ctx) }
|
||||
: step
|
||||
);
|
||||
const result = await workflow.run(overrides, { campaign });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WorkflowContext` — `{ campaign; contactIds?; batches?; results? }`.
|
||||
- `WorkflowStep` — `{ id; name; description; execute(context) }`.
|
||||
- `CampaignWorkflowOptions` — `{ logger? }`.
|
||||
- `CampaignWorkflow.buildSendPipeline(campaign): WorkflowStep[]`.
|
||||
- `CampaignWorkflow.run(steps, initial): Promise<WorkflowContext>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`run` executes sequentially — there is no parallel-step support, because the default pipeline is strictly ordered (you can't send batches before you've planned them). For fan-out scenarios, wrap `send-batches` to dispatch its work to a worker pool. Error wrapping uses `{ cause }`, so `err.cause` preserves the original throw and is consumable by `Error.cause` walkers and structured-logger redactors.
|
||||
@@ -0,0 +1,106 @@
|
||||
# @suaveplan/delivery-tracking - Channel-agnostic delivery event tracking for campaigns and messages: lifecycle dedupe, in-memory + SurrealDB stores, time-series aggregation, tracking pixel / link tokens, multi-provider webhook parsing, OTel instrumentation
|
||||
|
||||
Channel-agnostic delivery event tracking for campaigns and messages: lifecycle dedupe, in-memory + SurrealDB stores, time-series aggregation, tracking pixel and click-link tokens, multi-provider webhook parsing, and OpenTelemetry instrumentation.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [aggregator](./src/core/aggregator/aggregator.md) — `DeliveryAggregator` reads campaign and contact statistics from any `DeliveryEventStore` and exposes time-series bucket…
|
||||
- [aggregator](./src/core/aggregator/aggregator.md) — `DeliveryAggregator` reads campaign and contact statistics from any `DeliveryEventStore` and exposes time-series bucket…
|
||||
- [tracker](./src/core/tracker/tracker.md) — `DeliveryTracker` is the package's primary write-side surface. It validates inbound `CreateDeliveryEventInput`s against…
|
||||
- [tracker](./src/core/tracker/tracker.md) — `DeliveryTracker` is the package's primary write-side surface. It validates inbound `CreateDeliveryEventInput`s against…
|
||||
- [tracker.schemas](./src/core/tracker/tracker.schemas.md) — `tracker.schemas` declares the Zod boundary schemas applied to `DeliveryTracker.track` input. Centralising the schemas…
|
||||
- [tracking-link](./src/core/tracking-link/tracking-link.md) — `TrackingLinkWrapper` rewrites outbound URLs with click-tracking redirects. Each wrapped URL points at the tracking hos…
|
||||
- [tracking-link](./src/core/tracking-link/tracking-link.md) — `TrackingLinkWrapper` rewrites outbound URLs with click-tracking redirects. Each wrapped URL points at the tracking hos…
|
||||
- [tracking-pixel](./src/core/tracking-pixel/tracking-pixel.md) — `TrackingPixelGenerator` produces and parses the open-tracking pixel URLs embedded in outbound HTML emails. The pixel U…
|
||||
- [token-codec](./src/core/tracking-pixel/token-codec.md) — `token-codec` is the shared XOR + base64url encoder/decoder used by both `TrackingPixelGenerator` (open tracking) and `…
|
||||
- [tracking-pixel](./src/core/tracking-pixel/tracking-pixel.md) — `TrackingPixelGenerator` produces and parses the open-tracking pixel URLs embedded in outbound HTML emails. The pixel U…
|
||||
- [webhook-parser](./src/core/webhook-parser/webhook-parser.md) — `WebhookEventParser` normalises provider-specific delivery webhooks into `CreateDeliveryEventInput[]` ready for `Delive…
|
||||
- [webhook-parser](./src/core/webhook-parser/webhook-parser.md) — `WebhookEventParser` normalises provider-specific delivery webhooks into `CreateDeliveryEventInput[]` ready for `Delive…
|
||||
- [errors](./src/errors/delivery-tracking-error.md) — `DeliveryTrackingError` is the stable typed error class raised by every code path inside `@suaveplan/delivery-tracking`…
|
||||
- [delivery-tracking-error](./src/errors/delivery-tracking-error.md) — `DeliveryTrackingError` is the stable typed error class raised by every code path inside `@suaveplan/delivery-tracking`…
|
||||
- [otel](./src/otel/instruments.md) — `getDeliveryTrackingInstruments` lazily constructs and caches the OTel `Counter` set used by every production code path…
|
||||
- [instruments](./src/otel/instruments.md) — `getDeliveryTrackingInstruments` lazily constructs and caches the OTel `Counter` set used by every production code path…
|
||||
- [memory](./src/stores/memory/memory-store.md) — `memory-store` defines the `DeliveryEventStore` interface that every persistence backend must satisfy, ships the determ…
|
||||
- [memory-store](./src/stores/memory/memory-store.md) — `memory-store` defines the `DeliveryEventStore` interface that every persistence backend must satisfy, ships the determ…
|
||||
- [surreal](./src/stores/surreal/surreal-store.md) — `SurrealDeliveryEventStore` is the SurrealDB-backed implementation of the `DeliveryEventStore` contract. It uses positi…
|
||||
- [surreal-store](./src/stores/surreal/surreal-store.md) — `SurrealDeliveryEventStore` is the SurrealDB-backed implementation of the `DeliveryEventStore` contract. It uses positi…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/delivery-tracking
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
DeliveryAggregator,
|
||||
DeliveryTracker,
|
||||
InMemoryDeliveryEventStore,
|
||||
TrackingPixelGenerator,
|
||||
WebhookEventParser,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
const tracker = new DeliveryTracker(store);
|
||||
const aggregator = new DeliveryAggregator(store);
|
||||
const parser = new WebhookEventParser();
|
||||
const pixel = new TrackingPixelGenerator({
|
||||
baseUrl: "https://t.example.com",
|
||||
secret: "tracking-secret",
|
||||
});
|
||||
|
||||
tracker.bus.on("delivery:opened", (e) => {
|
||||
console.log("opened", e.messageId);
|
||||
});
|
||||
|
||||
const events = parser.parse("sendgrid", { body: webhookBody });
|
||||
await tracker.trackBatch(events);
|
||||
|
||||
const stats = await aggregator.getCampaignStats("c1");
|
||||
const pixelUrl = pixel.generatePixelUrl("m1", "u1", "c1");
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [`src/core/tracker/tracker.ts`](src/core/tracker/tracker.md) | Validates and records events, dedupes lifecycle types, emits on a typed bus. |
|
||||
| [`src/core/tracker/tracker.schemas.ts`](src/core/tracker/tracker.schemas.md) | Zod boundary schemas for `CreateDeliveryEventInput`. |
|
||||
| [`src/core/aggregator/aggregator.ts`](src/core/aggregator/aggregator.md) | Campaign rollups, contact histories, time-series bucketing. |
|
||||
| [`src/core/tracking-pixel/tracking-pixel.ts`](src/core/tracking-pixel/tracking-pixel.md) | Open-tracking pixel URL generator + parser. |
|
||||
| [`src/core/tracking-pixel/token-codec.ts`](src/core/tracking-pixel/token-codec.md) | Shared XOR + base64url codec used by pixel and link wrappers. |
|
||||
| [`src/core/tracking-link/tracking-link.ts`](src/core/tracking-link/tracking-link.md) | Click-tracking URL wrapper, HTML rewrite, token parse. |
|
||||
| [`src/core/webhook-parser/webhook-parser.ts`](src/core/webhook-parser/webhook-parser.md) | Pluggable provider webhook → input normaliser. |
|
||||
| [`src/stores/memory/memory-store.ts`](src/stores/memory/memory-store.md) | `DeliveryEventStore` contract + in-memory implementation + `computeStats`. |
|
||||
| [`src/stores/surreal/surreal-store.ts`](src/stores/surreal/surreal-store.md) | SurrealDB-backed implementation using positional SurQL. |
|
||||
| [`src/errors/delivery-tracking-error.ts`](src/errors/delivery-tracking-error.md) | `DeliveryTrackingError` + stable code registry. |
|
||||
| [`src/otel/instruments.ts`](src/otel/instruments.md) | Lazy OTel counter set (`tracked`, `deduped`, `webhook_parsed`, `token_issued`, `token_rejected`). |
|
||||
|
||||
## Features
|
||||
|
||||
- Lifecycle dedupe per `messageId` for `opened`, `delivered`, `bounced`, `unsubscribed`, `complained`.
|
||||
- Typed `EventBus<DeliveryTrackerEventMap>` with one topic per `DeliveryEventType`.
|
||||
- Backend-agnostic via `DeliveryEventStore` — drop-in `InMemoryDeliveryEventStore`, `SurrealDeliveryEventStore`, or your own.
|
||||
- Time-series bucketing by `hour`/`day`/`week`.
|
||||
- Pluggable webhook parsers for SendGrid, Mailgun, AWS SES, and a generic shape.
|
||||
- Zod boundary validation on every event the tracker accepts.
|
||||
- Counters emitted via `@opentelemetry/api` for ingest, dedupe, parser activity, and token issuance/rejection.
|
||||
|
||||
## Subpath exports
|
||||
|
||||
`./tracker`, `./aggregator`, `./tracking-pixel`, `./tracking-link`, `./webhook-parser`, `./stores/memory`, `./stores/surreal`, `./errors`, `./otel`.
|
||||
|
||||
## Errors
|
||||
|
||||
Every failure raised by the package extends `DeliveryTrackingError`. The stable code registry:
|
||||
|
||||
- `DELIVERY_TRACKING.INVALID_INPUT`
|
||||
- `DELIVERY_TRACKING.INVALID_TOKEN`
|
||||
- `DELIVERY_TRACKING.UNSUPPORTED_PROVIDER`
|
||||
- `DELIVERY_TRACKING.STORE_FAILURE`
|
||||
- `DELIVERY_TRACKING.QUERY_FAILURE`
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# aggregator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DeliveryAggregator` reads campaign and contact statistics from any `DeliveryEventStore` and exposes time-series bucketing on top. It is intentionally stateless: every public method is a thin composition of store calls plus the pure `computeStats` helper, so caching belongs to the caller (a Redis layer, a Hono route, etc.).
|
||||
|
||||
The aggregator is the read-side counterpart to `DeliveryTracker`. Together they implement the channel-agnostic delivery analytics surface described in the package's enterprise spec — counts, rates, contact histories, and time-bucketed rollups across whatever store the host wires up.
|
||||
|
||||
## Features
|
||||
|
||||
- Campaign-level rollup via `getCampaignStats` returning `DeliveryStats` (counts + percentage rates).
|
||||
- Per-contact history via `getContactStats` with optional `DeliveryEventQuery` filtering.
|
||||
- Time-series bucketing by hour, day, or week through `getTimeSeriesStats`.
|
||||
- Empty-input guards return `[]` for the time series and `0`-valued rates rather than `NaN`.
|
||||
- Backend-agnostic — works with `InMemoryDeliveryEventStore`, `SurrealDeliveryEventStore`, or any custom backend implementing `DeliveryEventStore`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DeliveryAggregator,
|
||||
InMemoryDeliveryEventStore,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
|
||||
const agg = new DeliveryAggregator(new InMemoryDeliveryEventStore());
|
||||
const stats = await agg.getCampaignStats("c1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DeliveryAggregator,
|
||||
SurrealDeliveryEventStore,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
|
||||
const agg = new DeliveryAggregator(new SurrealDeliveryEventStore(adapter));
|
||||
const hourly = await agg.getTimeSeriesStats("c-launch", "hour");
|
||||
const filtered = await agg.getContactStats("u1", {
|
||||
eventType: "clicked",
|
||||
from: new Date("2026-01-01"),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class DeliveryAggregator` — `constructor(store: DeliveryEventStore)`.
|
||||
- `getCampaignStats(campaignId)` → `Promise<DeliveryStats>`.
|
||||
- `getContactStats(contactId, query?)` → `Promise<DeliveryEvent[]>`.
|
||||
- `getTimeSeriesStats(campaignId, interval)` → `Promise<TimeSeriesBucket[]>` where `interval ∈ {"hour", "day", "week"}`.
|
||||
- `type TimeInterval = "hour" | "day" | "week"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Bucketing walks from the earliest to the latest event timestamp in fixed-size strides; empty buckets are *included* (they contain a zero-valued `DeliveryStats` from `computeStats([])`). This matches the typical analytics expectation that a charted series shows quiet hours as flat zero rather than gaps. The interval-millisecond table is hard-coded — `hour: 3_600_000`, `day: 86_400_000`, `week: 604_800_000` — so DST does not skew the bucket boundaries.
|
||||
@@ -0,0 +1,74 @@
|
||||
# tracker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DeliveryTracker` is the package's primary write-side surface. It validates inbound `CreateDeliveryEventInput`s against a Zod schema, applies lifecycle dedupe per `messageId` for `opened`/`delivered`/`bounced`/`unsubscribed`/`complained`, persists accepted events via a pluggable `DeliveryEventStore`, increments the appropriate OTel counters, and re-emits the persisted event on a typed `EventBus` so downstream subscribers (notification triggers, analytics fan-out) can react.
|
||||
|
||||
Both the `Clock` and `IdGenerator` are injectable from `@suaveplan/types`, so tests can pin timestamps and IDs deterministically. The default ID generator combines the injected clock with a monotonically increasing counter, ensuring uniqueness without depending on `Math.random` or `crypto.randomUUID`.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod boundary validation; failures throw `DeliveryTrackingError(INVALID_INPUT)`.
|
||||
- Lifecycle dedupe via `store.exists(messageId, eventType)` with a `delivery_tracking.deduped.total` counter increment on suppression.
|
||||
- Typed `EventBus<DeliveryTrackerEventMap>` emission (`delivery:sent`, `delivery:opened`, …).
|
||||
- Injectable `Clock`, `IdGenerator`, and `bus` instance.
|
||||
- `trackBatch` for sequential ingest with `null` slots for deduplicated entries.
|
||||
- OTel counter `delivery_tracking.tracked.total` with `event_type` + `channel` attributes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DeliveryTracker,
|
||||
InMemoryDeliveryEventStore,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
|
||||
tracker.bus.on("delivery:opened", (e) => console.log("opened", e.messageId));
|
||||
await tracker.track({
|
||||
tenantId: "t1",
|
||||
contactId: "u1",
|
||||
channel: "email",
|
||||
eventType: "opened",
|
||||
messageId: "m1",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import {
|
||||
DeliveryTracker,
|
||||
type DeliveryTrackerEventMap,
|
||||
InMemoryDeliveryEventStore,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
|
||||
const bus = new EventBus<DeliveryTrackerEventMap>();
|
||||
let id = 0;
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore(), {
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: () => `evt-${++id}`,
|
||||
bus,
|
||||
});
|
||||
const r = await tracker.trackBatch([
|
||||
{ tenantId: "t", contactId: "u", channel: "email", eventType: "delivered", messageId: "m" },
|
||||
{ tenantId: "t", contactId: "u", channel: "email", eventType: "delivered", messageId: "m" }, // deduped
|
||||
]);
|
||||
// r[1] === null
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class DeliveryTracker` — public `bus` (`EventBus<DeliveryTrackerEventMap>`).
|
||||
- `constructor(store: DeliveryEventStore, options?: DeliveryTrackerOptions)` — options accept `clock`, `idGenerator`, `bus`.
|
||||
- `track(input)` → `Promise<DeliveryEvent | null>` — `null` on dedupe; throws `DeliveryTrackingError(INVALID_INPUT)` on schema failure.
|
||||
- `trackBatch(inputs)` → `Promise<(DeliveryEvent | null)[]>` — sequential, preserves input order.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The dedupe set is hard-coded to the five "irreversible" lifecycle events. `clicked`, `sent`, `deferred`, `dropped`, and `read` are intentionally allowed to repeat because they correspond to actions a recipient or provider can plausibly trigger multiple times per message. The `bus.emit` call dispatches synchronously — async subscribers should attach via `emitAsync` paths inside their own logic, not by changing the tracker.
|
||||
@@ -0,0 +1,52 @@
|
||||
# tracker.schemas
|
||||
|
||||
## Purpose
|
||||
|
||||
`tracker.schemas` declares the Zod boundary schemas applied to `DeliveryTracker.track` input. Centralising the schemas next to the consumer keeps the validation contract close to the code that enforces it and lets callers reuse the same schemas (e.g. when ingesting from an HTTP route via `@hono/zod-openapi`).
|
||||
|
||||
The schema mirrors the `CreateDeliveryEventInput` interface in `types/delivery-tracking.types.ts`. Both are kept in sync — the schema is the runtime contract, the interface is the compile-time contract, and the tracker's `safeParse` is the single chokepoint that proves they match for any input that reaches the store.
|
||||
|
||||
## Features
|
||||
|
||||
- `deliveryEventTypeSchema` — Zod enum matching `DeliveryEventType` exactly.
|
||||
- `createDeliveryEventInputSchema` — Zod object with required `tenantId`, `contactId`, `channel`, `eventType`, `messageId` and optional `campaignId`, `metadata`, `timestamp`, `ip`, `userAgent`.
|
||||
- `CreateDeliveryEventInputSchema` type alias for the parsed shape.
|
||||
- Zero side effects — purely declarative.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDeliveryEventInputSchema } from "@suaveplan/delivery-tracking/tracker";
|
||||
|
||||
const parsed = createDeliveryEventInputSchema.safeParse({
|
||||
tenantId: "t1",
|
||||
contactId: "u1",
|
||||
channel: "email",
|
||||
eventType: "sent",
|
||||
messageId: "m1",
|
||||
});
|
||||
if (!parsed.success) throw parsed.error;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { createDeliveryEventInputSchema } from "@suaveplan/delivery-tracking/tracker";
|
||||
|
||||
// Extend with a route-specific constraint without redeclaring the base shape.
|
||||
const httpInputSchema = createDeliveryEventInputSchema.extend({
|
||||
sourceIp: z.string().ip().optional(),
|
||||
});
|
||||
type HttpInput = z.infer<typeof httpInputSchema>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `deliveryEventTypeSchema: z.ZodEnum<…>` — accepts every value in `DeliveryEventType`.
|
||||
- `createDeliveryEventInputSchema: z.ZodObject<…>` — Zod object schema. Use `.safeParse` for typed branching, `.parse` to throw `ZodError` on failure.
|
||||
- `type CreateDeliveryEventInputSchema = z.infer<typeof createDeliveryEventInputSchema>` — convenience alias for the parsed object shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema uses `z.string().min(1)` on `tenantId`/`contactId`/`channel`/`messageId` because empty strings are a frequent symptom of upstream serialisation bugs (e.g. a Mailgun webhook with a missing recipient). Allowing them through silently would make every downstream aggregator carry "where the contact is" defensive code. The `metadata` field accepts `Record<string, unknown>` so providers can ship arbitrary payload blobs.
|
||||
@@ -0,0 +1,59 @@
|
||||
# tracking-link
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TrackingLinkWrapper` rewrites outbound URLs with click-tracking redirects. Each wrapped URL points at the tracking host's `clickPath` (default `/track/click`) with an opaque token carrying `(originalUrl, messageId, contactId, campaignId)`. The receiving handler decodes the token, records a `clicked` event via `DeliveryTracker.track`, then 302s to `originalUrl`.
|
||||
|
||||
`wrapAllLinks` rewrites every `<a href="…">` in an HTML body so a single call before send-time turns every link clickable-trackable. The HTML regex is intentionally simple: it matches both single and double quotes and preserves any attributes around the `href`, but does not handle script-injected anchors or anchors split across CDATA boundaries — callers needing those edge cases should pre-process the HTML through a real DOM parser.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `baseUrl`, `secret`, and `clickPath` (default `/track/click`).
|
||||
- `wrapUrl` for a single URL.
|
||||
- `wrapAllLinks` for bulk rewrites across an HTML body, case-insensitive `href` matching.
|
||||
- `parseClickToken` decodes back to `ClickTrackingTokenPayload` and throws `DeliveryTrackingError(INVALID_TOKEN)` on failure.
|
||||
- Shares the token codec with `TrackingPixelGenerator` so OTel issuance/rejection counters are consistent.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TrackingLinkWrapper } from "@suaveplan/delivery-tracking";
|
||||
|
||||
const wrap = new TrackingLinkWrapper({
|
||||
baseUrl: "https://t.example.com",
|
||||
secret: process.env.TRACKING_SECRET!,
|
||||
});
|
||||
const click = wrap.wrapUrl("https://example.com/landing", "m1", "u1", "c1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TrackingLinkWrapper } from "@suaveplan/delivery-tracking";
|
||||
|
||||
const wrap = new TrackingLinkWrapper({
|
||||
baseUrl: "https://t.example.com",
|
||||
secret: process.env.TRACKING_SECRET!,
|
||||
clickPath: "/r",
|
||||
});
|
||||
const htmlOut = wrap.wrapAllLinks(htmlIn, "m1", "u1", "c1");
|
||||
|
||||
// In the redirect handler:
|
||||
const { originalUrl } = wrap.parseClickToken(req.query.t as string);
|
||||
res.redirect(302, originalUrl);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TrackingLinkWrapper` — `constructor(config: TrackingConfig)`.
|
||||
- `wrapUrl(originalUrl, messageId, contactId, campaignId?)` → `string`.
|
||||
- `parseClickToken(token)` → `ClickTrackingTokenPayload`.
|
||||
- `wrapAllLinks(html, messageId, contactId, campaignId?)` → `string`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The regex `/<a\s([^>]*?)href=["']([^"']+)["']([^>]*)>/gi` captures the attributes before and after `href` so they survive the rewrite. Anchors whose `href` is double-encoded or contains nested quotes will not match — that is intentional: the regex is a fast-path for the typical email template, and any caller with adversarial HTML should run the body through a DOM parser first and then call `wrapUrl` per anchor.
|
||||
@@ -0,0 +1,61 @@
|
||||
# token-codec
|
||||
|
||||
## Purpose
|
||||
|
||||
`token-codec` is the shared XOR + base64url encoder/decoder used by both `TrackingPixelGenerator` (open tracking) and `TrackingLinkWrapper` (click tracking). Factoring it out keeps the two generators byte-compatible (a token issued by one decodes via the other when the secret matches) and centralises the OTel instrumentation for token issuance and rejection.
|
||||
|
||||
The codec is **not cryptographic** — it provides URL-safe opaque encoding of small JSON payloads to keep tracking URLs short and free of forbidden characters. Callers needing tamper resistance must layer an HMAC (or another signed envelope) on top.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure functions: `encodeToken(payload, secret)` and `decodeToken<T>(token, secret)`.
|
||||
- URL-safe base64 alphabet (`-` and `_` replacing `+` and `/`, padding stripped).
|
||||
- Bidirectional XOR cipher keyed by the user-supplied secret.
|
||||
- Decode failures emit `DeliveryTrackingError(INVALID_TOKEN)` and increment the rejection counter.
|
||||
- Issuance and rejection counters are emitted via `getDeliveryTrackingInstruments()`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
encodeToken,
|
||||
decodeToken,
|
||||
} from "@suaveplan/delivery-tracking/tracking-pixel";
|
||||
|
||||
const token = encodeToken({ messageId: "m1", contactId: "u1" }, "secret");
|
||||
const payload = decodeToken<{ messageId: string; contactId: string }>(
|
||||
token,
|
||||
"secret"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
decodeToken,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
|
||||
try {
|
||||
decodeToken(req.query.t, process.env.TRACKING_SECRET!);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof DeliveryTrackingError &&
|
||||
e.code === DELIVERY_TRACKING_CODES.INVALID_TOKEN
|
||||
) {
|
||||
return res.status(400).send("invalid token");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeToken(payload: unknown, secret: string): string` — returns a URL-safe base64 token. Increments `delivery_tracking.token_issued.total` with `kind: "encode"`.
|
||||
- `decodeToken<T>(token: string, secret: string): T` — throws `DeliveryTrackingError(INVALID_TOKEN)` on failure. Increments `delivery_tracking.token_rejected.total` on the failure path.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The XOR cipher is keyed by repeatedly cycling through `secret.charCodeAt(i % secret.length)`. Both `encodeToken` and `decodeToken` rely on Node's `Buffer` for base64 conversion (the package is server-tier per the rollup `external` list — `node:` is allowed). The instruments are resolved lazily on each call so `metrics.setGlobalMeterProvider` can install a custom provider before the first call without missing the counter increment.
|
||||
@@ -0,0 +1,66 @@
|
||||
# tracking-pixel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TrackingPixelGenerator` produces and parses the open-tracking pixel URLs embedded in outbound HTML emails. The pixel URL contains an opaque token carrying the `(messageId, contactId, campaignId)` triple so the receiving webhook can attribute the open directly without an extra database lookup.
|
||||
|
||||
The generator is symmetric: the same instance that issued a pixel URL can parse the resulting token. Callers can also use `decodeToken` from `tracking-pixel/token-codec` directly when the URL has already been split into its token component (e.g. inside a Hono handler reading `req.query.t`).
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `baseUrl`, `secret`, and optional `pixelPath` (default `/track/open`).
|
||||
- Trailing slashes in `baseUrl` are trimmed so the URL never contains a double slash.
|
||||
- `generatePixelHtml(url)` emits a hidden 1×1 `<img>` element suitable for direct inclusion in an email template.
|
||||
- Round-trip safe with `parsePixelToken`.
|
||||
- Shares the `tracking-pixel/token-codec` module with `TrackingLinkWrapper`, so OTel counter semantics are consistent.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TrackingPixelGenerator } from "@suaveplan/delivery-tracking";
|
||||
|
||||
const gen = new TrackingPixelGenerator({
|
||||
baseUrl: "https://t.example.com",
|
||||
secret: process.env.TRACKING_SECRET!,
|
||||
});
|
||||
const url = gen.generatePixelUrl("m1", "u1", "c1");
|
||||
const html = gen.generatePixelHtml(url);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TrackingPixelGenerator } from "@suaveplan/delivery-tracking";
|
||||
|
||||
const gen = new TrackingPixelGenerator({
|
||||
baseUrl: "https://t.example.com/",
|
||||
secret: process.env.TRACKING_SECRET!,
|
||||
pixelPath: "/p",
|
||||
});
|
||||
// Inside the open-webhook handler:
|
||||
const token = new URL(req.url).searchParams.get("t")!;
|
||||
const { messageId, contactId, campaignId } = gen.parsePixelToken(token);
|
||||
await tracker.track({
|
||||
tenantId: "t1",
|
||||
campaignId,
|
||||
contactId,
|
||||
channel: "email",
|
||||
eventType: "opened",
|
||||
messageId,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TrackingPixelGenerator` — `constructor(config: TrackingConfig)`.
|
||||
- `generatePixelUrl(messageId, contactId, campaignId?)` → `string`.
|
||||
- `parsePixelToken(token)` → `TrackingTokenPayload`. Throws `DeliveryTrackingError(INVALID_TOKEN)` on failure.
|
||||
- `generatePixelHtml(url)` → `string` — hidden 1×1 `<img>` tag.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default pixel path is `/track/open`; the default click path on the link wrapper is `/track/click`. Both default values can be changed via `TrackingConfig` so a host can route to existing infrastructure without rewriting the SDK. The HTML snippet uses `display:none;border:0;` rather than `visibility:hidden` so it does not consume layout space in clients that render hidden images.
|
||||
@@ -0,0 +1,74 @@
|
||||
# webhook-parser
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`WebhookEventParser` normalises provider-specific delivery webhooks into `CreateDeliveryEventInput[]` ready for `DeliveryTracker.track`. Built-in parsers cover SendGrid, Mailgun, AWS SES, and a `generic` shape that downstream services or SDKs can target directly. The registry is open: callers can pass custom parsers at construction time or register them at runtime via `registerProvider`.
|
||||
|
||||
The parser intentionally accepts loose `WebhookPayload` shapes (single objects or arrays) because providers vary — Mailgun nests its payload under `event-data`, SES wraps its JSON under `Message` from the SNS envelope, and SendGrid posts a flat array of events. Each adapter pulls only the fields the package needs and stuffs the original payload into `metadata` for downstream forensics.
|
||||
|
||||
## Features
|
||||
|
||||
- Four built-in providers: `sendgrid`, `mailgun`, `ses`, `generic`.
|
||||
- Unknown providers raise `DeliveryTrackingError(UNSUPPORTED_PROVIDER)`.
|
||||
- Pluggable `ProviderParser` interface — custom providers receive the same `WebhookPayload` and return the same `CreateDeliveryEventInput[]` shape.
|
||||
- OTel counter `delivery_tracking.webhook_parsed.total` attributed by `provider`.
|
||||
- Unknown event names within a known provider are filtered out (returns an empty event slot) rather than throwing — providers add new events frequently and the package should not refuse the whole batch over one unfamiliar entry.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DeliveryTracker,
|
||||
InMemoryDeliveryEventStore,
|
||||
WebhookEventParser,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
|
||||
const parser = new WebhookEventParser();
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
|
||||
|
||||
const events = parser.parse("sendgrid", { body: req.body });
|
||||
await tracker.trackBatch(events);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
type ProviderParser,
|
||||
WebhookEventParser,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
|
||||
const postmark: ProviderParser = {
|
||||
parse: ({ body }) => {
|
||||
const arr = Array.isArray(body) ? body : [body];
|
||||
return arr.map((evt) => ({
|
||||
tenantId: (evt.tenantId as string) ?? "default",
|
||||
contactId: evt.Recipient as string,
|
||||
channel: "email",
|
||||
eventType: "delivered",
|
||||
messageId: evt.MessageID as string,
|
||||
metadata: { provider: "postmark", raw: evt },
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const parser = new WebhookEventParser({ postmark });
|
||||
parser.registerProvider("brevo", { parse: () => [] });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface WebhookPayload` — `body: Record | Record[]`, optional `headers`.
|
||||
- `interface ProviderParser` — `parse(payload: WebhookPayload): CreateDeliveryEventInput[]`.
|
||||
- `class WebhookEventParser` — `constructor(custom?: Record<string, ProviderParser>)`.
|
||||
- `parse(provider, payload)` → `CreateDeliveryEventInput[]`. Throws `DeliveryTrackingError(UNSUPPORTED_PROVIDER)` for unknown providers.
|
||||
- `registerProvider(name, parser)` → `void` — add or replace a provider at runtime.
|
||||
- `type BuiltinWebhookProvider = "sendgrid" | "mailgun" | "ses" | "generic"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`asObject` and `extractString` are defensive helpers: provider payloads are often shaped by JSON unmarshalling and may contain `null` or `undefined` where strings are expected. Returning `""` instead of throwing keeps the parser tolerant of partial payloads while letting Zod boundary validation in `DeliveryTracker.track` reject any resulting empty `tenantId`/`contactId`/`messageId` downstream. The `metadata` field always carries the original payload under `raw` so callers can audit what the provider actually sent.
|
||||
@@ -0,0 +1,67 @@
|
||||
# delivery-tracking-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DeliveryTrackingError` is the stable typed error class raised by every code path inside `@suaveplan/delivery-tracking`. It extends `@suaveplan/error`'s `SuaveplanError` with a discriminant `code` drawn from the `DELIVERY_TRACKING_CODES` registry, giving consumers `instanceof` + code branching without inspecting the i18n-keyed `message` string.
|
||||
|
||||
The codes are stable across versions: each entry exists because at least one production code path can produce it (lifecycle dedupe, store I/O, token parsing, webhook routing). Consumers can build exhaustive switches over the union derived from the registry and rely on TypeScript's exhaustiveness check to flag any future addition.
|
||||
|
||||
## Features
|
||||
|
||||
- Code registry generated via `defineCodes("DELIVERY_TRACKING", […])`.
|
||||
- Five stable codes: `INVALID_INPUT`, `INVALID_TOKEN`, `UNSUPPORTED_PROVIDER`, `STORE_FAILURE`, `QUERY_FAILURE`.
|
||||
- Subclass of `SuaveplanError` preserving cause chains, context bags, and i18n message keys.
|
||||
- Type-safe `DeliveryTrackingCode` union derived from the registry — no manual sync.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
} from "@suaveplan/delivery-tracking/errors";
|
||||
|
||||
throw new DeliveryTrackingError(
|
||||
DELIVERY_TRACKING_CODES.INVALID_INPUT,
|
||||
"error.delivery_tracking.invalid_input",
|
||||
{ context: { field: "tenantId" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
type DeliveryTrackingCode,
|
||||
} from "@suaveplan/delivery-tracking/errors";
|
||||
|
||||
function classify(err: unknown): "client" | "server" | "other" {
|
||||
if (!(err instanceof DeliveryTrackingError)) return "other";
|
||||
const code: DeliveryTrackingCode = err.code;
|
||||
switch (code) {
|
||||
case DELIVERY_TRACKING_CODES.INVALID_INPUT:
|
||||
case DELIVERY_TRACKING_CODES.INVALID_TOKEN:
|
||||
case DELIVERY_TRACKING_CODES.UNSUPPORTED_PROVIDER:
|
||||
return "client";
|
||||
case DELIVERY_TRACKING_CODES.STORE_FAILURE:
|
||||
case DELIVERY_TRACKING_CODES.QUERY_FAILURE:
|
||||
return "server";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DELIVERY_TRACKING_CODES` — frozen record of stable codes, each value prefixed `DELIVERY_TRACKING.*`.
|
||||
- `type DeliveryTrackingCode` — union of the registry's values.
|
||||
- `class DeliveryTrackingError extends SuaveplanError<DeliveryTrackingCode>` — accepts `(code, messageKey, options?)` and exposes `code`, `messageKey`, `cause`, and `context` on the resulting instance.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is `defineCodes("DELIVERY_TRACKING", [...] as const)`; the `as const` is what keeps the resulting object literal-typed so the union can be derived. The class only widens the protected constructor to public and sets `this.name = "DeliveryTrackingError"` — every other behaviour (cause chains, redaction, OTel span enrichment) is inherited from `SuaveplanError`.
|
||||
@@ -0,0 +1,68 @@
|
||||
# instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`getDeliveryTrackingInstruments` lazily constructs and caches the OTel `Counter` set used by every production code path in `@suaveplan/delivery-tracking`. Centralising the instrument names here keeps metric semantics consistent and makes the package observable via any `MeterProvider` configured by the host application.
|
||||
|
||||
The instruments are bound to a `Meter` named `@suaveplan/delivery-tracking`. Tests install a real `InMemoryMetricExporter` + `MeterProvider` in `tests/setup/polyfills.ts` and assert counter totals using the exporter's snapshot — no mocking is involved, satisfying CLAUDE.md rule 2 ("no mocks").
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy singleton; instruments are created on first call and reused.
|
||||
- `resetDeliveryTrackingInstruments()` clears the cache for test isolation.
|
||||
- Five counters with stable names: `delivery_tracking.tracked.total`, `delivery_tracking.deduped.total`, `delivery_tracking.webhook_parsed.total`, `delivery_tracking.token_issued.total`, `delivery_tracking.token_rejected.total`.
|
||||
- All counters carry attribute hints (event_type, channel, provider, kind) that callers should pass when incrementing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getDeliveryTrackingInstruments } from "@suaveplan/delivery-tracking/otel";
|
||||
|
||||
const i = getDeliveryTrackingInstruments();
|
||||
i.trackedCounter.add(1, { event_type: "sent" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
InMemoryMetricExporter,
|
||||
AggregationTemporality,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
getDeliveryTrackingInstruments,
|
||||
resetDeliveryTrackingInstruments,
|
||||
} from "@suaveplan/delivery-tracking/otel";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
metrics.setGlobalMeterProvider(
|
||||
new MeterProvider({
|
||||
readers: [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
resetDeliveryTrackingInstruments();
|
||||
const i = getDeliveryTrackingInstruments();
|
||||
i.webhookParsedCounter.add(3, { provider: "sendgrid" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getDeliveryTrackingInstruments(): DeliveryTrackingInstruments` — returns the cached set; constructs on first call.
|
||||
- `resetDeliveryTrackingInstruments(): void` — clears the cache; intended for test setup.
|
||||
- `interface DeliveryTrackingInstruments` — `meter`, `trackedCounter`, `dedupedCounter`, `webhookParsedCounter`, `tokenIssuedCounter`, `tokenRejectedCounter`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lazy-init pattern is necessary because the global `MeterProvider` is usually set after module import. Constructing the counters at module top-level would bind them to the no-op default provider; constructing inside the accessor lets the test harness install its provider in `setupFiles` before any source module imports.
|
||||
@@ -0,0 +1,58 @@
|
||||
# memory-store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`memory-store` defines the `DeliveryEventStore` interface that every persistence backend must satisfy, ships the deterministic `InMemoryDeliveryEventStore` implementation used in development and tests, and exposes the pure `computeStats` helper that turns a list of events into a `DeliveryStats` rollup.
|
||||
|
||||
The in-memory store is intentionally simple — events are appended to an array and queried with predicate filters. It is the source of truth for the contract the SurrealDB-backed store must mirror.
|
||||
|
||||
## Features
|
||||
|
||||
- `DeliveryEventStore` interface covering record + getById/Campaign/Contact/MessageId, counts, stats, and existence.
|
||||
- `InMemoryDeliveryEventStore` implementation with full query support (`eventType`, `channel`, `from`, `to`, `limit`, `offset`).
|
||||
- Pure `computeStats(events)` exposed for reuse by other stores (SurrealDB, future Postgres backend).
|
||||
- Zero deps — only the package's own types.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
InMemoryDeliveryEventStore,
|
||||
} from "@suaveplan/delivery-tracking/stores/memory";
|
||||
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
await store.record({
|
||||
id: "evt-1",
|
||||
tenantId: "t1",
|
||||
contactId: "u1",
|
||||
channel: "email",
|
||||
eventType: "sent",
|
||||
messageId: "m1",
|
||||
metadata: {},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
const stats = await store.getStatsByCampaign("c1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { computeStats } from "@suaveplan/delivery-tracking/stores/memory";
|
||||
|
||||
const stats = computeStats(myEvents);
|
||||
console.log(stats.deliveryRate, stats.bounceRate);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface DeliveryEventStore` — `record`, `getById`, `getByCampaign`, `getByContact`, `getByMessageId`, `countByType`, `getStatsByCampaign`, `exists`.
|
||||
- `class InMemoryDeliveryEventStore implements DeliveryEventStore` — array-backed reference implementation.
|
||||
- `function computeStats(events: DeliveryEvent[]): DeliveryStats` — counts every event type and computes percentage rates with safe zero-division handling.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Rates are returned as percentages rounded to two decimals via `Number((n/d*100).toFixed(2))`. When the denominator is zero the rate is `0` (not `NaN`). The store's `applyQuery` helper supports paging via `offset` + `limit` and applies all four query filters as `Array.filter` chains — performance is O(n) per query, which is acceptable for in-memory usage but is one of the reasons the SurrealDB backend exists for production loads.
|
||||
@@ -0,0 +1,71 @@
|
||||
# surreal-store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SurrealDeliveryEventStore` is the SurrealDB-backed implementation of the `DeliveryEventStore` contract. It uses positional SurQL via `SurrealAdapter.query(sql, params)` — no typed-CRUD wrappers — so the SQL is auditable, the parameter binding is explicit, and adapter exceptions are translated into stable `DeliveryTrackingError` codes (`STORE_FAILURE` for writes, `QUERY_FAILURE` for reads).
|
||||
|
||||
The schema mapping is intentionally direct: SurrealDB rows use snake_case (`message_id`, `event_type`, `campaign_id`, …) and `rowToEvent` rehydrates them into the camelCase domain shape. The aggregation rollup is computed in-process via `computeStats`, keeping the SurrealDB driver free of percentage arithmetic.
|
||||
|
||||
## Features
|
||||
|
||||
- Positional SurQL queries; no driver-specific typed CRUD.
|
||||
- All errors wrapped in `DeliveryTrackingError(STORE_FAILURE | QUERY_FAILURE)` with the originating cause preserved.
|
||||
- Customisable table name via the second constructor argument.
|
||||
- Snake-case row mapping with `Date` rehydration from string timestamps.
|
||||
- `getByCampaign`/`getByContact` accept filters + paging that are threaded into the SQL (`LIMIT`, `START`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { SurrealDeliveryEventStore } from "@suaveplan/delivery-tracking/stores/surreal";
|
||||
|
||||
const adapter = new SurrealAdapter({
|
||||
url: "ws://localhost:8000",
|
||||
namespace: "app",
|
||||
database: "main",
|
||||
username: "root",
|
||||
password: "root",
|
||||
});
|
||||
await adapter.connect();
|
||||
const store = new SurrealDeliveryEventStore(adapter);
|
||||
const events = await store.getByCampaign("c1", { limit: 50 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
SurrealDeliveryEventStore,
|
||||
} from "@suaveplan/delivery-tracking";
|
||||
|
||||
const store = new SurrealDeliveryEventStore(adapter, "tenant_a_events");
|
||||
try {
|
||||
await store.record(event);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof DeliveryTrackingError &&
|
||||
e.code === DELIVERY_TRACKING_CODES.STORE_FAILURE
|
||||
) {
|
||||
logger.warn("delivery store unavailable", { cause: e.cause });
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SurrealDeliveryEventStore implements DeliveryEventStore` — `constructor(db, table = "delivery_events")`.
|
||||
- `record(event)` → `Promise<void>` — INSERTs the event; throws `DeliveryTrackingError(STORE_FAILURE)` on adapter exception.
|
||||
- `getById`, `getByCampaign`, `getByContact`, `getByMessageId`, `countByType`, `getStatsByCampaign`, `exists` — every read wraps adapter failures in `DeliveryTrackingError(QUERY_FAILURE)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`firstResult<T>()` collapses the SurQL multi-statement result envelope to the first OK statement's rows; non-OK statuses and null results return an empty array so callers never need to inspect raw driver output. Optional fields (`campaignId`, `ip`, `userAgent`) are persisted as `null` rather than omitted so `SELECT *` returns a stable schema. The `GROUP ALL` aggregation in `countByType`/`exists` is intentional — it forces SurrealDB to return a single bucket regardless of row count.
|
||||
@@ -0,0 +1,74 @@
|
||||
# @suaveplan/dmarc - DMARC report parser — parses aggregate (RUA) and forensic (RUF) DMARC XML reports into typed structures
|
||||
|
||||
## Summary
|
||||
|
||||
DMARC report parser for `@suaveplan` — parses aggregate (RUA) and forensic (RUF) DMARC XML reports into validated, strongly-typed structures. Accepts raw XML strings or gzip-compressed binary blobs.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [gzip](./src/decoders/gzip.md) — Provides gzip decompression and input-decoding utilities for `@suaveplan/dmarc`. DMARC aggregate and forensic reports a…
|
||||
- [xml](./src/decoders/xml.md) — Wraps `fast-xml-parser` with a fixed, strict configuration tuned for DMARC report XML. The module isolates all parser c…
|
||||
- [errors](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [dmarc-error](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [dmarc-otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [aggregate](./src/parsers/aggregate.md) — Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into val…
|
||||
- [forensic](./src/parsers/forensic.md) — Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify doma…
|
||||
<!-- end:auto-docs --> - [dmarc-error](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [dmarc-otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [aggregate](./src/parsers/aggregate.md) — Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into val…
|
||||
- [forensic](./src/parsers/forensic.md) — Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify doma…
|
||||
- [dmarc-error](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [dmarc-otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [aggregate](./src/parsers/aggregate.md) — Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into val…
|
||||
- [forensic](./src/parsers/forensic.md) — Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify doma…
|
||||
- [dmarc-error](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [dmarc-otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [aggregate](./src/parsers/aggregate.md) — Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into val…
|
||||
- [forensic](./src/parsers/forensic.md) — Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify doma…
|
||||
- [dmarc-error](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dmarc-otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [aggregate](./src/parsers/aggregate.md) — Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into val…
|
||||
- [forensic](./src/parsers/forensic.md) — Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify doma…
|
||||
|
||||
## Packages
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [`src/parsers/aggregate.ts`](src/parsers/aggregate.md) | Parse DMARC aggregate (RUA) reports |
|
||||
| [`src/parsers/forensic.ts`](src/parsers/forensic.md) | Parse DMARC forensic (RUF) reports |
|
||||
| [`src/decoders/gzip.ts`](src/decoders/gzip.md) | Gzip detection and decompression |
|
||||
| [`src/decoders/xml.ts`](src/decoders/xml.md) | XML parsing via fast-xml-parser |
|
||||
| [`src/errors/dmarc-error.ts`](src/errors/dmarc-error.md) | Typed error class and code registry |
|
||||
| [`src/otel/dmarc-otel.ts`](src/otel/dmarc-otel.md) | OpenTelemetry metrics |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { parseDmarcAggregate, parseDmarcForensic } from "@suaveplan/dmarc";
|
||||
|
||||
// Parse an aggregate report from an XML string
|
||||
const aggregate = parseDmarcAggregate(xmlString);
|
||||
console.log(aggregate.reportMetadata.orgName);
|
||||
console.log(aggregate.record[0].row.sourceIp);
|
||||
|
||||
// Parse from gzip-compressed bytes (auto-detected)
|
||||
const { gzipSync, strToU8 } = await import("fflate");
|
||||
const compressed = gzipSync(strToU8(xmlString));
|
||||
const report = parseDmarcAggregate(compressed);
|
||||
|
||||
// Parse a forensic report
|
||||
const forensic = parseDmarcForensic(forensicXml);
|
||||
console.log(forensic.feedback.policyPublished.domain);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```ts
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc";
|
||||
|
||||
try {
|
||||
parseDmarcAggregate(input);
|
||||
} catch (err) {
|
||||
if (err instanceof DmarcError) {
|
||||
switch (err.code) {
|
||||
case DMARC_CODES.PARSE_ERROR: // XML could not be parsed
|
||||
case DMARC_CODES.INVALID_REPORT: // Schema validation failed
|
||||
case DMARC_CODES.UNSUPPORTED_ENCODING: // Gzip decompression failed
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# gzip
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides gzip decompression and input-decoding utilities for `@suaveplan/dmarc`. DMARC aggregate and forensic reports are commonly delivered as gzip-compressed XML attachments (`.xml.gz`). This module abstracts the decompression step so that parsers can accept both raw XML strings and compressed binary blobs through a single entry point without depending on Node.js stream APIs or native bindings.
|
||||
|
||||
## Features
|
||||
|
||||
- `isGzip(bytes)` — magic-byte probe to identify gzip streams before attempting decompression
|
||||
- `decompressGzip(bytes)` — synchronous gunzip via `fflate.gunzipSync` + `strFromU8` for zero-dependency UTF-8 conversion
|
||||
- `decodeInput(input)` — unified entry point: pass-through for strings, auto-gunzip for gzip blobs, plain UTF-8 decode for non-gzip bytes
|
||||
- Structured errors via `DmarcError` with code `DMARC.UNSUPPORTED_ENCODING` on decompression failure
|
||||
- No Node.js built-ins (`node:zlib`, `node:fs`) — pure `fflate` implementation works in browsers and edge runtimes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeInput } from "@suaveplan/dmarc/decoders/gzip";
|
||||
|
||||
// Accepts a plain XML string
|
||||
const xml1 = decodeInput("<feedback>...</feedback>");
|
||||
|
||||
// Accepts a gzip-compressed Uint8Array
|
||||
const gzippedBytes = new Uint8Array([0x1f, 0x8b, /* ... */]);
|
||||
const xml2 = decodeInput(gzippedBytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { isGzip, decompressGzip, decodeInput } from "@suaveplan/dmarc/decoders/gzip";
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc/errors";
|
||||
|
||||
async function fetchAndDecode(url: string): Promise<string> {
|
||||
const resp = await fetch(url);
|
||||
const bytes = new Uint8Array(await resp.arrayBuffer());
|
||||
|
||||
if (isGzip(bytes)) {
|
||||
// Explicit probe — useful for logging / metrics before decode
|
||||
console.log("Input is gzip-compressed, decompressing…");
|
||||
return decompressGzip(bytes);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
// Error handling
|
||||
try {
|
||||
decodeInput(new Uint8Array([0x1f, 0x8b, 0x00])); // truncated gzip
|
||||
} catch (err) {
|
||||
if (err instanceof DmarcError && err.code === DMARC_CODES.UNSUPPORTED_ENCODING) {
|
||||
console.error("Decompression failed:", err.message, err.cause);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `isGzip(bytes: Uint8Array): boolean`
|
||||
|
||||
Returns `true` if `bytes` starts with the two gzip magic bytes (`0x1f 0x8b`). Returns `false` for any input shorter than 2 bytes.
|
||||
|
||||
### `decompressGzip(bytes: Uint8Array): string`
|
||||
|
||||
Decompresses `bytes` synchronously via `fflate.gunzipSync` and converts the result to a UTF-8 string using `fflate.strFromU8`.
|
||||
|
||||
**Throws:** `DmarcError` with code `DMARC.UNSUPPORTED_ENCODING` if decompression fails.
|
||||
|
||||
### `decodeInput(input: string | Uint8Array): string`
|
||||
|
||||
Unified decoder: returns strings unchanged, decompresses gzip bytes, and UTF-8 decodes non-gzip bytes.
|
||||
|
||||
**Throws:** `DmarcError` with code `DMARC.UNSUPPORTED_ENCODING` if gzip decompression fails.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`fflate.gunzipSync` is chosen over `DecompressionStream` (Streams API) because the synchronous path avoids async complexity in callers and works identically across Node.js, Bun, Deno, and browser environments. `strFromU8` is preferred over `TextDecoder` on the decompressed output because it avoids an extra allocation when fflate has already produced a UTF-8 buffer.
|
||||
@@ -0,0 +1,69 @@
|
||||
# xml
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps `fast-xml-parser` with a fixed, strict configuration tuned for DMARC report XML. The module isolates all parser configuration in one place so that both aggregate and forensic parsers produce a consistently shaped raw object tree, reducing the surface area for subtle parsing divergences between the two report types.
|
||||
|
||||
## Features
|
||||
|
||||
- Shared `XMLParser` instance configuration: no-op attribute prefix, numeric tag-value coercion, whitespace trimming
|
||||
- `isArray` hook forces `<record>`, `<dkim>`, `<spf>`, and `<error>` nodes to always deserialise as arrays, preventing single-item vs. multi-item inconsistency
|
||||
- Structured errors via `DmarcError` with code `DMARC.PARSE_ERROR` on any parse failure
|
||||
- Pure function — stateless, no module-level side effects
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseXml } from "@suaveplan/dmarc/decoders/xml";
|
||||
|
||||
const raw = parseXml(`
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example</org_name>
|
||||
</report_metadata>
|
||||
</feedback>
|
||||
`);
|
||||
console.log((raw as any).feedback.version); // "1.0"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseXml } from "@suaveplan/dmarc/decoders/xml";
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc/errors";
|
||||
|
||||
function safeParse(xml: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return parseXml(xml);
|
||||
} catch (err) {
|
||||
if (err instanceof DmarcError && err.code === DMARC_CODES.PARSE_ERROR) {
|
||||
console.error("XML parse failed:", err.message);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Single <record> is still an array
|
||||
const result = parseXml("<feedback><record><row/></record></feedback>");
|
||||
const records = (result as any).feedback.record; // Array, length 1
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseXml(xml: string): Record<string, unknown>`
|
||||
|
||||
Parses a UTF-8 XML string into a raw nested JS object.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `xml` — Well-formed UTF-8 XML string.
|
||||
|
||||
**Returns:** Raw `Record<string, unknown>` mirroring the XML element hierarchy.
|
||||
|
||||
**Throws:** `DmarcError` with code `DMARC.PARSE_ERROR` if parsing fails or the parser returns a non-object value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`fast-xml-parser` is used in preference to DOM-based parsers (e.g. `DOMParser`) because it works in all JavaScript environments — including Node.js and Bun — without requiring a DOM polyfill. The `isArray` hook is critical for DMARC reports: RFC 7489 allows a single `<record>` element, and without forcing array mode a one-record report would produce an object where `record` is a plain object rather than a length-1 array, breaking downstream mapping code. Numeric coercion (`parseTagValue: true`) handles the `<begin>`, `<end>`, `<count>`, and `<pct>` fields that appear as integer text nodes.
|
||||
@@ -0,0 +1,84 @@
|
||||
# dmarc-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the lightweight error class and stable code registry for `@suaveplan/dmarc`. Because this package carries no `@suaveplan/error` runtime dependency (it is a leaf package with no `@suaveplan/*` runtime deps), it provides its own self-contained error substrate that gives callers the same discriminated-error contract: a strongly-typed `code` field for `switch`/`instanceof` discrimination, plus an idiomatic `cause` chain via the built-in `ErrorOptions`.
|
||||
|
||||
## Features
|
||||
|
||||
- `DMARC_CODES` — three stable string-literal constants (`DMARC.PARSE_ERROR`, `DMARC.INVALID_REPORT`, `DMARC.UNSUPPORTED_ENCODING`)
|
||||
- `DmarcCode` — union type of all valid code values
|
||||
- `DmarcError` — `Error` subclass with a `readonly code: DmarcCode` field and preserved `name`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc/errors";
|
||||
|
||||
try {
|
||||
// ... parse logic
|
||||
} catch (cause) {
|
||||
throw new DmarcError(DMARC_CODES.PARSE_ERROR, "Failed to parse XML", { cause });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DmarcError, DMARC_CODES, type DmarcCode } from "@suaveplan/dmarc/errors";
|
||||
|
||||
function handleDmarcError(err: unknown): never {
|
||||
if (err instanceof DmarcError) {
|
||||
switch (err.code) {
|
||||
case DMARC_CODES.PARSE_ERROR:
|
||||
console.error("XML parse failure:", err.message, err.cause);
|
||||
break;
|
||||
case DMARC_CODES.INVALID_REPORT:
|
||||
console.error("Schema validation failed:", err.message);
|
||||
break;
|
||||
case DMARC_CODES.UNSUPPORTED_ENCODING:
|
||||
console.error("Cannot decode input:", err.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DMARC_CODES`
|
||||
|
||||
`{ PARSE_ERROR: "DMARC.PARSE_ERROR"; INVALID_REPORT: "DMARC.INVALID_REPORT"; UNSUPPORTED_ENCODING: "DMARC.UNSUPPORTED_ENCODING" }`
|
||||
|
||||
Immutable code registry. Use these constants instead of raw strings so that typos produce type errors.
|
||||
|
||||
### `DmarcCode`
|
||||
|
||||
`"DMARC.PARSE_ERROR" | "DMARC.INVALID_REPORT" | "DMARC.UNSUPPORTED_ENCODING"`
|
||||
|
||||
Union of all valid code values for use in signatures and `switch` exhaustiveness checks.
|
||||
|
||||
### `DmarcError`
|
||||
|
||||
`class DmarcError extends Error`
|
||||
|
||||
**Constructor:** `new DmarcError(code: DmarcCode, message: string, options?: ErrorOptions)`
|
||||
|
||||
- `code` — one of the `DMARC_CODES` values
|
||||
- `message` — human-readable description
|
||||
- `options.cause` — original error for cause-chain logging
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `code: DmarcCode` — readonly; set during construction
|
||||
- `name: string` — always `"DmarcError"`
|
||||
|
||||
**Throws:** nothing
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The absence of `@suaveplan/error` as a runtime dependency is intentional: this package is a leaf node in the dependency graph and must remain importable in environments where the broader Suaveplan stack is not installed. The same discriminated-error pattern (stable `code` field, `cause` chain, subclassed `Error`) is preserved so that tooling and callers that understand `@suaveplan/error` conventions can handle these errors uniformly.
|
||||
@@ -0,0 +1,69 @@
|
||||
# dmarc-otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides OpenTelemetry metric instruments for `@suaveplan/dmarc`. It gives operators visibility into parse throughput and latency without coupling the parse logic itself to observability concerns. All instruments are created lazily by the OTel SDK's no-op implementation, so importing this module in an environment without an OTel SDK installed is always safe — the calls become no-ops.
|
||||
|
||||
## Features
|
||||
|
||||
- `dmarcParseTotal` — counter tracking total parse invocations by `kind` and `status`
|
||||
- `dmarcParseDurationMs` — histogram recording parse latency in milliseconds by `kind`
|
||||
- `recordParse()` — convenience wrapper that updates both instruments in one call
|
||||
- Zero runtime dependencies beyond `@opentelemetry/api`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordParse } from "@suaveplan/dmarc/otel/dmarc-otel";
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
// ... parse logic
|
||||
recordParse("aggregate", "success", performance.now() - start);
|
||||
} catch {
|
||||
recordParse("aggregate", "error", performance.now() - start);
|
||||
throw;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { dmarcParseTotal, dmarcParseDurationMs, recordParse } from "@suaveplan/dmarc/otel/dmarc-otel";
|
||||
|
||||
// Direct instrument access for custom attribute sets
|
||||
dmarcParseTotal.add(1, { kind: "aggregate", status: "success", source: "s3" });
|
||||
dmarcParseDurationMs.record(42, { kind: "forensic", source: "email" });
|
||||
|
||||
// Or use the convenience wrapper for standard usage
|
||||
recordParse("forensic", "success", 38);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `dmarcParseTotal`
|
||||
|
||||
`Counter` — incremented once per parse call. Attributes: `kind` (`"aggregate" | "forensic"`), `status` (`"success" | "error"`).
|
||||
|
||||
### `dmarcParseDurationMs`
|
||||
|
||||
`Histogram` — records elapsed parse time in milliseconds. Attributes: `kind` (`"aggregate" | "forensic"`).
|
||||
|
||||
### `recordParse(kind, status, durationMs): void`
|
||||
|
||||
Convenience function that increments `dmarcParseTotal` and records `durationMs` in `dmarcParseDurationMs` in a single call.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `kind` — `"aggregate"` or `"forensic"`
|
||||
- `status` — `"success"` or `"error"`
|
||||
- `durationMs` — elapsed time in milliseconds
|
||||
|
||||
**Returns:** `void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter name is `"@suaveplan/dmarc"` which matches the npm package name, making it trivial to filter metrics by package in dashboards. Histogram bucket boundaries are left to the OTel SDK/exporter configuration rather than hardcoded here, which is the recommended practice per the OTel specification. Importing this module in test environments without an SDK causes zero issues because the OTel API ships a built-in no-op implementation.
|
||||
@@ -0,0 +1,108 @@
|
||||
# aggregate
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into validated, strongly-typed `DmarcAggregateReport` objects. It handles the full pipeline: gzip auto-detection and decompression, XML parsing, snake_case-to-camelCase field mapping, single-vs-multiple element normalisation, and Zod schema validation with structured error reporting.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-detects gzip input via magic-byte probe — no caller configuration needed for compressed reports
|
||||
- Accepts both raw XML strings and `Uint8Array` binary blobs in a single overload
|
||||
- Full snake_case → camelCase mapping for all RFC 7489 field names
|
||||
- Handles variable-arity elements: `<record>`, `<dkim>`, `<spf>` can be single or multiple
|
||||
- Validates the mapped structure with `DmarcAggregateReportSchema` (Zod)
|
||||
- Emits OTel counter and histogram via `recordParse()` on both success and error paths
|
||||
- Wraps all failures in typed `DmarcError` with stable codes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseDmarcAggregate } from "@suaveplan/dmarc/parsers/aggregate";
|
||||
|
||||
const report = parseDmarcAggregate(`
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Corp</org_name>
|
||||
<email>dmarc@example.com</email>
|
||||
<report_id>12345</report_id>
|
||||
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>192.0.2.1</source_ip><count>5</count>
|
||||
<policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
|
||||
</row>
|
||||
<identifiers><header_from>example.com</header_from></identifiers>
|
||||
<auth_results>
|
||||
<dkim><domain>example.com</domain><result>pass</result></dkim>
|
||||
<spf><domain>example.com</domain><result>pass</result></spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
`);
|
||||
|
||||
console.log(report.reportMetadata.orgName); // "Example Corp"
|
||||
console.log(report.record[0].row.count); // 5
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseDmarcAggregate } from "@suaveplan/dmarc/parsers/aggregate";
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc/errors";
|
||||
import { gzipSync, strToU8 } from "fflate";
|
||||
|
||||
// Parse from gzip-compressed bytes
|
||||
const xmlBytes = strToU8(xmlString);
|
||||
const compressed = gzipSync(xmlBytes);
|
||||
const report = parseDmarcAggregate(compressed);
|
||||
|
||||
// Error discrimination
|
||||
try {
|
||||
parseDmarcAggregate("not xml");
|
||||
} catch (err) {
|
||||
if (err instanceof DmarcError) {
|
||||
switch (err.code) {
|
||||
case DMARC_CODES.PARSE_ERROR:
|
||||
// XML could not be parsed
|
||||
break;
|
||||
case DMARC_CODES.INVALID_REPORT:
|
||||
// Parsed but failed schema validation
|
||||
break;
|
||||
case DMARC_CODES.UNSUPPORTED_ENCODING:
|
||||
// Gzip decompression failed
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseDmarcAggregate(input, options?): DmarcAggregateReport`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `input: string | Uint8Array` — raw XML string or binary data (plain or gzip-compressed)
|
||||
- `options.decompress?: boolean` — force gzip decompression even without magic bytes
|
||||
|
||||
**Returns:** `DmarcAggregateReport` — fully validated aggregate report
|
||||
|
||||
**Throws:**
|
||||
|
||||
- `DmarcError(DMARC.PARSE_ERROR)` — XML parse failure
|
||||
- `DmarcError(DMARC.INVALID_REPORT)` — missing required elements or schema validation failure
|
||||
- `DmarcError(DMARC.UNSUPPORTED_ENCODING)` — gzip decompression failure
|
||||
|
||||
### `ParseDmarcAggregateOptions`
|
||||
|
||||
`{ decompress?: boolean }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The parser uses a two-phase approach: structural mapping first (XML → plain object), validation second (Zod). This separation means that when validation fails the error message includes Zod's structured issue list rather than a raw type error, making debugging significantly easier. The `performance.now()` timing wraps the entire pipeline so OTel histograms reflect the true end-to-end cost including decompression and schema validation.
|
||||
@@ -0,0 +1,89 @@
|
||||
# forensic
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify domain owners of individual authentication failures; they share the `<feedback>` / `<report_metadata>` / `<policy_published>` structure with aggregate reports but omit the `<record>` block and may include a raw MIME-formatted `auth_failure_report` element. This module provides the same auto-detect-decompress → parse → map → validate pipeline as the aggregate parser but targets the forensic schema.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-detects gzip input via magic-byte probe
|
||||
- Accepts raw XML strings or `Uint8Array` binary blobs
|
||||
- Maps snake_case XML fields to camelCase typed fields
|
||||
- Surfaces `authFailureReport` string when present in the source XML
|
||||
- Validates against `DmarcForensicReportSchema` (Zod)
|
||||
- Emits OTel counter and histogram for both success and error paths
|
||||
- Wraps all failures in typed `DmarcError` with stable codes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseDmarcForensic } from "@suaveplan/dmarc/parsers/forensic";
|
||||
|
||||
const report = parseDmarcForensic(`
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Corp</org_name>
|
||||
<email>dmarc@example.com</email>
|
||||
<report_id>ruf-001</report_id>
|
||||
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>
|
||||
`);
|
||||
|
||||
console.log(report.feedback.reportMetadata.orgName); // "Example Corp"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseDmarcForensic } from "@suaveplan/dmarc/parsers/forensic";
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc/errors";
|
||||
import { gzipSync, strToU8 } from "fflate";
|
||||
|
||||
const compressed = gzipSync(strToU8(xmlString));
|
||||
const report = parseDmarcForensic(compressed);
|
||||
|
||||
// Access optional auth failure report body
|
||||
if (report.authFailureReport) {
|
||||
console.log("Failure report MIME body:", report.authFailureReport);
|
||||
}
|
||||
|
||||
// Discriminated error handling
|
||||
try {
|
||||
parseDmarcForensic("<bad>xml</bad>");
|
||||
} catch (err) {
|
||||
if (err instanceof DmarcError && err.code === DMARC_CODES.INVALID_REPORT) {
|
||||
// Schema validation failed — missing required elements
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseDmarcForensic(input, options?): DmarcForensicReport`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `input: string | Uint8Array` — raw XML string or binary data
|
||||
- `options.decompress?: boolean` — force gzip decompression
|
||||
|
||||
**Returns:** `DmarcForensicReport` — validated forensic report
|
||||
|
||||
**Throws:**
|
||||
|
||||
- `DmarcError(DMARC.PARSE_ERROR)` — XML parse failure
|
||||
- `DmarcError(DMARC.INVALID_REPORT)` — schema validation failure or missing required elements
|
||||
- `DmarcError(DMARC.UNSUPPORTED_ENCODING)` — gzip decompression failure
|
||||
|
||||
### `ParseDmarcForensicOptions`
|
||||
|
||||
`{ decompress?: boolean }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The forensic parser is structurally identical to the aggregate parser except for the output schema. Both share the same `str`/`num` coercion helpers and the same two-phase (map → validate) approach. In practice, RFC 7489 forensic reports are far less common than aggregate reports and are often transmitted as raw MIME messages rather than standalone XML files; the `authFailureReport` field is therefore optional and surfaced as a plain string for callers to process further if needed.
|
||||
@@ -0,0 +1,61 @@
|
||||
# @suaveplan/domain-normalizer - Normalisation, matching, and alias-canonicalisation for internet domain names
|
||||
|
||||
## Summary
|
||||
|
||||
Normalisation, matching, and alias-canonicalisation for internet domain names. Given heterogeneous URL or domain input — including common mobile/CDN subdomains and 31 built-in alias mappings (e.g. `m.youtube.com → youtube.com`, `old.reddit.com → reddit.com`) — this package produces a canonical registrable domain via `tldts`, strips `www`/`m`/`mobile` prefixes, resolves the alias map, and returns structured `NormalizedDomain` objects. It also provides Levenshtein-based similarity scoring (`computeSimilarity`), fuzzy matching (`matchDomains`), and a `groupDomains` function that clusters a mixed list by canonical root domain. Use this package for deduplicating contact email domains, canonicalising URLs before storage, and building domain-level blocklists. For full entity deduplication across arbitrary fields use `@suaveplan/dedup`; for MIME or URL content-type detection use `@suaveplan/mime`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [aliases](./src/core/aliases/aliases.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [aliases](./src/core/aliases/aliases.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [matcher](./src/core/matcher/matcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [matcher](./src/core/matcher/matcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [normalizer](./src/core/normalizer/normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [normalizer](./src/core/normalizer/normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/domain-normalizer-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [domain-normalizer-error](./src/errors/domain-normalizer-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/domain-normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [domain-normalizer](./src/types/domain-normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [aliases](./src/core/aliases/aliases.md) — <!-- begin:auto-docs --> _(no submodules)_- [matcher](./src/core/matcher/matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [matcher](./src/core/matcher/matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalizer](./src/core/normalizer/normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalizer](./src/core/normalizer/normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/domain-normalizer-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [domain-normalizer-error](./src/errors/domain-normalizer-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/domain-normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [domain-normalizer](./src/types/domain-normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [aliases](./src/core/aliases/aliases.md) — <!-- begin:auto-docs --> _(no submodules)_- [matcher](./src/core/matcher/matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [matcher](./src/core/matcher/matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalizer](./src/core/normalizer/normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalizer](./src/core/normalizer/normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/domain-normalizer-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [domain-normalizer-error](./src/errors/domain-normalizer-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/domain-normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [domain-normalizer](./src/types/domain-normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [aliases](./src/core/aliases/aliases.md) — <!-- begin:auto-docs --> _(no submodules)_- [matcher](./src/core/matcher/matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [matcher](./src/core/matcher/matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalizer](./src/core/normalizer/normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalizer](./src/core/normalizer/normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/domain-normalizer-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [domain-normalizer-error](./src/errors/domain-normalizer-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/domain-normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [domain-normalizer](./src/types/domain-normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [aliases](./src/core/aliases/aliases.md) — <!-- begin:auto-docs --> _(no submodules)_- [matcher](./src/core/matcher/matcher.md) — <!-- begin:auto-docs --> _(no submodules)_ - [matcher](./src/core/matcher/matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [normalizer](./src/core/normalizer/normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [normalizer](./src/core/normalizer/normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/domain-normalizer-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [domain-normalizer-error](./src/errors/domain-normalizer-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/domain-normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [domain-normalizer](./src/types/domain-normalizer.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [`./src/core/normalizer/normalizer.md`](./src/core/normalizer/normalizer.md) | Domain/URL normalisation with tldts, prefix stripping, alias resolution |
|
||||
| [`./src/core/aliases/aliases.md`](./src/core/aliases/aliases.md) | Built-in alias map and canonical lookup functions |
|
||||
| [`./src/core/matcher/matcher.md`](./src/core/matcher/matcher.md) | Levenshtein similarity scoring, domain grouping, and matching |
|
||||
| [`./src/errors/domain-normalizer-error.md`](./src/errors/domain-normalizer-error.md) | Local error class and stable code registry |
|
||||
| [`./src/types/domain-normalizer.md`](./src/types/domain-normalizer.md) | Shared type definitions (NormalizerConfig, NormalizedDomain, MatchResult) |
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { normalizeDomain, matchDomains, groupDomains } from "@suaveplan/domain-normalizer";
|
||||
|
||||
// Normalize a domain
|
||||
const result = normalizeDomain("m.youtube.com");
|
||||
console.log(result.normalized); // "youtube.com"
|
||||
|
||||
// Normalize a full URL
|
||||
import { normalizeUrl } from "@suaveplan/domain-normalizer";
|
||||
const urlResult = normalizeUrl("https://old.reddit.com/r/programming");
|
||||
console.log(urlResult.normalized); // "reddit.com"
|
||||
|
||||
// Check similarity
|
||||
const match = matchDomains("youtube.com", "m.youtube.com");
|
||||
console.log(match.isMatch); // true
|
||||
|
||||
// Group a list of mixed domains
|
||||
const groups = groupDomains(["m.youtube.com", "youtube.com", "reddit.com", "old.reddit.com"]);
|
||||
// Map { "youtube.com" => [...], "reddit.com" => [...] }
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
```ts
|
||||
import { normalizeDomain } from "@suaveplan/domain-normalizer/normalizer";
|
||||
import { getCanonicalDomain } from "@suaveplan/domain-normalizer/aliases";
|
||||
import { levenshteinDistance } from "@suaveplan/domain-normalizer/matcher";
|
||||
import { DomainNormalizerError } from "@suaveplan/domain-normalizer/errors";
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
# aliases
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the built-in alias map and lookup functions for well-known domain variants. Many popular sites publish the same content under mobile subdomains (`m.`, `mobile.`), AMP subdomains (`amp.`), and legacy subdomains (`old.`, `lite.`). This module centralises the mapping from these variant domains to their canonical registrable domain, so that downstream deduplication and normalisation functions operate on a consistent canonical form.
|
||||
|
||||
The reverse-index (canonical → group of aliases) is computed lazily and cached for O(1) repeated lookups; `resetAliasCache()` clears it for test isolation.
|
||||
|
||||
## Features
|
||||
|
||||
- `BUILT_IN_ALIASES` — exported `ReadonlyMap<string, string>` covering 30+ well-known variant domains
|
||||
- `getCanonicalDomain(domain)` — returns canonical form or original input when not an alias
|
||||
- `isKnownAlias(domain)` — O(1) membership check in the alias map
|
||||
- `getAliasGroup(domain)` — returns all equivalent domains (including canonical) sorted alphabetically
|
||||
- `resetAliasCache()` — clears the lazy reverse-index for test-environment isolation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getCanonicalDomain,
|
||||
isKnownAlias,
|
||||
} from "@suaveplan/domain-normalizer/aliases";
|
||||
|
||||
console.log(getCanonicalDomain("m.youtube.com")); // "youtube.com"
|
||||
console.log(getCanonicalDomain("youtube.com")); // "youtube.com" (passthrough)
|
||||
console.log(isKnownAlias("old.reddit.com")); // true
|
||||
console.log(isKnownAlias("example.com")); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getAliasGroup,
|
||||
getCanonicalDomain,
|
||||
resetAliasCache,
|
||||
} from "@suaveplan/domain-normalizer/aliases";
|
||||
|
||||
// Find all equivalent domains for a given input
|
||||
const group = getAliasGroup("m.reddit.com");
|
||||
// ["i.reddit.com", "m.reddit.com", "new.reddit.com", "old.reddit.com", "reddit.com"]
|
||||
|
||||
// Canonical also returns its full group
|
||||
const sameGroup = getAliasGroup("reddit.com");
|
||||
// same array as above
|
||||
|
||||
// In tests: reset the cache after any mutation
|
||||
resetAliasCache();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `BUILT_IN_ALIASES`
|
||||
|
||||
```ts
|
||||
const BUILT_IN_ALIASES: ReadonlyMap<string, string>
|
||||
```
|
||||
|
||||
Immutable map of `variantDomain → canonicalDomain`. Contains entries for YouTube, Twitter/X, The Guardian, CNN, Facebook, Reddit, Instagram, LinkedIn, Wikipedia, GitHub, Google, Amazon, TikTok, Bing, and DuckDuckGo.
|
||||
|
||||
### `getCanonicalDomain(domain)`
|
||||
|
||||
```ts
|
||||
function getCanonicalDomain(domain: string): string
|
||||
```
|
||||
|
||||
Returns the canonical domain for `domain`. If `domain` is not in `BUILT_IN_ALIASES`, returns `domain` unchanged (making this function idempotent on canonical domains).
|
||||
|
||||
### `isKnownAlias(domain)`
|
||||
|
||||
```ts
|
||||
function isKnownAlias(domain: string): boolean
|
||||
```
|
||||
|
||||
Returns `true` if `domain` has an entry in `BUILT_IN_ALIASES`, `false` otherwise.
|
||||
|
||||
### `getAliasGroup(domain)`
|
||||
|
||||
```ts
|
||||
function getAliasGroup(domain: string): string[]
|
||||
```
|
||||
|
||||
Returns a sorted array of all domain strings that map to the same canonical as `domain`. Includes the canonical form itself. If `domain` is the canonical and has no aliases, returns `[canonical]`.
|
||||
|
||||
### `resetAliasCache()`
|
||||
|
||||
```ts
|
||||
function resetAliasCache(): void
|
||||
```
|
||||
|
||||
Clears the internal reverse-index cache. The cache is rebuilt on the next call to `getAliasGroup`. Intended for test environments only.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The reverse-index is built lazily on first call to `getAliasGroup` to avoid unnecessary work in code paths that only call `getCanonicalDomain`.
|
||||
- `BUILT_IN_ALIASES` is a `ReadonlyMap` constructed once at module load — it is not frozen at runtime but TypeScript prevents mutation at the type level.
|
||||
- `getAliasGroup` returns a new sorted array on every call to prevent callers from mutating the internal set.
|
||||
- The alias map is intentionally conservative — it only includes widely deployed subdomain patterns, not every possible variant, to minimise false-positive collapsing of genuinely distinct domains.
|
||||
@@ -0,0 +1,127 @@
|
||||
# matcher
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides domain similarity scoring and grouping functions for deduplication workflows. Uses Levenshtein edit distance on registrable domain parts (eTLD+1) to compute a normalized similarity score in `[0, 1]`, enabling fuzzy matching of typos, regional TLD variants, and subdomain variants that survive normalization.
|
||||
|
||||
The grouping function builds on the normalizer's canonical domain resolution, collecting all aliases of the same domain into a single group for deduplication pipelines.
|
||||
|
||||
## Features
|
||||
|
||||
- `levenshteinDistance(a, b)` — classic two-row DP edit distance, O(m×n) time, O(n) space
|
||||
- `computeSimilarity(a, b)` — normalized distance score: 1.0 = exact, 0.0 = fully different
|
||||
- `matchDomains(domain1, domain2)` — full `MatchResult` with similarity and boolean threshold flag
|
||||
- `findMatchingDomains(needle, haystack, threshold?)` — sorted list of haystack entries that match a needle
|
||||
- `groupDomains(domains)` — groups a list of domain strings by their canonical registrable domain
|
||||
- `DEFAULT_MATCH_THRESHOLD` — exported constant (0.8) for custom threshold decisions
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { matchDomains, computeSimilarity } from "@suaveplan/domain-normalizer/matcher";
|
||||
|
||||
const result = matchDomains("youtube.com", "m.youtube.com");
|
||||
console.log(result.similarity); // 1.0 (both normalize to youtube.com)
|
||||
console.log(result.isMatch); // true
|
||||
|
||||
const score = computeSimilarity("google.com", "googel.com");
|
||||
console.log(score); // ~0.857 (one typo in 7-char string)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
findMatchingDomains,
|
||||
groupDomains,
|
||||
levenshteinDistance,
|
||||
} from "@suaveplan/domain-normalizer/matcher";
|
||||
|
||||
// Find duplicates of a known domain in a list
|
||||
const matches = findMatchingDomains("reddit.com", [
|
||||
"m.reddit.com",
|
||||
"old.reddit.com",
|
||||
"example.com",
|
||||
"reddlt.com",
|
||||
], 0.8);
|
||||
// ["m.reddit.com", "old.reddit.com", "reddlt.com"]
|
||||
|
||||
// Group a crawled link list by canonical domain
|
||||
const grouped = groupDomains([
|
||||
"m.youtube.com",
|
||||
"youtube.com",
|
||||
"old.reddit.com",
|
||||
"reddit.com",
|
||||
"example.com",
|
||||
]);
|
||||
// Map {
|
||||
// "youtube.com" => ["m.youtube.com", "youtube.com"],
|
||||
// "reddit.com" => ["old.reddit.com", "reddit.com"],
|
||||
// "example.com" => ["example.com"]
|
||||
// }
|
||||
|
||||
// Raw edit distance
|
||||
console.log(levenshteinDistance("kitten", "sitting")); // 3
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DEFAULT_MATCH_THRESHOLD`
|
||||
|
||||
```ts
|
||||
const DEFAULT_MATCH_THRESHOLD: number // 0.8
|
||||
```
|
||||
|
||||
### `levenshteinDistance(a, b)`
|
||||
|
||||
```ts
|
||||
function levenshteinDistance(a: string, b: string): number
|
||||
```
|
||||
|
||||
Returns the minimum number of single-character edits (insertions, deletions, substitutions) to transform `a` into `b`. Returns 0 for identical strings. Property: `distance(a, a) === 0`; triangle inequality holds.
|
||||
|
||||
### `computeSimilarity(a, b)`
|
||||
|
||||
```ts
|
||||
function computeSimilarity(a: string, b: string): number
|
||||
```
|
||||
|
||||
Returns a score in `[0, 1]`. Computes `1 - levenshtein(regA, regB) / max(len(regA), len(regB))` where `regA`/`regB` are the registrable domain parts. Returns `1.0` for two empty strings.
|
||||
|
||||
### `matchDomains(domain1, domain2)`
|
||||
|
||||
```ts
|
||||
function matchDomains(domain1: string, domain2: string): MatchResult
|
||||
```
|
||||
|
||||
Returns `{ domain1, domain2, similarity, isMatch }` where `isMatch = similarity >= DEFAULT_MATCH_THRESHOLD`.
|
||||
|
||||
### `findMatchingDomains(needle, haystack, threshold?)`
|
||||
|
||||
```ts
|
||||
function findMatchingDomains(
|
||||
needle: string,
|
||||
haystack: string[],
|
||||
threshold?: number,
|
||||
): string[]
|
||||
```
|
||||
|
||||
Returns haystack entries with `computeSimilarity(needle, entry) >= threshold`, sorted by similarity descending.
|
||||
|
||||
### `groupDomains(domains)`
|
||||
|
||||
```ts
|
||||
function groupDomains(domains: string[]): Map<string, string[]>
|
||||
```
|
||||
|
||||
Returns a `Map<canonicalDomain, string[]>` grouping all input domains by their canonical registrable domain. Preserves insertion order within each group.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `levenshteinDistance` uses the standard two-row optimization: only two rows of the DP table are kept in memory at a time, keeping space complexity at O(n).
|
||||
- `computeSimilarity` calls `getRegistrableDomain` first — two aliases that normalize to the same eTLD+1 always score 1.0.
|
||||
- `findMatchingDomains` always computes similarity for every haystack entry; it does not short-circuit for large haystacks. For very large lists (thousands of entries), consider pre-grouping first.
|
||||
- `groupDomains` relies on `getDomainGroup` which never throws, so this function is also safe to use in non-throwing map/reduce pipelines.
|
||||
@@ -0,0 +1,110 @@
|
||||
# normalizer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the core domain and URL normalization functions for `@suaveplan/domain-normalizer`. Uses `tldts` for accurate public suffix list (PSL) lookups to extract registrable domains (eTLD+1), and integrates with the alias module to resolve known mobile/AMP subdomain variants to their canonical form.
|
||||
|
||||
The normalizer is designed to be idempotent: `normalizeDomain(normalizeDomain(x))` produces the same result as `normalizeDomain(x)` for any valid input.
|
||||
|
||||
## Features
|
||||
|
||||
- `normalizeDomain(input, config?)` — normalizes a bare domain string with configurable stripping and alias resolution
|
||||
- `normalizeUrl(url, config?)` — extracts hostname from a full URL, then normalizes it
|
||||
- `getRegistrableDomain(input)` — returns the eTLD+1 or null for invalid inputs
|
||||
- `getSubdomain(input)` — returns the subdomain portion or null
|
||||
- `getDomainGroup(input)` — returns the canonical registrable domain as a grouping key
|
||||
- Configurable via `NormalizerConfig`: strip www, strip mobile prefixes, lowercase, resolve aliases
|
||||
- Returns rich `NormalizedDomain` objects with subdomain, publicSuffix, and isPrivateDomain metadata
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { normalizeDomain } from "@suaveplan/domain-normalizer/normalizer";
|
||||
|
||||
const result = normalizeDomain("m.youtube.com");
|
||||
console.log(result.normalized); // "youtube.com"
|
||||
console.log(result.registrableDomain); // "youtube.com"
|
||||
console.log(result.publicSuffix); // "com"
|
||||
console.log(result.original); // "m.youtube.com"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { normalizeUrl, getDomainGroup } from "@suaveplan/domain-normalizer/normalizer";
|
||||
|
||||
// Normalize a full URL
|
||||
const result = normalizeUrl("https://old.reddit.com/r/programming");
|
||||
console.log(result.normalized); // "reddit.com"
|
||||
console.log(result.original); // "https://old.reddit.com/r/programming"
|
||||
|
||||
// Group a mixed list of domains
|
||||
const domains = ["m.youtube.com", "youtube.com", "old.reddit.com", "reddit.com"];
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const d of domains) {
|
||||
const key = getDomainGroup(d);
|
||||
const arr = groups.get(key) ?? [];
|
||||
arr.push(d);
|
||||
groups.set(key, arr);
|
||||
}
|
||||
// Map { "youtube.com" => ["m.youtube.com", "youtube.com"], "reddit.com" => [...] }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `normalizeDomain(input, config?)`
|
||||
|
||||
```ts
|
||||
function normalizeDomain(input: string, config?: NormalizerConfig): NormalizedDomain
|
||||
```
|
||||
|
||||
Normalizes a bare domain string. Applies lowercase, alias resolution, www/mobile stripping, and PSL parsing in that order.
|
||||
|
||||
- **Throws** `DomainNormalizerError(INVALID_INPUT)` for empty/whitespace-only inputs
|
||||
- **Throws** `DomainNormalizerError(PARSE_ERROR)` when tldts cannot extract a registrable domain
|
||||
|
||||
### `normalizeUrl(url, config?)`
|
||||
|
||||
```ts
|
||||
function normalizeUrl(url: string, config?: NormalizerConfig): NormalizedDomain
|
||||
```
|
||||
|
||||
Extracts the hostname from `url` (using `URL` constructor with protocol injection if needed), then calls `normalizeDomain` on it. The `original` field in the result contains the full URL.
|
||||
|
||||
- **Throws** `DomainNormalizerError(INVALID_INPUT)` for empty inputs
|
||||
- **Throws** `DomainNormalizerError(PARSE_ERROR)` when hostname cannot be extracted
|
||||
|
||||
### `getRegistrableDomain(input)`
|
||||
|
||||
```ts
|
||||
function getRegistrableDomain(input: string): string | null
|
||||
```
|
||||
|
||||
Returns the eTLD+1 for `input`, or `null` if parsing fails or input is empty. Does not throw.
|
||||
|
||||
### `getSubdomain(input)`
|
||||
|
||||
```ts
|
||||
function getSubdomain(input: string): string | null
|
||||
```
|
||||
|
||||
Returns the subdomain portion of `input`, or `null` if there is none. Does not throw.
|
||||
|
||||
### `getDomainGroup(input)`
|
||||
|
||||
```ts
|
||||
function getDomainGroup(input: string): string
|
||||
```
|
||||
|
||||
Returns the canonical registrable domain for use as a deduplication/grouping key. Falls back to lowercased trimmed input if normalization fails. Does not throw.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Alias resolution is applied before prefix stripping so that an alias like `www.facebook.com` maps to `facebook.com` (rather than stripping `www.` first and getting `facebook.com` without alias resolution running).
|
||||
- `tldts` handles private domains (e.g. `github.io`) correctly when `allowPrivateDomains: true` is set.
|
||||
- The `URL` constructor is used for URL hostname extraction to handle percent-encoding, authentication components, and port numbers transparently.
|
||||
- `stripPrefixes` loops until no more changes occur to handle edge cases like hypothetical `m.m.domain.com` inputs.
|
||||
- `getDomainGroup` catches all errors and falls back to the lowercased input to ensure it never throws — making it safe for use in array mapping operations.
|
||||
@@ -0,0 +1,92 @@
|
||||
# domain-normalizer-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the stable error class and error-code registry for the `@suaveplan/domain-normalizer` package. All errors thrown by normalizer, matcher, and alias functions are instances of `DomainNormalizerError` with a stable machine-readable code string, enabling programmatic error handling without string matching on messages.
|
||||
|
||||
This module deliberately avoids depending on `@suaveplan/error` so that `domain-normalizer` stays a leaf package with no workspace runtime dependencies.
|
||||
|
||||
## Features
|
||||
|
||||
- `DOMAIN_NORMALIZER_CODES` — frozen const object with every stable code string
|
||||
- `DomainNormalizerError` — extends `Error`, adds `readonly code: string` and sets `name = 'DomainNormalizerError'`
|
||||
- `DomainNormalizerCode` — union type of all defined code strings for exhaustive switch guards
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DomainNormalizerError, DOMAIN_NORMALIZER_CODES } from "@suaveplan/domain-normalizer/errors";
|
||||
|
||||
try {
|
||||
// any domain-normalizer call
|
||||
} catch (err) {
|
||||
if (err instanceof DomainNormalizerError) {
|
||||
if (err.code === DOMAIN_NORMALIZER_CODES.INVALID_INPUT) {
|
||||
console.error("Bad input:", err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DomainNormalizerError, DOMAIN_NORMALIZER_CODES, type DomainNormalizerCode } from "@suaveplan/domain-normalizer/errors";
|
||||
|
||||
function handleError(err: unknown): string {
|
||||
if (!(err instanceof DomainNormalizerError)) return "unknown error";
|
||||
|
||||
const code: DomainNormalizerCode = err.code as DomainNormalizerCode;
|
||||
switch (code) {
|
||||
case DOMAIN_NORMALIZER_CODES.INVALID_INPUT:
|
||||
return `Invalid domain input: ${err.message}`;
|
||||
case DOMAIN_NORMALIZER_CODES.PARSE_ERROR:
|
||||
return `Failed to parse domain: ${err.message}`;
|
||||
default:
|
||||
return `Domain normalizer error [${code}]: ${err.message}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DOMAIN_NORMALIZER_CODES`
|
||||
|
||||
```ts
|
||||
const DOMAIN_NORMALIZER_CODES: {
|
||||
readonly INVALID_INPUT: "DOMAIN_NORMALIZER.INVALID_INPUT";
|
||||
readonly PARSE_ERROR: "DOMAIN_NORMALIZER.PARSE_ERROR";
|
||||
}
|
||||
```
|
||||
|
||||
Stable code strings for every error produced by this package. Use these constants for programmatic error matching rather than message strings.
|
||||
|
||||
### `DomainNormalizerError`
|
||||
|
||||
```ts
|
||||
class DomainNormalizerError extends Error {
|
||||
readonly code: string;
|
||||
constructor(message: string, code: string): DomainNormalizerError;
|
||||
}
|
||||
```
|
||||
|
||||
- `message` — human-readable description of the error condition
|
||||
- `code` — machine-readable code from `DOMAIN_NORMALIZER_CODES`
|
||||
- `name` — always `"DomainNormalizerError"` for `instanceof`-free detection
|
||||
|
||||
### `DomainNormalizerCode`
|
||||
|
||||
```ts
|
||||
type DomainNormalizerCode = "DOMAIN_NORMALIZER.INVALID_INPUT" | "DOMAIN_NORMALIZER.PARSE_ERROR";
|
||||
```
|
||||
|
||||
Union type derived from `DOMAIN_NORMALIZER_CODES` values, suitable for exhaustive switch statements.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The class intentionally calls `super(message)` before setting `this.name` to work correctly with all JS runtimes.
|
||||
- No `Error.captureStackTrace` call is made — this keeps the module universal (works in browsers without V8).
|
||||
- The `code` field is `readonly` to prevent accidental mutation after construction.
|
||||
@@ -0,0 +1,86 @@
|
||||
# domain-normalizer types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the shared TypeScript interfaces consumed by every module in `@suaveplan/domain-normalizer`. Centralising type declarations here prevents circular imports and makes the public API shape visible in one place.
|
||||
|
||||
These types are shipped as part of the package's type declarations and re-exported from the root barrel so consumers can import them without importing any runtime code.
|
||||
|
||||
## Features
|
||||
|
||||
- `NormalizerConfig` — configuration object controlling stripping and alias-resolution behaviour
|
||||
- `NormalizedDomain` — rich result object returned by `normalizeDomain` and `normalizeUrl`
|
||||
- `MatchResult` — comparison result from `matchDomains` with a numeric similarity score and boolean threshold flag
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { NormalizerConfig, NormalizedDomain } from "@suaveplan/domain-normalizer";
|
||||
|
||||
const config: NormalizerConfig = { stripWww: true, stripMobile: true };
|
||||
|
||||
function process(result: NormalizedDomain): void {
|
||||
console.log(result.registrableDomain, result.publicSuffix);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { MatchResult, NormalizerConfig } from "@suaveplan/domain-normalizer";
|
||||
import { matchDomains } from "@suaveplan/domain-normalizer/matcher";
|
||||
|
||||
function deduplicate(domains: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const d of domains) {
|
||||
let isDup = false;
|
||||
for (const u of unique) {
|
||||
const result: MatchResult = matchDomains(d, u);
|
||||
if (result.isMatch) { isDup = true; break; }
|
||||
}
|
||||
if (!isDup) { unique.push(d); seen.add(d); }
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `NormalizerConfig`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `stripWww` | `boolean?` | `true` | Remove `www.` prefix |
|
||||
| `stripMobile` | `boolean?` | `true` | Remove `m.`, `mobile.`, `lite.`, `amp.` prefixes |
|
||||
| `toLowercase` | `boolean?` | `true` | Lowercase the result |
|
||||
| `resolveAliases` | `boolean?` | `true` | Apply built-in alias map |
|
||||
|
||||
### `NormalizedDomain`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `original` | `string` | Unmodified input |
|
||||
| `normalized` | `string` | Canonical registrable domain |
|
||||
| `subdomain` | `string?` | Remaining subdomain after stripping |
|
||||
| `registrableDomain` | `string` | eTLD+1 (e.g. `"youtube.com"`) |
|
||||
| `publicSuffix` | `string?` | eTLD (e.g. `"com"`) |
|
||||
| `isPrivateDomain` | `boolean?` | From public suffix list private section |
|
||||
|
||||
### `MatchResult`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `domain1` | `string` | First input |
|
||||
| `domain2` | `string` | Second input |
|
||||
| `similarity` | `number` | Score in `[0, 1]` |
|
||||
| `isMatch` | `boolean` | `true` when similarity ≥ default threshold (0.8) |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All interfaces use optional (`?`) fields rather than union with `undefined` for better JSON round-trip compatibility.
|
||||
- `NormalizerConfig` fields are all optional so callers can pass a partial config; the normalizer merges with defaults internally.
|
||||
- `MatchResult.similarity` is always in the range `[0, 1]` inclusive — callers can apply their own threshold by comparing to `similarity` directly.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Domain Normalizer Types
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared TypeScript type definitions for the `@suaveplan/domain-normalizer` package. These interfaces describe the configuration surface and output shapes used by every normalizer, alias, and matcher function. Centralising them here prevents circular imports between modules and provides a single place to extend the public API contract.
|
||||
|
||||
## Features
|
||||
|
||||
- `NormalizerConfig` — four boolean flags that control how raw input is transformed before canonicalization
|
||||
- `NormalizedDomain` — rich output record containing the original input, canonical form, subdomain, registrable domain (eTLD+1), public suffix, and private-domain flag
|
||||
- `MatchResult` — pair-wise comparison result with a `[0, 1]` similarity score and a boolean threshold indicator
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { NormalizerConfig, NormalizedDomain } from '@suaveplan/domain-normalizer';
|
||||
|
||||
const config: NormalizerConfig = {
|
||||
stripWww: true,
|
||||
stripMobile: true,
|
||||
toLowercase: true,
|
||||
resolveAliases: true,
|
||||
};
|
||||
|
||||
// NormalizedDomain returned by normalizeDomain()
|
||||
const result: NormalizedDomain = {
|
||||
original: 'https://m.YouTube.com/watch?v=dQw4w9WgXcQ',
|
||||
normalized: 'youtube.com',
|
||||
registrableDomain: 'youtube.com',
|
||||
publicSuffix: 'com',
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { MatchResult, NormalizerConfig } from '@suaveplan/domain-normalizer';
|
||||
import { matchDomains, normalizeDomain } from '@suaveplan/domain-normalizer';
|
||||
|
||||
// Use a stricter config that keeps subdomains intact
|
||||
const strictConfig: NormalizerConfig = {
|
||||
stripWww: false,
|
||||
stripMobile: false,
|
||||
toLowercase: true,
|
||||
resolveAliases: false,
|
||||
};
|
||||
|
||||
const a = normalizeDomain('blog.example.com', strictConfig);
|
||||
const b = normalizeDomain('BLOG.EXAMPLE.COM', strictConfig);
|
||||
|
||||
const result: MatchResult = matchDomains(a.normalized, b.normalized);
|
||||
// result.similarity === 1, result.isMatch === true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `NormalizerConfig`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `stripWww` | `boolean?` | `true` | Remove `www.` prefix |
|
||||
| `stripMobile` | `boolean?` | `true` | Remove `m.`, `mobile.`, `lite.`, `amp.` prefixes |
|
||||
| `toLowercase` | `boolean?` | `true` | Lowercase the entire domain |
|
||||
| `resolveAliases` | `boolean?` | `true` | Map known alias subdomains to their canonical registrable domain |
|
||||
|
||||
### `NormalizedDomain`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `original` | `string` | The raw input passed to the normalizer |
|
||||
| `normalized` | `string` | The canonical domain after all transformations |
|
||||
| `subdomain` | `string?` | Remaining subdomain after stripping www/mobile |
|
||||
| `registrableDomain` | `string` | The eTLD+1 (e.g. `youtube.com`) |
|
||||
| `publicSuffix` | `string?` | The effective TLD (e.g. `com`, `co.uk`) |
|
||||
| `isPrivateDomain` | `boolean?` | `true` for private registries per public suffix list |
|
||||
|
||||
### `MatchResult`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `domain1` | `string` | First domain in the comparison |
|
||||
| `domain2` | `string` | Second domain in the comparison |
|
||||
| `similarity` | `number` | Levenshtein-normalized score in `[0, 1]` |
|
||||
| `isMatch` | `boolean` | `true` when `similarity >= 0.8` (default threshold) |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All interfaces are exported from `src/index.ts` and re-exported under the `./types` subpath export. The types file contains no runtime code and therefore has no test file, but it is included in `docs:check` coverage because it forms part of the package's documented public API surface.
|
||||
@@ -0,0 +1,121 @@
|
||||
# @suaveplan/email-processing - Universal MIME parsing and email processing: postal-mime + libmime wrappers, attachment processing with SHA-256 hashing + MIME-type allowlists, Content-ID resolution, structured data extraction (iCalendar, delivery tracking, receipts), DKIM/SPF/DMARC verification, spam scoring with pluggable rule sets, conversation threading, hook pipeline + OTel instrumentation
|
||||
|
||||
Universal MIME parsing and email processing for the Suaveplan
|
||||
. Wraps `postal-mime` and `libmime` with structured errors,
|
||||
OpenTelemetry instrumentation, a hook pipeline for transforming
|
||||
parsed messages, full attachment processing (SHA-256 hashing, MIME
|
||||
allowlists, CID resolution), structured-data extraction (iCalendar,
|
||||
delivery tracking, receipts), DKIM/SPF/DMARC verification, spam
|
||||
scoring, and conversation threading.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [attachment-processor](./src/attachment-processor/attachment-processor.md) — Normalises and validates `EmailAttachment` payloads emerging from `parseEmail`. Each attachment is decoded to bytes, va…
|
||||
- [attachment-processor](./src/attachment-processor/attachment-processor.md) — Normalises and validates `EmailAttachment` payloads emerging from `parseEmail`. Each attachment is decoded to bytes, va…
|
||||
- [calendar-parser](./src/calendar-parser/calendar-parser.md) — Parses iCalendar (RFC 5545) / ICS payloads embedded in emails — these arrive either as `text/calendar` MIME parts or as…
|
||||
- [calendar-parser](./src/calendar-parser/calendar-parser.md) — Parses iCalendar (RFC 5545) / ICS payloads embedded in emails — these arrive either as `text/calendar` MIME parts or as…
|
||||
- [cid-resolver](./src/cid-resolver/cid-resolver.md) — Resolves Content-ID references inside HTML email bodies. Email clients that send inline images embed them as MIME parts…
|
||||
- [cid-resolver](./src/cid-resolver/cid-resolver.md) — Resolves Content-ID references inside HTML email bodies. Email clients that send inline images embed them as MIME parts…
|
||||
- [data-extractor](./src/data-extractor/data-extractor.md) — Extracts structured business signals — calendar events, parcel tracking numbers, and receipt / order data — from the fr…
|
||||
- [data-extractor](./src/data-extractor/data-extractor.md) — Extracts structured business signals — calendar events, parcel tracking numbers, and receipt / order data — from the fr…
|
||||
- [dkim-canonicalize](./src/dkim-canonicalize/dkim-canonicalize.md) — Implements RFC 6376 canonicalisation primitives — the byte-exact text normalisation steps DKIM requires before hashing…
|
||||
- [dkim-canonicalize](./src/dkim-canonicalize/dkim-canonicalize.md) — Implements RFC 6376 canonicalisation primitives — the byte-exact text normalisation steps DKIM requires before hashing…
|
||||
- [dkim-verifier](./src/dkim-verifier/dkim-verifier.md) — Implements DKIM (RFC 6376) signature verification — parses a `DKIM-Signature` header, looks up the signer's public key…
|
||||
- [dkim-verifier](./src/dkim-verifier/dkim-verifier.md) — Implements DKIM (RFC 6376) signature verification — parses a `DKIM-Signature` header, looks up the signer's public key…
|
||||
- [dmarc-checker](./src/dmarc-checker/dmarc-checker.md) — Implements DMARC (RFC 7489) policy evaluation on top of an upstream SPF result and DKIM verification. Looks up the `_dm…
|
||||
- [dmarc-checker](./src/dmarc-checker/dmarc-checker.md) — Implements DMARC (RFC 7489) policy evaluation on top of an upstream SPF result and DKIM verification. Looks up the `_dm…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed error class for the `@suaveplan/email-processing` package. Every failu…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed error class for the `@suaveplan/email-processing` package. Every failu…
|
||||
- [hooks](./src/hooks/hooks.md) — Implements the package's `email:processing` hook pipeline: every parsed email passes through a sequence of handlers tha…
|
||||
- [hooks](./src/hooks/hooks.md) — Implements the package's `email:processing` hook pipeline: every parsed email passes through a sequence of handlers tha…
|
||||
- [mime-utils](./src/mime-utils/mime-utils.md) — Wraps the `libmime` library with a strict, typed boundary so the rest of the package can call RFC 2047 / 2231 / 5322 en…
|
||||
- [mime-utils](./src/mime-utils/mime-utils.md) — Wraps the `libmime` library with a strict, typed boundary so the rest of the package can call RFC 2047 / 2231 / 5322 en…
|
||||
- [parser](./src/parser/parser.md) — Wraps `postal-mime` with a strict input/option boundary, OTel instrumentation, structured error codes, and the `@suavep…
|
||||
- [parser](./src/parser/parser.md) — Wraps `postal-mime` with a strict input/option boundary, OTel instrumentation, structured error codes, and the `@suavep…
|
||||
- [spam-scorer](./src/spam-scorer/spam-scorer.md) — Heuristic spam scorer with a pluggable rule set. The default rules cover the most common spam signals — missing headers…
|
||||
- [spam-scorer](./src/spam-scorer/spam-scorer.md) — Heuristic spam scorer with a pluggable rule set. The default rules cover the most common spam signals — missing headers…
|
||||
- [spf-checker](./src/spf-checker/spf-checker.md) — Implements the SPF (RFC 7208) evaluation algorithm: parses the record, walks each mechanism in order, follows `include:…
|
||||
- [spf-checker](./src/spf-checker/spf-checker.md) — Implements the SPF (RFC 7208) evaluation algorithm: parses the record, walks each mechanism in order, follows `include:…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Provides the shared OpenTelemetry meter and instrument set used by every public entry point of `@suaveplan/email-proces…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Provides the shared OpenTelemetry meter and instrument set used by every public entry point of `@suaveplan/email-proces…
|
||||
- [thread-grouper](./src/thread-grouper/thread-grouper.md) — Groups a flat list of emails into conversation threads using In-Reply-To, References, and subject-line heuristics. Each…
|
||||
- [thread-grouper](./src/thread-grouper/thread-grouper.md) — Groups a flat list of emails into conversation threads using In-Reply-To, References, and subject-line heuristics. Each…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
attachment-processor/ # SHA-256 hash + size + MIME allowlist validation
|
||||
calendar-parser/ # iCalendar VEVENT extraction
|
||||
cid-resolver/ # Content-ID resolution to data URIs / URL maps
|
||||
data-extractor/ # tracking numbers, receipts, calendar events
|
||||
dkim-canonicalize/ # RFC 6376 relaxed + simple canonicalisation
|
||||
dkim-verifier/ # Tag parsing, body-hash, RSASSA-PKCS1-v1_5 verify
|
||||
dmarc-checker/ # Policy + alignment over SPF + DKIM
|
||||
error-codes/ # EMAIL_PROCESSING_CODES + EmailProcessingError
|
||||
hooks/ # email:processing pipeline + singleton bus
|
||||
mime-utils/ # libmime wrappers (encode/decode words, folding)
|
||||
parser/ # parseEmail / parseEmailAddress / decodeEmailWords
|
||||
spam-scorer/ # 12 default rules + custom-rule merge
|
||||
spf-checker/ # RFC 7208 evaluator with include/redirect limits
|
||||
telemetry/ # Shared OTel meter + instruments
|
||||
thread-grouper/ # In-Reply-To + References + subject threading
|
||||
types/ # Shared MIME / address / header type aliases
|
||||
index.ts # Package barrel
|
||||
```
|
||||
|
||||
## Quality
|
||||
|
||||
- 100% statement / branch / function / line coverage (Istanbul).
|
||||
- Real `postal-mime` parses against embedded RFC 822 fixtures — no
|
||||
mocks (rule 2).
|
||||
- Test runner imports go through `@suaveplan/testing/runner` only
|
||||
(rule 11).
|
||||
- Zod-validated option boundaries (`parseEmail`, `processAttachment`).
|
||||
- Two error codes:
|
||||
`INVALID_INPUT`, `PARSE_FAILURE`, `INVALID_OPTIONS`,
|
||||
`ATTACHMENT_TOO_LARGE`, `ATTACHMENT_TYPE_REJECTED`, `HASH_FAILURE`,
|
||||
`DKIM_VERIFY_FAILURE`, `HOOK_FAILURE` — every code is thrown by at
|
||||
least one production path under `src/`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseEmail,
|
||||
processAttachments,
|
||||
onEmailProcessing,
|
||||
} from "@suaveplan/email-processing";
|
||||
|
||||
onEmailProcessing((data) => ({
|
||||
...data,
|
||||
parsed: {
|
||||
...data.parsed,
|
||||
html: data.parsed.html?.replace(/<script[\s\S]*?<\/script>/gi, ""),
|
||||
},
|
||||
}));
|
||||
|
||||
const parsed = await parseEmail(rawMimeBuffer);
|
||||
const processed = await processAttachments(parsed.attachments, {
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
allowedMimeTypes: ["image/*", "application/pdf"],
|
||||
});
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
`./parser`, `./mime-utils`, `./hooks`, `./attachments`, `./cid-resolver`,
|
||||
`./extraction`, `./calendar`, `./dkim`, `./dmarc`, `./spf`, `./spam`,
|
||||
`./threading`, `./errors`, `./otel`.
|
||||
|
||||
## Provenance
|
||||
|
||||
Migrated from `packages/universal/email-processing` on the
|
||||
`/mnt/s/web/suaveplan` main branch per the enterprise plan at
|
||||
`/mnt/s/web/suaveplan/enterprise/email-processing.md`. Hook
|
||||
infrastructure was rewritten locally because the foundation
|
||||
`@suaveplan/events` library does not yet ship a shared
|
||||
`Hooks<T>` substrate.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# attachment-processor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Normalises and validates `EmailAttachment` payloads emerging from
|
||||
`parseEmail`. Each attachment is decoded to bytes, validated against a
|
||||
caller-supplied size + MIME-type allowlist, hashed with SHA-256 over
|
||||
the Web Crypto API, and packaged as a `ProcessedAttachment` carrying
|
||||
the original metadata plus a deterministic content hash for dedup,
|
||||
provenance, and dehydrated-template workflows.
|
||||
|
||||
## Features
|
||||
|
||||
- Three input shapes: `ArrayBuffer`, base64 string (per `encoding`),
|
||||
or plain UTF-8 string — falls back to `Uint8Array(0)` for unknown
|
||||
shapes (matches upstream behaviour) so callers do not see crashes
|
||||
on rare attachment encodings.
|
||||
- Size limit defaults to 25 MiB; overrides validated via Zod.
|
||||
- MIME allowlist supports both exact match (`application/pdf`) and
|
||||
wildcard (`image/*`).
|
||||
- SHA-256 via `crypto.subtle.digest` — works in browsers, Node, Bun
|
||||
and Deno without polyfills. Hash failures surface as
|
||||
`EMAIL_PROCESSING.HASH_FAILURE` with the underlying error on
|
||||
`cause`.
|
||||
- OTel counters for processed vs rejected attachments (attributed by
|
||||
reason) and a duration histogram for hash + metadata extraction.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { processAttachment } from "@suaveplan/email-processing/attachments";
|
||||
|
||||
const out = await processAttachment(parsed.attachments[0]);
|
||||
if (out.valid) {
|
||||
await store({ hash: out.metadata.contentHash, bytes: out.content });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
processAttachments,
|
||||
type ProcessedAttachment,
|
||||
} from "@suaveplan/email-processing/attachments";
|
||||
|
||||
const results = await processAttachments(parsed.attachments, {
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
allowedMimeTypes: ["image/*", "application/pdf"],
|
||||
});
|
||||
|
||||
const valid = results.filter((r): r is ProcessedAttachment => r.valid);
|
||||
const rejected = results.filter((r) => !r.valid);
|
||||
log.info({
|
||||
valid: valid.length,
|
||||
rejected: rejected.length,
|
||||
hashes: valid.map((v) => v.metadata.contentHash),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AttachmentMetadata` — filename, size, MIME type, disposition,
|
||||
contentHash (hex SHA-256), optional contentId + description.
|
||||
- `ProcessedAttachment` — `{ metadata, content, valid, error? }`.
|
||||
- `AttachmentProcessorOptions` — `{ allowedMimeTypes?, maxSize? }`,
|
||||
Zod-validated.
|
||||
- `processAttachment(att, options?)` — returns `ProcessedAttachment`.
|
||||
- `processAttachments(list, options?)` — returns
|
||||
`ProcessedAttachment[]`.
|
||||
|
||||
Errors: `INVALID_OPTIONS` for bad options, `HASH_FAILURE` if the Web
|
||||
Crypto API throws (e.g. in environments where the digest is
|
||||
unavailable).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`crypto.subtle.digest` requires the source buffer to satisfy the
|
||||
`BufferSource` type; passing a fresh `Uint8Array` works in every
|
||||
runtime tested. The duration histogram uses `performance.now()` when
|
||||
available and falls back to `Date.now()` for environments without it
|
||||
(older Node test isolates). Errors surfaced inside `extractMetadata`
|
||||
are propagated upward by `processAttachment` without being swallowed,
|
||||
so callers do not lose root-cause information when hashing fails.
|
||||
@@ -0,0 +1,81 @@
|
||||
# calendar-parser
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Parses iCalendar (RFC 5545) / ICS payloads embedded in emails — these
|
||||
arrive either as `text/calendar` MIME parts or as attachments with the
|
||||
`application/ics` media type. Only the subset of fields meaningful to
|
||||
email automation is extracted: summary, description, start/end,
|
||||
location, organizer, attendees, UID, and METHOD (REQUEST / REPLY /
|
||||
CANCEL). The parser is deliberately tolerant of folded continuation
|
||||
lines and ICS escape sequences.
|
||||
|
||||
## Features
|
||||
|
||||
- Handles RFC 5545 line folding — both `CRLF + SP` and `CRLF + TAB`
|
||||
continuations.
|
||||
- Unescapes the four standard ICS escapes: `\\n`, `\\,`, `\\;`, `\\\\`.
|
||||
- Extracts `mailto:` addresses from `ORGANIZER` and `ATTENDEE` lines
|
||||
regardless of preceding `CN` parameters.
|
||||
- Captures the calendar-level `METHOD` and surfaces it on every event
|
||||
so consumers can branch on REQUEST vs CANCEL.
|
||||
- Ignores lines outside `BEGIN:VEVENT` / `END:VEVENT` blocks so
|
||||
`VCALENDAR` boilerplate does not leak into event fields.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseICalendar } from "@suaveplan/email-processing/calendar";
|
||||
|
||||
const events = parseICalendar(icsAttachment);
|
||||
for (const event of events) {
|
||||
if (event.method === "CANCEL") {
|
||||
await cancelMeeting(event.uid);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseICalendar } from "@suaveplan/email-processing/calendar";
|
||||
|
||||
const events = parseICalendar(icsString);
|
||||
for (const event of events) {
|
||||
const attendees = event.attendees.map((mailto) => mailto.toLowerCase());
|
||||
await db.calendar.upsert({
|
||||
where: { uid: event.uid ?? hash(event.summary ?? "") },
|
||||
update: {
|
||||
summary: event.summary ?? null,
|
||||
dtStart: event.dtStart ?? null,
|
||||
dtEnd: event.dtEnd ?? null,
|
||||
location: event.location ?? null,
|
||||
attendees,
|
||||
},
|
||||
create: { ... },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CalendarEvent` — `{ summary?, description?, dtStart?, dtEnd?,
|
||||
location?, organizer?, attendees, uid?, method? }`.
|
||||
- `parseICalendar(icsContent: string): CalendarEvent[]` — returns an
|
||||
empty array when no `VEVENT` blocks are present. Throws
|
||||
`EmailProcessingError(INVALID_INPUT)` for non-string input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
DTSTART and DTEND are returned as the raw ICS date-time strings (e.g.
|
||||
`20250101T120000Z`) without conversion. Consumers that need
|
||||
`Date`/`Temporal` instances should call their preferred parser — the
|
||||
package deliberately does not pick one, since `text/calendar` payloads
|
||||
arrive in many timezone shapes (floating, UTC, TZID-suffixed) and
|
||||
correct handling requires knowledge of the surrounding email context.
|
||||
The unfolding regexes operate on `\r\n` first so that `\n`-only ICS
|
||||
payloads still split correctly on the second `\r?\n` split call.
|
||||
@@ -0,0 +1,81 @@
|
||||
# cid-resolver
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Resolves Content-ID references inside HTML email bodies. Email clients
|
||||
that send inline images embed them as MIME parts with a
|
||||
`Content-ID` header and reference them in HTML with
|
||||
`<img src="cid:<id>">`. After parsing, the rendered HTML needs those
|
||||
references replaced by something a downstream consumer (an inbox UI,
|
||||
a digest builder, an audit trail) can serve — either a data URI, a
|
||||
static URL, or an explicit URL mapping.
|
||||
|
||||
## Features
|
||||
|
||||
- Three resolution strategies, evaluated in order: explicit
|
||||
`urlMap` (`Object.hasOwn`-checked so prototype keys cannot leak),
|
||||
`urlPrefix` + filename / contentHash, then data URI fallback.
|
||||
- Configurable `maxDataUriSize` (default 5 MiB) — attachments larger
|
||||
than the cap fall through to the unresolved list rather than
|
||||
exploding the HTML string.
|
||||
- Deduplicates CID references via `extractCidReferences`.
|
||||
- `isReferencedInline` short-circuits the lookup so callers can
|
||||
classify attachments as "inline" vs "true attachment" before
|
||||
serialising.
|
||||
- Strict input validation — non-string `html` inputs raise
|
||||
`EMAIL_PROCESSING.INVALID_INPUT`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolveCidReferences } from "@suaveplan/email-processing/cid-resolver";
|
||||
|
||||
const out = resolveCidReferences(parsed.html ?? "", processedAttachments);
|
||||
console.log(out.resolvedCount, out.unresolvedCids);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
resolveCidReferences,
|
||||
isReferencedInline,
|
||||
} from "@suaveplan/email-processing/cid-resolver";
|
||||
|
||||
const result = resolveCidReferences(parsed.html ?? "", processed, {
|
||||
urlMap: { "logo@brand": "https://cdn.brand/logo.png" },
|
||||
urlPrefix: "/inbox/inline/",
|
||||
maxDataUriSize: 2 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const inlineAttachments = processed.filter((a) =>
|
||||
isReferencedInline(parsed.html ?? "", a)
|
||||
);
|
||||
const trueAttachments = processed.filter(
|
||||
(a) => !isReferencedInline(parsed.html ?? "", a)
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CidResolverOptions` — `{ maxDataUriSize?, urlPrefix?, urlMap? }`.
|
||||
- `CidResolutionResult` — `{ html, totalReferences, resolvedCount, unresolvedCids }`.
|
||||
- `resolveCidReferences(html, attachments, options?)` — primary entry.
|
||||
- `extractCidReferences(html)` — deduped list of CIDs found.
|
||||
- `isReferencedInline(html, attachment)` — boolean.
|
||||
|
||||
Errors: `INVALID_INPUT` for non-string `html`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Object.hasOwn(urlMap, cleanRef)` is used in preference to a direct
|
||||
truthy check so a deliberate `urlMap: { id: "" }` mapping is still
|
||||
recognised as "set", and so a polluted prototype cannot inject phantom
|
||||
keys. `String.prototype.matchAll` is used over `regex.exec` loops to
|
||||
keep the resolver simple and avoid stateful `lastIndex` bugs. The data
|
||||
URI fallback uses `btoa(String.fromCharCode(...))` so it works in the
|
||||
browser and Node without buffer polyfills.
|
||||
@@ -0,0 +1,75 @@
|
||||
# data-extractor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Extracts structured business signals — calendar events, parcel
|
||||
tracking numbers, and receipt / order data — from the freeform text
|
||||
and HTML of a parsed email. The extractor is heuristic by design:
|
||||
patterns are tuned against a sample of real shipping carrier and
|
||||
e-commerce templates and prioritise high precision over recall.
|
||||
Calendar extraction is delegated to `calendar-parser`.
|
||||
|
||||
## Features
|
||||
|
||||
- UPS and USPS tracking-number recognition with carrier-specific
|
||||
tracking URLs.
|
||||
- Deduplication of repeated tracking numbers inside the same body.
|
||||
- Receipt parsing covering order/confirmation/reference numbers,
|
||||
totals, and currency (USD / EUR / GBP / CAD / AUD plus `$`).
|
||||
- Falls back to stripped HTML when no plain-text body is present.
|
||||
- Handles attachment payloads in any of the three shapes
|
||||
(`ArrayBuffer`, `string`, or `Uint8Array`) by decoding to UTF-8.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { extractData } from "@suaveplan/email-processing/extraction";
|
||||
|
||||
const out = extractData(parsed.text, parsed.html, parsed.attachments);
|
||||
console.log(out.deliveryTracking, out.receipts, out.calendarEvents);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
extractData,
|
||||
extractTrackingInfo,
|
||||
extractReceiptData,
|
||||
} from "@suaveplan/email-processing/extraction";
|
||||
|
||||
// Bulk classify recent inbox messages.
|
||||
const enriched = await Promise.all(
|
||||
messages.map(async (msg) => ({
|
||||
message: msg,
|
||||
signals: extractData(msg.text, msg.html, msg.attachments),
|
||||
}))
|
||||
);
|
||||
const shipments = enriched.flatMap((m) => m.signals.deliveryTracking);
|
||||
const receipts = enriched.flatMap((m) => m.signals.receipts);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DeliveryTracking` — `{ carrier?, trackingNumber, trackingUrl? }`.
|
||||
- `ReceiptData` — `{ orderNumber?, total?, currency?, merchant?, date? }`.
|
||||
- `ExtractedData` — `{ calendarEvents, deliveryTracking, receipts }`.
|
||||
- `extractTrackingInfo(text)` — UPS/USPS hits, deduped.
|
||||
- `extractReceiptData(text)` — single `ReceiptData` or `null`.
|
||||
- `extractData(text?, html?, attachments?)` — orchestrator that
|
||||
combines all three.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`extractData` only falls back to stripped HTML when **no** plain-text
|
||||
body is present so well-formed multipart/alternative messages do not
|
||||
double-count tracking numbers. Attachment decoding tries the simplest
|
||||
shape first (`string`), then `ArrayBuffer`, then `Uint8Array`. The
|
||||
order-pattern alternations are anchored on common e-commerce labels
|
||||
(`order`, `confirmation`, `reference`) plus permissive punctuation;
|
||||
they reject anything that does not include an alphanumeric token
|
||||
after the label so single-word noise does not produce false positives.
|
||||
@@ -0,0 +1,86 @@
|
||||
# dkim-canonicalize
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements RFC 6376 canonicalisation primitives — the byte-exact text
|
||||
normalisation steps DKIM requires before hashing a header set or body.
|
||||
Both `simple` and `relaxed` modes are supported for headers and
|
||||
bodies, plus `buildSignedHeaderBlock` which combines the canonicalised
|
||||
signed headers with the canonicalised `DKIM-Signature` line (sans
|
||||
`b=` value) that callers feed into RSA `verify`.
|
||||
|
||||
## Features
|
||||
|
||||
- `canonicalizeHeaderRelaxed` — lower-case name, collapse folded
|
||||
whitespace, trim, single-space internal runs.
|
||||
- `canonicalizeBodyRelaxed` — CRLF-normalise, trim trailing per-line
|
||||
WS, collapse internal runs, strip trailing empty lines, ensure one
|
||||
final CRLF.
|
||||
- `canonicalizeBodySimple` — CRLF-normalise + ensure exactly one
|
||||
final CRLF.
|
||||
- `buildSignedHeaderBlock` — walks `signedHeaders` in order, indexes
|
||||
repeated headers, removes the `b=` payload of the
|
||||
`DKIM-Signature` line per RFC, and joins with CRLF.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { canonicalizeBodyRelaxed } from "@suaveplan/email-processing/dkim";
|
||||
|
||||
const canon = canonicalizeBodyRelaxed(body);
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(canon)
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildSignedHeaderBlock,
|
||||
canonicalizeBodyRelaxed,
|
||||
} from "@suaveplan/email-processing/dkim";
|
||||
|
||||
const canonHeaders = buildSignedHeaderBlock(headers, sig);
|
||||
const canonBody = canonicalizeBodyRelaxed(body);
|
||||
const bodyHash = b64(
|
||||
await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(canonBody)
|
||||
)
|
||||
);
|
||||
const ok = await crypto.subtle.verify(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
key,
|
||||
sigBytes,
|
||||
new TextEncoder().encode(canonHeaders)
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `canonicalizeHeaderRelaxed(name, value): string` — single-line.
|
||||
- `canonicalizeBodyRelaxed(body): string`.
|
||||
- `canonicalizeBodySimple(body): string`.
|
||||
- `buildSignedHeaderBlock(headers, sig): string`.
|
||||
|
||||
Types `DkimSignature`, `DkimResult`, `DkimVerificationResult`,
|
||||
`DkimDnsResolver` are re-exported from the package root for callers
|
||||
that consume the verifier.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The relaxed body canonicalisation collapses runs of tabs and spaces
|
||||
inside a line **before** stripping trailing whitespace, matching the
|
||||
order specified in RFC 6376 §3.4.4. Empty bodies still emit the
|
||||
mandated trailing CRLF after `simple` canonicalisation so a hash
|
||||
mismatch is not produced by a body that legitimately ends without one.
|
||||
`buildSignedHeaderBlock` keeps a per-name index counter so a signature
|
||||
that lists `from` then `from` retrieves the first and second `From:`
|
||||
header in order — without the counter, the DKIM rebuild would emit the
|
||||
same header twice and verification would fail on otherwise valid mail.
|
||||
@@ -0,0 +1,84 @@
|
||||
# dkim-verifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements DKIM (RFC 6376) signature verification — parses a
|
||||
`DKIM-Signature` header, looks up the signer's public key through an
|
||||
injectable DNS resolver, recomputes the body hash, rebuilds the
|
||||
canonicalised signed-header block, and calls Web Crypto's
|
||||
RSASSA-PKCS1-v1_5 verify. Returns a structured outcome that callers
|
||||
plug into DMARC alignment, spam scoring, and inbox display.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict tag parsing with explicit nullability — missing `v`/`a`/`d`/
|
||||
`s`/`h`/`bh`/`b` returns `null` (treated as `fail` by the
|
||||
verifier).
|
||||
- Defaults `c` to `simple/simple`, accepts shorthand `c=relaxed`
|
||||
(header relaxed, body simple).
|
||||
- Honours optional `l=` body-length tag, `t=` timestamp, `x=`
|
||||
expiration so callers can surface staleness.
|
||||
- DNS resolver is fully injectable — the package never calls `dns.*`
|
||||
directly, so consumers can route through any DoH / cache /
|
||||
`@suaveplan/dns` strategy.
|
||||
- Hard error path: a structurally-broken public-key DNS record raises
|
||||
`EMAIL_PROCESSING.DKIM_VERIFY_FAILURE` rather than silently
|
||||
failing, so observability dashboards distinguish "signer is
|
||||
misconfigured" from "this message did not verify".
|
||||
- OTel counter `email_processing.dkim.result_total` attributed by
|
||||
`result` for every outcome.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { verifyDkim } from "@suaveplan/email-processing/dkim";
|
||||
|
||||
const out = await verifyDkim(headers, body, async (selector, domain) => {
|
||||
const records = await dns.resolveTxt(`${selector}._domainkey.${domain}`);
|
||||
return records[0]?.join("") ?? null;
|
||||
});
|
||||
if (out.result === "pass") authenticate();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
extractDkimHeader,
|
||||
parseDkimSignature,
|
||||
verifyDkim,
|
||||
} from "@suaveplan/email-processing/dkim";
|
||||
|
||||
const raw = extractDkimHeader(headers);
|
||||
if (raw) {
|
||||
const sig = parseDkimSignature(raw);
|
||||
if (sig && sig.expiration && sig.expiration < Date.now() / 1000) {
|
||||
log.warn({ domain: sig.domain }, "stale DKIM signature");
|
||||
}
|
||||
}
|
||||
|
||||
const result = await verifyDkim(headers, body, dnsResolver);
|
||||
metrics.recordDkim(result.domain ?? "unknown", result.result);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseDkimSignature(value): DkimSignature | null`.
|
||||
- `extractDkimHeader(headers): string | null`.
|
||||
- `verifyDkim(headers, body, dnsResolver): Promise<DkimVerificationResult>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The verifier strips the `b=` value from the `DKIM-Signature` header
|
||||
before feeding the canonicalised block into the verify call — this
|
||||
matches RFC 6376 §3.5 and ensures consumers who pass the
|
||||
`DKIM-Signature` header back through unchanged still produce the
|
||||
correct hash. The `catch` arm of the main `try/try-catch` block is
|
||||
intentionally narrow: it re-throws our own `EmailProcessingError` so
|
||||
unrecoverable configuration errors are not silently downgraded to
|
||||
`fail` outcomes, but every other thrown value becomes a `fail` with a
|
||||
diagnostic `error` field.
|
||||
@@ -0,0 +1,78 @@
|
||||
# dmarc-checker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements DMARC (RFC 7489) policy evaluation on top of an upstream
|
||||
SPF result and DKIM verification. Looks up the `_dmarc.<from-domain>`
|
||||
TXT record through an injectable resolver, parses its policy and
|
||||
alignment tags, then determines whether at least one of SPF or DKIM
|
||||
passes **and** aligns with the From header's organisational domain.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseDmarcRecord` returns a strongly-typed `DmarcRecord` with all
|
||||
standard tags (`p`, `sp`, `adkim`, `aspf`, `pct`, `rua`, `ruf`).
|
||||
- `checkDmarc` accepts pre-computed SPF and DKIM outcomes so callers
|
||||
can compose authentication pipelines without re-running expensive
|
||||
DNS or crypto work.
|
||||
- Both alignment modes — strict (exact domain match) and relaxed
|
||||
(organisational-domain match) — are supported.
|
||||
- DNS resolver is async and injectable; the package itself never
|
||||
calls into `dns.*` directly.
|
||||
- Records `email_processing.dmarc.result_total` attributed by
|
||||
`result` for every outcome.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { checkDmarc } from "@suaveplan/email-processing/dmarc";
|
||||
|
||||
const out = await checkDmarc(fromDomain, spfResult, dkimResult, async (d) => {
|
||||
const recs = await dns.resolveTxt(d);
|
||||
return recs.map((p) => p.join(""));
|
||||
});
|
||||
if (out.result === "fail" && out.policy === "reject") {
|
||||
rejectMessage();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { checkDmarc, parseDmarcRecord } from "@suaveplan/email-processing/dmarc";
|
||||
|
||||
const recs = await dnsResolver(`_dmarc.${domain}`);
|
||||
const dmarcText = recs.find((r) => r.startsWith("v=DMARC1")) ?? "";
|
||||
const parsed = parseDmarcRecord(dmarcText);
|
||||
const aggregateUri = parsed?.reportUri;
|
||||
if (aggregateUri) {
|
||||
await scheduleReport({ domain, uri: aggregateUri });
|
||||
}
|
||||
const result = await checkDmarc(domain, spf, dkim, dnsResolver);
|
||||
metrics.recordDmarc(domain, result.result, result.policy);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DmarcPolicy` — `"none" | "quarantine" | "reject"`.
|
||||
- `DmarcAlignment` — `"strict" | "relaxed"`.
|
||||
- `DmarcResult` — `"pass" | "fail" | "none"`.
|
||||
- `DmarcRecord` — full parsed record.
|
||||
- `DmarcCheckResult` — `{ result, policy?, spfAligned, dkimAligned, record?, error? }`.
|
||||
- `parseDmarcRecord(txt)` — strict parser, returns `null` on invalid
|
||||
input.
|
||||
- `checkDmarc(fromDomain, spf, dkim, resolver)` — async orchestrator.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The DMARC TXT record lookup uses the bare `_dmarc.<domain>` form per
|
||||
RFC 7489 §6.6.3 (organisational-domain fallback is left to a higher
|
||||
layer — callers who want PSL-aware fallback should chain a second
|
||||
`checkDmarc` against the parent domain when the first returns
|
||||
`result: "none"` with `error: "No DMARC record found"`). The
|
||||
`reportDmarc` helper centralises OTel emission so every return path
|
||||
records the outcome exactly once.
|
||||
@@ -0,0 +1,84 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry and typed error class for the
|
||||
`@suaveplan/email-processing` package. Every failure raised inside the
|
||||
package goes through `EmailProcessingError`, carrying a discriminant
|
||||
`code` drawn from `EMAIL_PROCESSING_CODES` so callers can branch on the
|
||||
error without parsing a (possibly i18n-keyed) `message` string. The
|
||||
registry is closed: removing a code without removing its only call site
|
||||
is forbidden by `AGENTS.md §2c`, and adding a code requires an
|
||||
OpenSpec change.
|
||||
|
||||
## Features
|
||||
|
||||
- Eleven namespaced codes covering input validation, parse failures,
|
||||
attachment limits, hash failures, DKIM/DMARC/SPF outcomes, calendar
|
||||
parsing, hook-pipeline failures.
|
||||
- `EmailProcessingError` extends `SuaveplanError<EmailProcessingCode>`
|
||||
with a `public` constructor (the base constructor is `protected`).
|
||||
- Compatible with `@suaveplan/error`'s i18n resolver, cause chaining,
|
||||
redaction, OTel span-event adapter, and Zod wire schema.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
EMAIL_PROCESSING_CODES,
|
||||
EmailProcessingError,
|
||||
} from "@suaveplan/email-processing/errors";
|
||||
|
||||
throw new EmailProcessingError(
|
||||
EMAIL_PROCESSING_CODES.INVALID_INPUT,
|
||||
"email_processing.invalid_input"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { EmailProcessingError } from "@suaveplan/email-processing/errors";
|
||||
|
||||
try {
|
||||
await parseEmail(buffer);
|
||||
} catch (err) {
|
||||
if (err instanceof EmailProcessingError) {
|
||||
if (err.code === "EMAIL_PROCESSING.ATTACHMENT_TOO_LARGE") {
|
||||
logger.warn({ code: err.code }, "reject oversized attachment");
|
||||
return;
|
||||
}
|
||||
if (err.code === "EMAIL_PROCESSING.PARSE_FAILURE") {
|
||||
throw new EmailProcessingError(
|
||||
"EMAIL_PROCESSING.PARSE_FAILURE",
|
||||
"email_processing.parse_failure",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EMAIL_PROCESSING_CODES` — frozen literal map, value of each key is
|
||||
the dotted code (e.g. `EMAIL_PROCESSING.INVALID_INPUT`).
|
||||
- `EmailProcessingCode` — union of every `EMAIL_PROCESSING_CODES[K]`.
|
||||
- `EmailProcessingError` — class with `public constructor(code,
|
||||
messageKey, options?)` matching `SuaveplanErrorOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` from `@suaveplan/error` enforces uniqueness, freezes the
|
||||
output, and brands every value with the namespace prefix. The class's
|
||||
`public` constructor is intentional: tests, consumers, and downstream
|
||||
packages need to construct `EmailProcessingError` instances directly,
|
||||
and `SuaveplanError`'s `protected` constructor would block that without
|
||||
the delegating override. The `name` field is set to
|
||||
`"EmailProcessingError"` so stack traces and serialised wire format
|
||||
remain readable.
|
||||
@@ -0,0 +1,99 @@
|
||||
# hooks
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the package's `email:processing` hook pipeline: every parsed
|
||||
email passes through a sequence of handlers that may sanitise, redact,
|
||||
or enrich the parsed result. The implementation is intentionally local
|
||||
to this package — the foundation `@suaveplan/events` library
|
||||
does not (yet) export a shared `Hooks<T>` substrate, so a focused
|
||||
async pipeline lives here. The public shape matches the upstream
|
||||
`@suaveplan/email-processing` package so consumer code does not change.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic `Hooks<TMap>` class with explicit type-safe `hook` /
|
||||
`applyHooksAsync` / `clear` methods.
|
||||
- Handlers run in `order` (default 100, lower first) and may be
|
||||
sync or async — `applyHooksAsync` awaits each return value.
|
||||
- Errors thrown from any handler short-circuit the pipeline and are
|
||||
wrapped in an `EmailProcessingError` with code
|
||||
`EMAIL_PROCESSING.HOOK_FAILURE`. The original throw is preserved
|
||||
on `.cause` when it is an `Error`.
|
||||
- Each invocation increments the `email_processing.hook.invocation_total`
|
||||
OTel counter with `event` and `handlers` attributes.
|
||||
- Returned data is threaded through every handler so transforms compose.
|
||||
- Module-level `emailHooks` singleton + `onEmailProcessing` shorthand.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { onEmailProcessing } from "@suaveplan/email-processing/hooks";
|
||||
|
||||
onEmailProcessing((data) => ({
|
||||
...data,
|
||||
parsed: {
|
||||
...data.parsed,
|
||||
html: data.parsed.html?.replace(
|
||||
/<img[^>]*tracking[^>]*>/gi,
|
||||
""
|
||||
),
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { Hooks, type EmailHookMap } from "@suaveplan/email-processing/hooks";
|
||||
|
||||
const hooks = new Hooks<EmailHookMap>();
|
||||
const offFirst = hooks.hook(
|
||||
"email:processing",
|
||||
async (d) => {
|
||||
await audit.log({ messageId: d.parsed.messageId });
|
||||
return d;
|
||||
},
|
||||
{ order: 10 }
|
||||
);
|
||||
const offRedact = hooks.hook(
|
||||
"email:processing",
|
||||
(d) => ({
|
||||
...d,
|
||||
parsed: { ...d.parsed, text: redact(d.parsed.text ?? "") },
|
||||
}),
|
||||
{ order: 200 }
|
||||
);
|
||||
try {
|
||||
const out = await hooks.applyHooksAsync("email:processing", incoming);
|
||||
return out;
|
||||
} finally {
|
||||
offFirst();
|
||||
offRedact();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EmailProcessingHookData` — pipeline payload (`parsed`, `rawInput?`,
|
||||
`metadata`).
|
||||
- `EmailHookMap` — `{ "email:processing": EmailProcessingHookData }`.
|
||||
- `HookHandler<T>` — `(data: T) => T | Promise<T>`.
|
||||
- `HookHandlerOptions` — `{ order?: number }`.
|
||||
- `Hooks<TMap>` — class with `hook`, `applyHooksAsync`, `clear`.
|
||||
- `emailHooks` — singleton `Hooks<EmailHookMap>` instance.
|
||||
- `onEmailProcessing(handler, options?)` — shorthand registration on
|
||||
`emailHooks`; returns an idempotent unregister.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The unregister function returned from `hook(...)` is idempotent: calling
|
||||
it twice is a no-op so cleanup code in shared test fixtures can run
|
||||
without ordering pitfalls. The pipeline does not call the OTel counter
|
||||
when a handler throws — failures are observed via the
|
||||
`EMAIL_PROCESSING.HOOK_FAILURE` error path, and a separate consumer
|
||||
counter is the appropriate place to record those.
|
||||
@@ -0,0 +1,87 @@
|
||||
# mime-utils
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps the `libmime` library with a strict, typed boundary so the rest
|
||||
of the package can call RFC 2047 / 2231 / 5322 encoding and decoding
|
||||
helpers without using `// @ts-expect-error` or leaking `any` through
|
||||
the consumer surface. Every helper validates its primary string
|
||||
argument and raises an `EmailProcessingError` with code
|
||||
`EMAIL_PROCESSING.INVALID_INPUT` rather than silently calling `libmime`
|
||||
with garbage.
|
||||
|
||||
## Features
|
||||
|
||||
- Eleven wrappers: `encodeWord` / `encodeWords` / `decodeWords`,
|
||||
`decodeHeader` / `decodeHeaders`, `parseHeaderValue` /
|
||||
`buildHeaderValue` / `buildHeaderParam`, `foldLines`,
|
||||
`encodeFlowed` / `decodeFlowed`.
|
||||
- Local ambient declaration (`libmime.d.ts`) so the package compiles
|
||||
without `@types/libmime` while keeping the inner types narrow.
|
||||
- Strict input validation at every entry point — non-string args
|
||||
raise `INVALID_INPUT`, which is testable and observable.
|
||||
- Pass-through of optional parameters such as `maxLength`,
|
||||
`lineLength`, `afterSpace`, `delSp` so callers retain full
|
||||
`libmime` flexibility.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { encodeWord, decodeWords } from "@suaveplan/email-processing/mime-utils";
|
||||
|
||||
const encoded = encodeWord("résumé", "Q");
|
||||
const original = decodeWords(encoded);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseHeaderValue,
|
||||
buildHeaderValue,
|
||||
buildHeaderParam,
|
||||
foldLines,
|
||||
} from "@suaveplan/email-processing/mime-utils";
|
||||
|
||||
const parsed = parseHeaderValue('text/plain; charset="utf-8"; name="r.txt"');
|
||||
const segments = buildHeaderParam("filename*", "réport-2024.pdf", 60);
|
||||
parsed.params.filename = segments.map((s) => s.value).join("");
|
||||
|
||||
const header = foldLines(
|
||||
`Content-Type: ${buildHeaderValue(parsed)}`,
|
||||
76,
|
||||
true
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeWord(str, encoding?, maxLength?)`
|
||||
- `encodeWords(str, encoding?, maxLength?)`
|
||||
- `decodeWords(str)`
|
||||
- `decodeHeader(headerLine)` → `{ key, value }`
|
||||
- `decodeHeaders(headers)` → `Record<string, string[]>`
|
||||
- `parseHeaderValue(valueString)` → `StructuredHeaderValue`
|
||||
- `buildHeaderValue(structured)` → `string`
|
||||
- `buildHeaderParam(key, value, maxLength?)` → `HeaderParam[]`
|
||||
- `foldLines(str, lineLength?, afterSpace?)`
|
||||
- `encodeFlowed(str, lineLength?)`
|
||||
- `decodeFlowed(str, delSp?)`
|
||||
|
||||
All throw `EmailProcessingError` with code
|
||||
`EMAIL_PROCESSING.INVALID_INPUT` when their primary argument is missing
|
||||
or of the wrong type.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`libmime` ships without TypeScript declarations, so this module owns a
|
||||
local `libmime.d.ts` declaring only the subset of functions in use.
|
||||
That keeps the package compatible with strict `verbatimModuleSyntax`
|
||||
and `noUncheckedIndexedAccess` settings without `any` casts. The
|
||||
`buildHeaderValue` guard checks for `null`/`undefined`/non-object
|
||||
rather than `typeof === "string"` because the input is structured —
|
||||
mismatching that guard would let through invalid shapes.
|
||||
@@ -0,0 +1,85 @@
|
||||
# parser
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps `postal-mime` with a strict input/option boundary, OTel
|
||||
instrumentation, structured error codes, and the
|
||||
`@suaveplan/email-processing/hooks` pipeline. `parseEmail` is the
|
||||
package's primary entry point: it takes a raw RFC 822 message in any
|
||||
of the three supported input shapes (string, `ArrayBuffer`,
|
||||
`Uint8Array`) and returns a fully-parsed `ParsedEmail` whose
|
||||
attachments are real MIME parts (no stubs).
|
||||
|
||||
## Features
|
||||
|
||||
- Three input modes — string, `ArrayBuffer`, `Uint8Array` — covering
|
||||
both browser and Node consumers.
|
||||
- Zod-validated options at the boundary, raising
|
||||
`EMAIL_PROCESSING.INVALID_OPTIONS` with the failing issues on
|
||||
`context` so callers can surface diagnostics.
|
||||
- Distinct error codes for `INVALID_INPUT` (null/undefined/wrong type)
|
||||
and `PARSE_FAILURE` (`postal-mime` threw); `parse.failure_total`
|
||||
is incremented for the latter.
|
||||
- Hook pipeline integration — every parse returns the result of
|
||||
`emailHooks.applyHooksAsync("email:processing", …)`.
|
||||
- `parseEmailAddress` and `decodeEmailWords` re-export the
|
||||
`postal-mime` low-level helpers under the package's typed surface,
|
||||
with the same `INVALID_INPUT` guard.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseEmail } from "@suaveplan/email-processing/parser";
|
||||
|
||||
const parsed = await parseEmail(rawMimeString);
|
||||
console.log(parsed.subject, parsed.attachments.length);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseEmail } from "@suaveplan/email-processing/parser";
|
||||
import { onEmailProcessing } from "@suaveplan/email-processing/hooks";
|
||||
|
||||
const off = onEmailProcessing((data) => ({
|
||||
...data,
|
||||
parsed: {
|
||||
...data.parsed,
|
||||
html: data.parsed.html?.replace(/<script[\s\S]*?<\/script>/gi, ""),
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const parsed = await parseEmail(buffer, {
|
||||
attachmentEncoding: "base64",
|
||||
maxNestingDepth: 64,
|
||||
});
|
||||
await persist(parsed);
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseEmail(raw, options?)` — async; returns `ParsedEmail`. Throws
|
||||
`EmailProcessingError` with codes `INVALID_INPUT`,
|
||||
`INVALID_OPTIONS`, or `PARSE_FAILURE`.
|
||||
- `parseEmailAddress(addressString)` — sync; returns `EmailAddress[]`.
|
||||
Throws `EmailProcessingError(INVALID_INPUT)` for non-string input.
|
||||
- `decodeEmailWords(text)` — sync; returns decoded RFC 2047 string.
|
||||
Throws `EmailProcessingError(INVALID_INPUT)` for non-string input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`parseEmail` records `email_processing.parse.total` with the attachment
|
||||
count as an attribute so dashboards can surface average
|
||||
attachments-per-message. Parse failures increment a separate
|
||||
`parse.failure_total` counter (no attachment count is recorded because
|
||||
the parser never reached the attachment array). The Zod options schema
|
||||
is `.strict()` so unknown option keys are rejected — this catches
|
||||
typos at the boundary instead of letting them silently no-op.
|
||||
@@ -0,0 +1,97 @@
|
||||
# spam-scorer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Heuristic spam scorer with a pluggable rule set. The default rules
|
||||
cover the most common spam signals — missing headers, all-caps
|
||||
subjects, common keyword lists, URL counts, low text-to-HTML ratio,
|
||||
DKIM / SPF authentication outcomes — and produce a clamped 0..10
|
||||
score plus a `clean` / `suspicious` / `spam` classification. Custom
|
||||
rules can be merged into the defaults, with conflict resolution by
|
||||
`id`.
|
||||
|
||||
## Features
|
||||
|
||||
- Twelve default rules covering header completeness, content, and
|
||||
authentication signals.
|
||||
- Configurable suspicious / spam thresholds (defaults: 3 / 6).
|
||||
- Custom rules merge with defaults via `id`; same-id custom rules
|
||||
replace the default in place, preserving merge order.
|
||||
- Records `email_processing.spam.scored_total` attributed by
|
||||
`classification`.
|
||||
- Pure: no DNS / network calls, fully deterministic given a fixed
|
||||
`SpamContext`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { scoreSpam } from "@suaveplan/email-processing/spam";
|
||||
|
||||
const result = scoreSpam({
|
||||
subject: parsed.subject,
|
||||
text: parsed.text,
|
||||
html: parsed.html,
|
||||
fromAddress: (parsed.from as { address?: string } | undefined)?.address,
|
||||
headers: parsed.headers,
|
||||
attachmentCount: parsed.attachments.length,
|
||||
});
|
||||
if (result.classification === "spam") quarantine();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
scoreSpam,
|
||||
DEFAULT_SPAM_RULES,
|
||||
type SpamRule,
|
||||
} from "@suaveplan/email-processing/spam";
|
||||
|
||||
const customRules: SpamRule[] = [
|
||||
{
|
||||
id: "competitor-domain",
|
||||
description: "From a known competitor domain",
|
||||
weight: 5,
|
||||
evaluate: (ctx) =>
|
||||
ctx.fromAddress?.endsWith("@competitor.example") ?? false,
|
||||
},
|
||||
];
|
||||
|
||||
const result = scoreSpam(
|
||||
{
|
||||
subject: msg.subject,
|
||||
text: msg.text,
|
||||
html: msg.html,
|
||||
fromAddress: msg.from,
|
||||
headers: msg.headers,
|
||||
attachmentCount: msg.attachments.length,
|
||||
dkimResult: dkim.result,
|
||||
spfResult: spf.result,
|
||||
},
|
||||
{ customRules, suspiciousThreshold: 2, spamThreshold: 5 }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SpamRule` — `{ id, description, weight, evaluate(ctx) }`.
|
||||
- `SpamContext` — full payload passed to each rule.
|
||||
- `SpamScoreResult` — `{ score, triggeredRules, classification }`.
|
||||
- `SpamScorerOptions` — `{ customRules?, suspiciousThreshold?, spamThreshold? }`.
|
||||
- `DEFAULT_SPAM_RULES` — array of the twelve built-in rules.
|
||||
- `scoreSpam(context, options?)` — pure scorer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The scorer clamps the raw weighted sum at `[0, 10]` so callers can
|
||||
rely on a fixed range when piping into a UI badge or alerting
|
||||
threshold. Rule weights are tuned against a hand-classified corpus
|
||||
and intentionally favour precision over recall — false-positive
|
||||
quarantines are more disruptive than false-negative deliveries.
|
||||
Custom rules with same `id` replace the default rule entirely (no
|
||||
weight-sum merge) so consumers retain full control over which rules
|
||||
fire.
|
||||
@@ -0,0 +1,74 @@
|
||||
# spf-checker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the SPF (RFC 7208) evaluation algorithm: parses the
|
||||
record, walks each mechanism in order, follows `include:` and
|
||||
`redirect=` with bounded depth and lookup limits, and matches the
|
||||
candidate IPv4 / IPv6 address against `ip4:` and `ip6:` CIDR ranges.
|
||||
The DNS resolver is fully injectable so the package can be embedded
|
||||
in browsers, edge workers, or sandboxed runtimes that need to route
|
||||
lookups through DoH or a service mesh.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses every mechanism token (`ip4`, `ip6`, `a`, `mx`, `include`,
|
||||
`all`, `exists`, `redirect`) and their qualifier prefixes
|
||||
(`+ - ~ ?`).
|
||||
- Bounded recursion: `MAX_INCLUDE_DEPTH = 10` and `MAX_DNS_LOOKUPS =
|
||||
10` to surface RFC 7208 §4.6.4 permerror conditions instead of
|
||||
hanging.
|
||||
- IPv4 matching with `Number.parseInt` + bitmasks; IPv6 matching by
|
||||
expanding `::` shorthand and comparing bit prefixes.
|
||||
- Records `email_processing.spf.result_total` attributed by `result`
|
||||
for every outcome.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { checkSpf } from "@suaveplan/email-processing/spf";
|
||||
|
||||
const out = await checkSpf(senderIp, fromDomain, async (d) => {
|
||||
const recs = await dns.resolveTxt(d);
|
||||
return recs.map((parts) => parts.join(""));
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { checkSpf, parseSpfRecord } from "@suaveplan/email-processing/spf";
|
||||
|
||||
const recs = await dnsResolver(domain);
|
||||
const v = recs.find((r) => r.startsWith("v=spf1"));
|
||||
if (v) {
|
||||
const mechs = parseSpfRecord(v);
|
||||
if (mechs.some((m) => m.type === "all" && m.qualifier === "+")) {
|
||||
log.warn({ domain }, "open SPF policy (+all)");
|
||||
}
|
||||
}
|
||||
const out = await checkSpf(ip, domain, dnsResolver);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseSpfRecord(record): SpfMechanism[]`.
|
||||
- `ipv4Matches(ip, cidr): boolean`.
|
||||
- `ipv6Matches(ip, cidr): boolean`.
|
||||
- `checkSpf(ip, domain, resolver, depth?, lookupCount?): Promise<SpfCheckResult>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`a` / `mx` / `exists` mechanisms are recognised but treated as
|
||||
non-matches because resolving them requires additional DNS lookups
|
||||
the caller may not want to fund — most real-world SPF records lean on
|
||||
`ip4:` / `ip6:` / `include:` anyway. The `lookupCount` parameter is
|
||||
exposed so callers can compose `checkSpf` across multiple top-level
|
||||
domains in a single run without re-instantiating the budget. The
|
||||
function records the SPF outcome to the OTel counter before returning,
|
||||
including the `permerror` paths, so dashboards can distinguish "bad
|
||||
record" from "no record".
|
||||
@@ -0,0 +1,77 @@
|
||||
# telemetry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the shared OpenTelemetry meter and instrument set used by every
|
||||
public entry point of `@suaveplan/email-processing`. Instruments are
|
||||
constructed lazily on first call, then cached so each metric is bound
|
||||
exactly once per process — this is what makes the package's
|
||||
spec-conformance test (which runs against a real `MeterProvider` +
|
||||
`InMemoryMetricExporter`) deterministic. There are no mocks: the test
|
||||
setup installs a real meter provider before any source module imports.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy, idempotent singleton (`getEmailProcessingInstruments`) with an
|
||||
explicit test-only `resetEmailProcessingInstruments` for re-binding
|
||||
after a fresh meter provider is installed in setup.
|
||||
- Stable instrument names under the `email_processing.*` prefix.
|
||||
- Six counters and one histogram covering parse counts, parse
|
||||
failures, attachment outcomes, attachment extraction duration, hook
|
||||
invocations, DKIM/SPF/DMARC outcomes, spam classification counts,
|
||||
and thread grouping counts.
|
||||
- Strictly typed `EmailProcessingInstruments` interface — counter
|
||||
misuses become typecheck errors at the call site.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getEmailProcessingInstruments } from "@suaveplan/email-processing/otel";
|
||||
|
||||
const m = getEmailProcessingInstruments();
|
||||
m.parseCounter.add(1);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
getEmailProcessingInstruments,
|
||||
resetEmailProcessingInstruments,
|
||||
} from "@suaveplan/email-processing/otel";
|
||||
|
||||
// In a test setup file:
|
||||
resetEmailProcessingInstruments();
|
||||
const meter = getEmailProcessingInstruments();
|
||||
const start = performance.now();
|
||||
await processAttachments(parsed.attachments);
|
||||
meter.attachmentExtractionDurationMs.record(performance.now() - start, {
|
||||
"attachment.count": parsed.attachments.length,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EmailProcessingInstruments` — readonly interface listing every
|
||||
counter and histogram, plus the underlying `meter`.
|
||||
- `getEmailProcessingInstruments(): EmailProcessingInstruments` — returns
|
||||
the cached instrument set, building it on first call.
|
||||
- `resetEmailProcessingInstruments(): void` — clears the cache so the
|
||||
next `getEmailProcessingInstruments()` rebuilds against whatever
|
||||
`MeterProvider` is currently registered via `metrics.setGlobal*`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are constructed against `metrics.getMeter(...)`, which
|
||||
defers to whichever `MeterProvider` was last installed via
|
||||
`metrics.setGlobalMeterProvider`. Tests install an
|
||||
`InMemoryMetricExporter`-backed provider in `tests/setup/polyfills.ts`
|
||||
before any source module loads; production code installs a real
|
||||
exporter via the application bootstrap. The histogram unit is `"ms"`
|
||||
and intentionally simple: the package emits durations in milliseconds
|
||||
from `performance.now()` deltas, so dashboards can read directly without
|
||||
unit conversion.
|
||||
@@ -0,0 +1,91 @@
|
||||
# thread-grouper
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Groups a flat list of emails into conversation threads using
|
||||
In-Reply-To, References, and subject-line heuristics. Each thread is
|
||||
returned with its full chronological message list plus a parent /
|
||||
child tree rooted at the earliest message, so consumers can render
|
||||
either a flat timeline or a nested conversation view.
|
||||
|
||||
## Features
|
||||
|
||||
- Parent resolution prefers `In-Reply-To` and falls back to the last
|
||||
`References` entry.
|
||||
- Subject-line normalisation strips `Re:`, `Fwd:`, `Aw:`, `Sv:`, `Vs:`
|
||||
prefixes (with optional `[N]` ticket-tracker suffixes).
|
||||
- A reply with a significantly different subject starts a new thread
|
||||
even when it references the original — heuristic catches
|
||||
"hijacked" reply chains.
|
||||
- Stable chronological ordering via `Date.parse` on the email `date`
|
||||
field (epoch-zero when missing, matching upstream behaviour).
|
||||
- Records `email_processing.thread.grouped_total` with the produced
|
||||
thread count per call.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { groupThreads } from "@suaveplan/email-processing/threading";
|
||||
|
||||
const threads = groupThreads(
|
||||
messages.map((m) => ({
|
||||
messageId: m.messageId ?? "",
|
||||
inReplyTo: m.inReplyTo,
|
||||
references: m.references,
|
||||
subject: m.subject,
|
||||
date: m.date,
|
||||
}))
|
||||
);
|
||||
for (const t of threads) {
|
||||
console.log(t.threadId, t.subject, t.messageCount);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
groupThreads,
|
||||
normalizeSubject,
|
||||
isSubjectChange,
|
||||
parseReferences,
|
||||
} from "@suaveplan/email-processing/threading";
|
||||
|
||||
const threads = groupThreads(messages);
|
||||
|
||||
function flatten(node) {
|
||||
return [node.message, ...node.children.flatMap(flatten)];
|
||||
}
|
||||
|
||||
const summary = threads.map((t) => ({
|
||||
threadId: t.threadId,
|
||||
subject: normalizeSubject(t.subject ?? ""),
|
||||
flat: flatten(t.root).map((m) => m.messageId),
|
||||
refs: parseReferences(t.messages.at(-1)?.references),
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ThreadableEmail` — `{ messageId, inReplyTo?, references?, subject?,
|
||||
date?, from? }`.
|
||||
- `ThreadNode` — `{ message, children }`.
|
||||
- `EmailThread` — `{ threadId, subject?, root, messageCount, messages }`.
|
||||
- `normalizeSubject(subject)`.
|
||||
- `parseReferences(references?)` — `<id>` extraction.
|
||||
- `isSubjectChange(original, reply)` — heuristic.
|
||||
- `groupThreads(emails)` — returns chronological `EmailThread[]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`buildTree` falls back to a `"messageId: ''"` placeholder root only
|
||||
when the input array is unexpectedly empty — `groupThreads` itself
|
||||
short-circuits empty input, so the fallback exists purely for type
|
||||
safety. The grouper's chronological pass uses the most-recent
|
||||
reference (last entry in `References`) when `In-Reply-To` is absent,
|
||||
matching most MUA conventions. Subject-change detection is
|
||||
case-insensitive and ignores whitespace differences.
|
||||
@@ -0,0 +1,118 @@
|
||||
# @suaveplan/email-templates - Universal React-free email template engine: VNode factories, nine reusable components, five built-in templates (welcome, verification, password-reset, notification, invoice), typed registry, fluent builder, theme system, structured errors, OTel instruments. Produces email-safe HTML strings with inline styles; runs in Bun, Node, and browser.
|
||||
|
||||
Universal, React-free email template engine. Produces email-safe HTML
|
||||
strings with inline styles via VNode factories — runs identically in Bun,
|
||||
Node, and the browser.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [email-button](./src/components/email-button/email-button.md) — `EmailButton` is the canonical call-to-action surface in an email body — a table-wrapped `<a>` with variant-driven them…
|
||||
- [email-button](./src/components/email-button/email-button.md) — `EmailButton` is the canonical call-to-action surface in an email body — a table-wrapped `<a>` with variant-driven them…
|
||||
- [email-container](./src/components/email-container/email-container.md) — `EmailContainer` groups related content inside an email body — a callout box, an info banner, a code-card. It emits a s…
|
||||
- [email-container](./src/components/email-container/email-container.md) — `EmailContainer` groups related content inside an email body — a callout box, an info banner, a code-card. It emits a s…
|
||||
- [email-divider](./src/components/email-divider/email-divider.md) — A horizontal divider rendered as a single-row `<table>` whose `<td>` carries `border-top: 1px solid <color>`. The table…
|
||||
- [email-divider](./src/components/email-divider/email-divider.md) — A horizontal divider rendered as a single-row `<table>` whose `<td>` carries `border-top: 1px solid <color>`. The table…
|
||||
- [email-footer](./src/components/email-footer/email-footer.md) — Renders the email footer block — company copyright, optional address, optional list of inline links (typically Privacy…
|
||||
- [email-footer](./src/components/email-footer/email-footer.md) — Renders the email footer block — company copyright, optional address, optional list of inline links (typically Privacy…
|
||||
- [email-heading](./src/components/email-heading/email-heading.md) — Renders an `<h1>`…`<h6>` element with a level-keyed font-size and line-height schedule tuned for inbox-rendering ergono…
|
||||
- [email-heading](./src/components/email-heading/email-heading.md) — Renders an `<h1>`…`<h6>` element with a level-keyed font-size and line-height schedule tuned for inbox-rendering ergono…
|
||||
- [email-image](./src/components/email-image/email-image.md) — Renders an `<img>` inside a centring `<table>` with the canonical responsive-email defaults (`display:block`, `max-widt…
|
||||
- [email-image](./src/components/email-image/email-image.md) — Renders an `<img>` inside a centring `<table>` with the canonical responsive-email defaults (`display:block`, `max-widt…
|
||||
- [email-layout](./src/components/email-layout/email-layout.md) — `EmailLayout` is the root VNode every email template wraps its body in. It emits a 100%-width `<table>` with a themed m…
|
||||
- [email-layout](./src/components/email-layout/email-layout.md) — `EmailLayout` is the root VNode every email template wraps its body in. It emits a 100%-width `<table>` with a themed m…
|
||||
- [email-link](./src/components/email-link/email-link.md) — Themed inline `<a>` link with safe defaults (`target="_blank"`, `rel="noopener noreferrer"`) for body-copy hyperlinks.…
|
||||
- [email-link](./src/components/email-link/email-link.md) — Themed inline `<a>` link with safe defaults (`target="_blank"`, `rel="noopener noreferrer"`) for body-copy hyperlinks.…
|
||||
- [email-text](./src/components/email-text/email-text.md) — Paragraph component for email-body copy. `EmailText` emits a themed `<p>` with one of three size variants (`body` / `ca…
|
||||
- [email-text](./src/components/email-text/email-text.md) — Paragraph component for email-body copy. `EmailText` emits a themed `<p>` with one of three size variants (`body` / `ca…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/email-templates`. Every failure raised by the package extends `SuaveplanErro…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/email-templates`. Every failure raised by the package extends `SuaveplanErro…
|
||||
- [message](./src/message/message.md) — `createEmailMessage` glues the renderer to the addressing envelope, producing an `EmailMessageResult` plain object suit…
|
||||
- [message](./src/message/message.md) — `createEmailMessage` glues the renderer to the addressing envelope, producing an `EmailMessageResult` plain object suit…
|
||||
- [renderer](./src/renderer/renderer.md) — The renderer is the bridge between the `EmailTemplate<TProps>` contract and a final `{ html, text, subject }` triple re…
|
||||
- [renderer](./src/renderer/renderer.md) — The renderer is the bridge between the `EmailTemplate<TProps>` contract and a final `{ html, text, subject }` triple re…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Exposes the OpenTelemetry instrument set published by `@suaveplan/email-templates`. Every public render or registry mut…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — Exposes the OpenTelemetry instrument set published by `@suaveplan/email-templates`. Every public render or registry mut…
|
||||
- [template-builder](./src/template-builder/template-builder.md) — `TemplateBuilder` is a fluent API for assembling an `EmailTemplate<TProps>` value step-by-step. It exists because const…
|
||||
- [template-builder](./src/template-builder/template-builder.md) — `TemplateBuilder` is a fluent API for assembling an `EmailTemplate<TProps>` value step-by-step. It exists because const…
|
||||
- [template-registry](./src/template-registry/template-registry.md) — `TemplateRegistry` is a type-safe in-memory store for `EmailTemplate` values keyed by name. It supports per-name lookup…
|
||||
- [template-registry](./src/template-registry/template-registry.md) — `TemplateRegistry` is a type-safe in-memory store for `EmailTemplate` values keyed by name. It supports per-name lookup…
|
||||
- [invoice](./src/templates/invoice/invoice.md) — The `invoiceTemplate` is the receipt / invoice / order-confirmation email. It renders a heading carrying the invoice nu…
|
||||
- [invoice](./src/templates/invoice/invoice.md) — The `invoiceTemplate` is the receipt / invoice / order-confirmation email. It renders a heading carrying the invoice nu…
|
||||
- [notification](./src/templates/notification/notification.md) — The `notificationTemplate` is the generic system-notification email — a configurable severity callout suitable for prod…
|
||||
- [notification](./src/templates/notification/notification.md) — The `notificationTemplate` is the generic system-notification email — a configurable severity callout suitable for prod…
|
||||
- [password-reset](./src/templates/password-reset/password-reset.md) — The `passwordResetTemplate` is the canonical password-reset message. It explains the request, exposes a reset CTA, comm…
|
||||
- [password-reset](./src/templates/password-reset/password-reset.md) — The `passwordResetTemplate` is the canonical password-reset message. It explains the request, exposes a reset CTA, comm…
|
||||
- [verification](./src/templates/verification/verification.md) — The `verificationTemplate` is the standard email-address verification flow message. It greets the recipient, optionally…
|
||||
- [verification](./src/templates/verification/verification.md) — The `verificationTemplate` is the standard email-address verification flow message. It greets the recipient, optionally…
|
||||
- [welcome](./src/templates/welcome/welcome.md) — The `welcomeTemplate` is the canonical post-signup onboarding email. It greets the recipient by name, summarises why th…
|
||||
- [welcome](./src/templates/welcome/welcome.md) — The `welcomeTemplate` is the canonical post-signup onboarding email. It greets the recipient by name, summarises why th…
|
||||
- [theme](./src/theme/theme.md) — Provides the theme tokens consumed by every email component — colours, typography, spacing, branding, border-radius, an…
|
||||
- [theme](./src/theme/theme.md) — Provides the theme tokens consumed by every email component — colours, typography, spacing, branding, border-radius, an…
|
||||
- [vnode](./src/vnode/vnode.md) — Defines the universal VNode primitive — a `{ tag, attrs, children }` triple plus a pure-string HTML renderer — tha…
|
||||
- [vnode](./src/vnode/vnode.md) — Defines the universal VNode primitive — a `{ tag, attrs, children }` triple plus a pure-string HTML renderer — tha…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What's included
|
||||
|
||||
- **Nine reusable components** — `EmailLayout`, `EmailContainer`,
|
||||
`EmailHeading`, `EmailText`, `EmailLink`, `EmailImage`, `EmailDivider`,
|
||||
`EmailButton`, `EmailFooter`.
|
||||
- **Five built-in templates** — `welcomeTemplate`, `verificationTemplate`,
|
||||
`passwordResetTemplate`, `notificationTemplate`, `invoiceTemplate`.
|
||||
- **VNode core** — `h(tag, attrs, children)` hyperscript factory and a
|
||||
pure-string `renderVNode` walker.
|
||||
- **Renderer** — `renderToHtml(template, props, options?)` → `{ html, text, subject }`
|
||||
with OTel instrumentation.
|
||||
- **Theme system** — `defaultTheme`, `createTheme`, `mergeThemes`.
|
||||
- **Type-safe registry** — `TemplateRegistry<TMap>` with category / tag /
|
||||
author search.
|
||||
- **Fluent builder** — `TemplateBuilder<TProps>` / `createTemplateBuilder<TProps>()`.
|
||||
- **Adapter** — `createEmailMessage(template, props, recipient, options?)`
|
||||
returning a plain-object envelope ready for SMTP / SES / SendGrid.
|
||||
- **Structured errors** — `EmailTemplatesError` with six `EMAIL_TEMPLATES.*` codes.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
createEmailMessage,
|
||||
welcomeTemplate,
|
||||
} from "@suaveplan/email-templates";
|
||||
|
||||
const message = createEmailMessage(
|
||||
welcomeTemplate,
|
||||
{ recipientName: "Jane", ctaUrl: "https://app.example.test/get-started" },
|
||||
{ to: "jane@example.test", from: "hello@suaveplan.com" },
|
||||
);
|
||||
|
||||
await mailer.send(message);
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Upstream `@suaveplan/email-templates` was a React component library
|
||||
rendered via `react-dom/server`. The port honours the enterprise
|
||||
spec §3 ("Zero browser / Node / React dependencies") by re-implementing
|
||||
the full upstream surface as **VNode factories** — pure functions
|
||||
returning `{ tag, attrs, children }` triples walked by a single
|
||||
HTML-emitter (`renderVNode`).
|
||||
|
||||
The renderer is the sole point at which child text is HTML-escaped and
|
||||
attributes are serialised. Components emit data; escaping happens once.
|
||||
|
||||
## Universal
|
||||
|
||||
- Zero React / DOM / Node-specific dependencies at runtime.
|
||||
- Injectable `Clock` (`@suaveplan/types`) for deterministic footer year.
|
||||
- OTel instruments via `@opentelemetry/api`.
|
||||
- Pure-TypeScript implementation, 100% statement / branch / function /
|
||||
line coverage.
|
||||
|
||||
## Sub-path exports
|
||||
|
||||
`@suaveplan/email-templates/vnode`, `…/theme`, `…/renderer`, `…/message`,
|
||||
`…/template-builder`, `…/template-registry`, `…/components`, `…/templates`,
|
||||
`…/errors`, `…/otel` — each exposes a focused slice of the surface for
|
||||
tree-shake friendliness.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# email-button
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EmailButton` is the canonical call-to-action surface in an email body —
|
||||
a table-wrapped `<a>` with variant-driven theme colours and three preset
|
||||
sizes. The table wrapper is non-optional: Outlook on Windows uses a
|
||||
Word-derived rendering engine that historically ignores `padding` on
|
||||
inline anchors, so the only reliable way to ship a visually-correct CTA
|
||||
into Outlook desktop is a `<table>` containing a centred `<td>` containing
|
||||
the styled `<a>`.
|
||||
|
||||
The component centralises the variant + size recipe so callers don't
|
||||
duplicate it at every CTA site, and validates `variant`/`size` against
|
||||
the published enums at call time — typos turn into a typed
|
||||
`EmailTemplatesError(INVALID_OPTIONS)` instead of a silently-malformed
|
||||
button.
|
||||
|
||||
## Features
|
||||
|
||||
- Three variants — `primary`, `secondary`, `outline` — each pulling
|
||||
colours from the active theme.
|
||||
- Three sizes — `sm`, `md`, `lg` — each driving distinct padding +
|
||||
font-size.
|
||||
- `fullWidth` toggle emits `width: 100%` on both the wrapping `<table>`
|
||||
and the `<a>` so the CTA spans its container.
|
||||
- `align` prop emitted as the table's `align` attribute.
|
||||
- Validation: unknown variant or size raises
|
||||
`EmailTemplatesError(INVALID_OPTIONS)` and bumps an OTel counter
|
||||
tagged `reason="invalid_button_variant"` or `"invalid_button_size"`.
|
||||
- Safe link defaults (`target="_blank"`, `rel="noopener noreferrer"`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailButton } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailButton({
|
||||
href: "https://app.example.test/get-started",
|
||||
children: "Get Started",
|
||||
}));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A full-width secondary CTA with custom letter-spacing:
|
||||
|
||||
```ts
|
||||
import { EmailButton } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailButton({
|
||||
href: "https://app.example.test/upgrade",
|
||||
children: "Upgrade Now",
|
||||
variant: "secondary",
|
||||
size: "lg",
|
||||
fullWidth: true,
|
||||
style: { "letter-spacing": "0.5px" },
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ----------- | --------------------------------- | ---------------------------------------------------- |
|
||||
| `href` | `string` | CTA target URL. |
|
||||
| `children` | `string` | Button label (string only — keeps the table cell-tight). |
|
||||
| `theme` | `EmailTheme?` | Theme override. |
|
||||
| `variant` | `"primary" \| "secondary" \| "outline"` | Defaults to `primary`. |
|
||||
| `size` | `"sm" \| "md" \| "lg"` | Defaults to `md`. |
|
||||
| `fullWidth` | `boolean?` | Defaults to `false`. |
|
||||
| `align` | `"left" \| "center" \| "right"` | Outer table align; defaults to `"center"`. |
|
||||
| `style` | `StyleObject?` | Additional anchor-level inline styles. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The variant style returns three keys (`background-color`, `color`,
|
||||
`border`) plus the size returns two (`padding`, `font-size`); a switch
|
||||
on the variant tag emits the right triple. Validation uses a `Set`
|
||||
lookup — branch coverage is satisfied by one true-case test plus one
|
||||
false-case test per validator.
|
||||
|
||||
The `box-sizing: border-box` declaration on the anchor ensures the
|
||||
2 px border doesn't push the button over its intended outside width when
|
||||
`fullWidth: true`. Without that declaration Outlook would render the
|
||||
button slightly wider than its container in the `outline` variant where
|
||||
the border weight matters most.
|
||||
@@ -0,0 +1,76 @@
|
||||
# email-container
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EmailContainer` groups related content inside an email body — a callout
|
||||
box, an info banner, a code-card. It emits a single-row table wrapping a
|
||||
themed `<td>` with configurable padding, background colour, alignment, and
|
||||
optional style overrides. Containers stack vertically inside an
|
||||
`EmailLayout`'s content cell.
|
||||
|
||||
The component is intentionally table-based: `display: flex` and CSS grid
|
||||
do not render reliably in Outlook 2007–2019, so the canonical email-design
|
||||
recipe is a `<table role="presentation">` per group with explicit
|
||||
`cellpadding`/`cellspacing` zeroed.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `padding` (defaults to `theme.spacing.md`), `background`,
|
||||
`align` (`left` / `center` / `right`), and arbitrary `style` overrides.
|
||||
- Themed border-radius emitted on the `<td>`.
|
||||
- `width: 100%` so containers fill their parent cell.
|
||||
- Margin-bottom of `theme.spacing.md` baked in so stacked containers
|
||||
retain breathing room without each caller adding spacing manually.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailContainer } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailContainer({ background: "#fff7ed", children: "Heads up!" }));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Tinted callout with custom padding and a left accent border:
|
||||
|
||||
```ts
|
||||
import { EmailContainer } from "@suaveplan/email-templates/components";
|
||||
import { EmailText } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailContainer({
|
||||
background: "#fffbeb",
|
||||
padding: "24px",
|
||||
style: { "border-left": "4px solid #d97706" },
|
||||
children: EmailText({ children: "Action required: verify your email." }),
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ------------- | --------------------------------- | -------------------------------------------------------- |
|
||||
| `children` | `EmailChildren?` | Inner content. |
|
||||
| `theme` | `EmailTheme?` | Theme override. |
|
||||
| `padding` | `string?` | CSS padding; defaults to `theme.spacing.md`. |
|
||||
| `background` | `string?` | CSS `background-color`. |
|
||||
| `align` | `"left" \| "center" \| "right"` | Cell `align` attribute; defaults to `"left"`. |
|
||||
| `style` | `StyleObject?` | Additional inline styles merged into the `<td>`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Caller-supplied `style` is merged **after** the component's own style
|
||||
keys, so it can override any of them. This is intentional: a caller who
|
||||
needs `border-radius: 0` (e.g. a full-width accent strip) should be able
|
||||
to override the themed default without forking the component.
|
||||
|
||||
The wrapping `<table>` carries `role="presentation"` to mark it as
|
||||
layout-only for assistive tech. The `<td>`'s `align` attribute (not just
|
||||
`text-align`) is set because Outlook honours the legacy attribute more
|
||||
reliably than the CSS shorthand inside a cell context.
|
||||
@@ -0,0 +1,66 @@
|
||||
# email-divider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A horizontal divider rendered as a single-row `<table>` whose `<td>`
|
||||
carries `border-top: 1px solid <color>`. The table-with-border-top
|
||||
approach renders consistently across every email client — Outlook,
|
||||
Gmail, Apple Mail, Outlook.com, mobile clients — while a plain `<hr>`
|
||||
historically produced wildly different visual weights (some clients
|
||||
emitting a 1 px line, others a 2 px etched groove, still others a 3D
|
||||
ridge).
|
||||
|
||||
## Features
|
||||
|
||||
- Themed colour by default (`theme.colors.border`); per-divider override
|
||||
via the `color` prop.
|
||||
- Margin top + bottom of `theme.spacing.md` baked in.
|
||||
- 1 px collapse-safe sizing (`font-size: 1px`, `line-height: 1px`)
|
||||
prevents the empty cell from inflating the divider thickness in
|
||||
clients that auto-fill empty `<td>` with the inherited font-size.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailDivider } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailDivider({}));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A coloured divider with extra top margin to separate two sections:
|
||||
|
||||
```ts
|
||||
import { EmailDivider } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailDivider({
|
||||
color: "#22d3ee",
|
||||
style: { "margin-top": "40px", "margin-bottom": "40px" },
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ------- | -------------- | -------------------------------------------------------- |
|
||||
| `theme` | `EmailTheme?` | Theme override (border colour, spacing). |
|
||||
| `color` | `string?` | Explicit divider colour; overrides `theme.colors.border`. |
|
||||
| `style` | `StyleObject?` | Additional `<td>` inline styles. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cell contains a single non-breaking space character (rendered as a
|
||||
literal space) to satisfy the few clients that collapse empty `<td>`
|
||||
into zero-height boxes. Combined with the 1 px font-size + line-height,
|
||||
the cell renders as the border alone — exactly 1 px tall.
|
||||
|
||||
The `<table>` wrapper uses `width: 100%` so the divider spans whatever
|
||||
column it lives in. Vertical margins come from the table, not the cell,
|
||||
so the divider sits inside the layout's vertical rhythm correctly.
|
||||
@@ -0,0 +1,92 @@
|
||||
# email-footer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Renders the email footer block — company copyright, optional address,
|
||||
optional list of inline links (typically Privacy / Terms / Help), and an
|
||||
optional unsubscribe link. The year is derived from an **injectable
|
||||
`Clock`** so the module has no implicit `Date` dependency at the
|
||||
component boundary — a critical property for snapshot-stable tests and
|
||||
deterministic re-renders.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `Clock` (defaults to a real `Date.now()` clock); the copyright
|
||||
year is `new Date(clock.now()).getUTCFullYear()` so the value is
|
||||
timezone-stable.
|
||||
- Company name falls back to `theme.branding.companyName` when the prop
|
||||
is omitted; explicit prop overrides the theme.
|
||||
- Optional `address` paragraph and `unsubscribeUrl` anchor.
|
||||
- Optional `links` list rendered as a centred paragraph separated by
|
||||
` · ` mid-dots — Apple Mail and Gmail render the mid-dot reliably.
|
||||
- Themed muted-foreground colour, top border, and padding.
|
||||
- `children` slot for custom footer copy (rendered before the boilerplate).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailFooter } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailFooter({
|
||||
companyName: "Acme Corp",
|
||||
unsubscribeUrl: "https://acme.test/unsub?token=abc",
|
||||
}));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A footer with custom legal copy, link row, address, and an injected
|
||||
clock for deterministic test output:
|
||||
|
||||
```ts
|
||||
import { EmailFooter } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
const fakeClock = { now: () => 1735689600000 }; // 2025-01-01
|
||||
|
||||
renderVNode(EmailFooter({
|
||||
clock: fakeClock,
|
||||
companyName: "Acme Corp",
|
||||
address: "1 Acme Way, Springfield, IL 62701, USA",
|
||||
links: [
|
||||
{ label: "Privacy", href: "https://acme.test/privacy" },
|
||||
{ label: "Terms", href: "https://acme.test/terms" },
|
||||
{ label: "Help", href: "https://acme.test/help" },
|
||||
],
|
||||
unsubscribeUrl: "https://acme.test/unsub?token=abc",
|
||||
children: "You're receiving this because you opted in on Jan 1.",
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ----------------- | --------------------------------- | ---------------------------------------------------- |
|
||||
| `theme` | `EmailTheme?` | Theme override. |
|
||||
| `clock` | `Clock?` | Injectable wall-clock for copyright year. |
|
||||
| `companyName` | `string?` | Overrides `theme.branding.companyName`. |
|
||||
| `address` | `string?` | Postal address line. |
|
||||
| `unsubscribeUrl` | `string?` | If set, emit an Unsubscribe anchor. |
|
||||
| `links` | `EmailFooterLink[]?` | Inline link row (Privacy / Terms / Help / …). |
|
||||
| `children` | `EmailChildren?` | Custom copy rendered before the boilerplate block. |
|
||||
| `style` | `StyleObject?` | Additional inline styles on the footer `<td>`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`year` is computed via `new Date(clock.now()).getUTCFullYear()` —
|
||||
`getUTCFullYear()` (not `getFullYear()`) is chosen so the rendered year
|
||||
matches the UTC calendar day, which is what the test corpus asserts.
|
||||
The choice avoids the edge case where a non-UTC server renders
|
||||
`new Date()` near year-end and produces a `getFullYear()` value that
|
||||
disagrees with the message header `Date:`.
|
||||
|
||||
The `children` slot accepts arbitrary `EmailChildren`. The
|
||||
implementation re-wraps `children` into a passthrough VNode and lifts
|
||||
its flattened children list into the footer cell — this handles the
|
||||
array case (where `children` itself is `[node, node]`) without
|
||||
re-implementing the flatten logic.
|
||||
@@ -0,0 +1,77 @@
|
||||
# email-heading
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Renders an `<h1>`…`<h6>` element with a level-keyed font-size and
|
||||
line-height schedule tuned for inbox-rendering ergonomics. The default
|
||||
level is `1`. Levels outside the `[1..6]` integer range raise
|
||||
`EmailTemplatesError(INVALID_HEADING_LEVEL)` — silently falling back to
|
||||
`h6` (the upstream behaviour) hides typos that would otherwise produce
|
||||
the wrong semantic structure.
|
||||
|
||||
The font-size schedule (`28px`, `24px`, `20px`, `18px`, `16px`, `14px`)
|
||||
is the conventional email-design ladder. Line-heights tighten as size
|
||||
grows so larger headings don't pull excessive vertical whitespace into
|
||||
the body.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-level font-size + line-height schedule baked in.
|
||||
- Themed `font-family`, `color`, and bottom margin (`theme.spacing.md`).
|
||||
- `align` prop (`left` / `center` / `right`) emitted as `text-align`.
|
||||
- `style` override merged after component defaults.
|
||||
- Validation: `INVALID_HEADING_LEVEL` for non-integer / out-of-range
|
||||
values; OTel counter (`validation_failure_total{reason="invalid_heading_level"}`)
|
||||
bumped on rejection.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailHeading } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailHeading({ children: "Welcome aboard" }));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A right-aligned subheading with a brand colour:
|
||||
|
||||
```ts
|
||||
import { EmailHeading } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailHeading({
|
||||
level: 2,
|
||||
align: "right",
|
||||
style: { color: "#22d3ee" },
|
||||
children: "Subtitle",
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ---------- | --------------------------------- | ---------------------------------------------------- |
|
||||
| `children` | `EmailChildren?` | Heading text. |
|
||||
| `level` | `HeadingLevel (1..6)?` | Heading tag selector; defaults to `1`. |
|
||||
| `theme` | `EmailTheme?` | Theme override. |
|
||||
| `align` | `"left" \| "center" \| "right"` | `text-align` CSS value. |
|
||||
| `style` | `StyleObject?` | Additional inline styles. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The font-weight is set to `700` (semibold-bold) regardless of level —
|
||||
emails benefit from slightly heavier headings than web because client
|
||||
rendering pipelines often anti-alias more aggressively, washing out
|
||||
medium-weight type. Components that need a light variant should override
|
||||
via `style: { "font-weight": 500 }`.
|
||||
|
||||
Validation happens at call time (synchronously). The OTel counter bump
|
||||
gives operators an early signal when a deployment is regressing into
|
||||
out-of-range heading levels — typically caused by an int-overflow bug in
|
||||
a template-mapping layer upstream.
|
||||
@@ -0,0 +1,85 @@
|
||||
# email-image
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Renders an `<img>` inside a centring `<table>` with the canonical
|
||||
responsive-email defaults (`display:block`, `max-width:100%`,
|
||||
`border:0`, `outline:none`). The `alt` attribute is required — missing
|
||||
alt text breaks accessibility and triggers spam-filter heuristics in
|
||||
several large providers.
|
||||
|
||||
The component is the right choice for hero images, illustrations, and
|
||||
inline graphics. For brand logos that benefit from MSO-specific
|
||||
fall-throughs, callers may render an additional `<v:image>` block
|
||||
wrapped in a `<!--[if mso]>` conditional via the renderer's `rawHtml`
|
||||
helper.
|
||||
|
||||
## Features
|
||||
|
||||
- Required `alt` prop (TypeScript will refuse to compile a missing alt).
|
||||
- Optional pixel `width` and `height` set as both HTML attributes and
|
||||
CSS values for Outlook-on-desktop compatibility.
|
||||
- `display:block` removes the inline-baseline gap that produces a thin
|
||||
border below the image in some clients.
|
||||
- `max-width:100%` makes images responsive — they scale down inside a
|
||||
narrower container (mobile) but cap at their natural size on desktop.
|
||||
- Wrapper `<table>` honours the `align` prop (`center` by default).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailImage } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailImage({
|
||||
src: "https://cdn.example.test/hero.png",
|
||||
alt: "A welcoming illustration",
|
||||
width: 600,
|
||||
height: 300,
|
||||
}));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A left-aligned logo with rounded corners:
|
||||
|
||||
```ts
|
||||
import { EmailImage } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailImage({
|
||||
src: "https://cdn.example.test/logo.png",
|
||||
alt: "Acme",
|
||||
width: 120,
|
||||
height: 40,
|
||||
align: "left",
|
||||
style: { "border-radius": "4px" },
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --------- | --------------------------------- | ---------------------------------------------------- |
|
||||
| `src` | `string` | Image URL. |
|
||||
| `alt` | `string` | Accessible alt text — required. |
|
||||
| `width` | `number?` | Pixel width (HTML + CSS). |
|
||||
| `height` | `number?` | Pixel height (HTML + CSS). |
|
||||
| `theme` | `EmailTheme?` | Theme override (used for the bottom margin). |
|
||||
| `align` | `"left" \| "center" \| "right"` | Cell alignment; defaults to `"center"`. |
|
||||
| `style` | `StyleObject?` | Additional `<img>` inline styles. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `<img>` is wrapped in a single-row `<table>` because Outlook
|
||||
honours the table's `align` attribute reliably; CSS `text-align` on a
|
||||
plain `<img>` would behave inconsistently. The wrapping `<td>` carries
|
||||
the alignment hint while the `<img>` itself stays block-level.
|
||||
|
||||
`width` and `height` set both the HTML attributes and the inline CSS
|
||||
because Outlook-on-Windows preserves the HTML attribute while modern
|
||||
clients use the CSS; the two values must agree.
|
||||
@@ -0,0 +1,80 @@
|
||||
# email-layout
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EmailLayout` is the root VNode every email template wraps its body in. It
|
||||
emits a 100%-width `<table>` with a themed muted background, an inner
|
||||
max-width table centred horizontally, and an optional invisible preview-text
|
||||
block that surfaces a snippet in the recipient's inbox-list view (Gmail,
|
||||
Apple Mail, etc.) without appearing in the rendered body.
|
||||
|
||||
The table-in-table structure is the canonical email-layout idiom: full-width
|
||||
clients (Outlook on desktop) honour the inner `<table>`'s pixel `max-width`
|
||||
where most CSS would be stripped, while modern clients honour both and the
|
||||
result looks identical.
|
||||
|
||||
## Features
|
||||
|
||||
- Table-in-table structure with `role="presentation"` so screen readers
|
||||
treat the markup as layout, not tabular data.
|
||||
- Optional `preview` prop rendering an invisible snippet (`display:none;
|
||||
max-height:0; overflow:hidden`) the inbox list samples.
|
||||
- Themed background, font-family, font-size, line-height, and foreground
|
||||
colour applied to the outer table; themed border, border-radius, and
|
||||
max-width applied to the inner table.
|
||||
- Children rendered into the inner content cell at `padding: theme.spacing.lg`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailLayout } from "@suaveplan/email-templates/components";
|
||||
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailLayout({
|
||||
children: h("p", {}, "Hi Jane"),
|
||||
}));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pass a custom theme and preview text for inbox-list snippet:
|
||||
|
||||
```ts
|
||||
import { EmailLayout } from "@suaveplan/email-templates/components";
|
||||
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
import { createTheme } from "@suaveplan/email-templates/theme";
|
||||
|
||||
const theme = createTheme({ colors: { muted: "#0f172a", primary: "#22d3ee" } });
|
||||
|
||||
renderVNode(EmailLayout({
|
||||
theme,
|
||||
preview: "Your invoice for January",
|
||||
children: h("p", {}, "Body content"),
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ---------- | ---------------- | -------------------------------------------------------- |
|
||||
| `children` | `EmailChildren?` | Body nodes rendered into the inner cell. |
|
||||
| `theme` | `EmailTheme?` | Theme override; defaults to `defaultTheme`. |
|
||||
| `preview` | `string?` | Hidden preview text for inbox-list snippet. |
|
||||
| `style` | `StyleObject?` | Reserved for future use; current renderer ignores. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The preview block emits a `<div>` (not a `<span>` or `<table>`) because
|
||||
historically Outlook 2007 mis-handled `display:none` on inline elements
|
||||
inside tables. The empty-preview branch is exercised by the `omits the
|
||||
preview block` test to keep the conditional fully covered.
|
||||
|
||||
`maxWidth` lives on the **inner** table, not the outer one — the outer
|
||||
table spans the viewport's full width to paint the muted background
|
||||
edge-to-edge. The inner table's `max-width: 600px` is the standard
|
||||
email-design recommendation; Apple's HIG suggests this number to ensure
|
||||
single-column readability across watch / phone / mail-app contexts.
|
||||
@@ -0,0 +1,75 @@
|
||||
# email-link
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Themed inline `<a>` link with safe defaults (`target="_blank"`,
|
||||
`rel="noopener noreferrer"`) for body-copy hyperlinks. The default colour
|
||||
is `theme.colors.primary` and underline is on; both are overridable.
|
||||
|
||||
The link component is intentionally minimal — for CTA-style call-to-action
|
||||
buttons use `EmailButton`, which emits the table-based, MSO-friendly
|
||||
fallback structure. `EmailLink` is for ordinary in-paragraph references.
|
||||
|
||||
## Features
|
||||
|
||||
- `target="_blank"` + `rel="noopener noreferrer"` baked in. These two
|
||||
attributes together close the `window.opener` exfiltration class of
|
||||
bugs that browsers historically left open for `target="_blank"` links.
|
||||
- Themed primary colour by default; per-link override via `color` prop.
|
||||
- Underline on by default (matches link-convention expectations); toggle
|
||||
via `underline: false` for navigation-style links inside footers.
|
||||
- `style` override merged after defaults.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailLink } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailLink({ href: "https://example.test", children: "click here" }));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A non-underlined branded link inside a footer:
|
||||
|
||||
```ts
|
||||
import { EmailLink } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailLink({
|
||||
href: "https://example.test/privacy",
|
||||
underline: false,
|
||||
color: "#64748b",
|
||||
style: { "font-size": "12px" },
|
||||
children: "Privacy Policy",
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ----------- | -------------- | ---------------------------------------------------- |
|
||||
| `href` | `string` | The link target. |
|
||||
| `children` | `EmailChildren?` | Link label. |
|
||||
| `theme` | `EmailTheme?` | Theme override. |
|
||||
| `color` | `string?` | Override the link colour (default: `theme.colors.primary`). |
|
||||
| `underline` | `boolean?` | Defaults to `true`. |
|
||||
| `style` | `StyleObject?` | Additional inline styles. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`href` is escaped via the standard attribute-escape path inside
|
||||
`renderVNode` — no special handling. The component does NOT validate
|
||||
that `href` is a well-formed URL; that lives at the caller boundary
|
||||
(typically a Zod schema upstream).
|
||||
|
||||
The `target="_blank"` + `rel="noopener noreferrer"` combination is
|
||||
non-negotiable: callers cannot opt out. Email-client renderers
|
||||
historically treat the `_blank` target differently (some open the
|
||||
default browser, some open an embedded preview), and the `rel` attribute
|
||||
guards against the `window.opener` reverse-tabnabbing class regardless.
|
||||
@@ -0,0 +1,71 @@
|
||||
# email-text
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Paragraph component for email-body copy. `EmailText` emits a themed `<p>`
|
||||
with one of three size variants (`body` / `caption` / `lead`), an
|
||||
alignment hint, and an opt-in muted-colour treatment for de-emphasised
|
||||
copy like footnotes or expiry notices.
|
||||
|
||||
Plain `<p>` elements work in every email client without further coaxing
|
||||
— this component exists to centralise the font-family / font-size /
|
||||
line-height / colour cascade so callers don't restate it at every
|
||||
paragraph site.
|
||||
|
||||
## Features
|
||||
|
||||
- Three variants — `body` (16/1.5), `caption` (13/1.4), `lead` (18/1.6).
|
||||
- `align` prop emitted as `text-align`.
|
||||
- `muted` toggle switches `color` to `theme.colors.mutedForeground`.
|
||||
- Themed `font-family` and bottom margin (`theme.spacing.md`).
|
||||
- `style` override merged last.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailText } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailText({ children: "Hi Jane, thanks for signing up." }));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A right-aligned muted caption for a one-time-code expiry notice:
|
||||
|
||||
```ts
|
||||
import { EmailText } from "@suaveplan/email-templates/components";
|
||||
import { renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
renderVNode(EmailText({
|
||||
variant: "caption",
|
||||
align: "right",
|
||||
muted: true,
|
||||
children: "This code expires in 5 minutes.",
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ---------- | --------------------------------- | ---------------------------------------------------- |
|
||||
| `children` | `EmailChildren?` | Paragraph content (text or nested links / spans). |
|
||||
| `theme` | `EmailTheme?` | Theme override. |
|
||||
| `variant` | `"body" \| "caption" \| "lead"` | Size variant; defaults to `body`. |
|
||||
| `align` | `"left" \| "center" \| "right"` | `text-align`. |
|
||||
| `muted` | `boolean?` | Switch to muted foreground colour. |
|
||||
| `style` | `StyleObject?` | Additional inline styles. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The variant table is module-local — adding a fourth variant requires
|
||||
extending both the type union and the table; the TypeScript compiler
|
||||
refuses to accept a mismatched pair so partial extensions are impossible.
|
||||
|
||||
Line heights are intentionally unit-less (e.g. `"1.5"`) so they inherit
|
||||
correctly into nested elements. Email clients honour unit-less
|
||||
`line-height` more reliably than `1.5em` or `24px`.
|
||||
@@ -0,0 +1,87 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error-code registry for `@suaveplan/email-templates`. Every failure
|
||||
raised by the package extends `SuaveplanError` with a discriminant `code`
|
||||
drawn from `EMAIL_TEMPLATES_CODES`, enabling programmatic dispatch by
|
||||
consumers without coupling to error-message text.
|
||||
|
||||
The registry is the contract: every code listed here MUST be thrown by at
|
||||
least one production code path under `src/`. Removing an entry without
|
||||
removing its call site is forbidden by §2c of `AGENTS.md`.
|
||||
|
||||
## Features
|
||||
|
||||
- Six stable codes registered via `defineCodes("EMAIL_TEMPLATES", [...])`.
|
||||
- `EmailTemplatesError` class with public constructor (so external callers
|
||||
can synthesise an instance for re-throw / wrapping).
|
||||
- `EmailTemplatesCode` discriminant union derived from the registry — every
|
||||
`code` field is statically narrowable.
|
||||
- Inherits OTel span-event adaptation, redaction, cause-chain traversal,
|
||||
and i18n message-key resolution from `SuaveplanError`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
EMAIL_TEMPLATES_CODES,
|
||||
EmailTemplatesError,
|
||||
} from "@suaveplan/email-templates/errors";
|
||||
|
||||
throw new EmailTemplatesError(
|
||||
EMAIL_TEMPLATES_CODES.UNKNOWN_TEMPLATE,
|
||||
"email_templates.unknown_template",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wrap an unexpected throwable, preserving its cause for the OTel span-event
|
||||
adapter to surface in tracing:
|
||||
|
||||
```ts
|
||||
import {
|
||||
EMAIL_TEMPLATES_CODES,
|
||||
EmailTemplatesError,
|
||||
} from "@suaveplan/email-templates/errors";
|
||||
|
||||
try {
|
||||
renderToHtml(template, props);
|
||||
} catch (cause) {
|
||||
throw new EmailTemplatesError(
|
||||
EMAIL_TEMPLATES_CODES.RENDER_FAILURE,
|
||||
"email_templates.render_failure",
|
||||
{ cause: cause instanceof Error ? cause : new Error(String(cause)) },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ---------------------------- | -------- | --------------------------------------------------- |
|
||||
| `EMAIL_TEMPLATES_CODES` | const | Frozen record of `EMAIL_TEMPLATES.<NAME>` strings. |
|
||||
| `EmailTemplatesCode` | type | Discriminant union of every registered code. |
|
||||
| `EmailTemplatesError` | class | `SuaveplanError<EmailTemplatesCode>` subclass. |
|
||||
|
||||
Codes (six): `INVALID_TEMPLATE`, `INVALID_OPTIONS`, `RENDER_FAILURE`,
|
||||
`DUPLICATE_NAME`, `UNKNOWN_TEMPLATE`, `INVALID_HEADING_LEVEL`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `defineCodes` helper from `@suaveplan/error` returns a frozen
|
||||
`Record<K, "EMAIL_TEMPLATES.K">` and side-steps the literal-string-typing
|
||||
pitfall by inferring each key as a string-literal type. `EmailTemplatesCode`
|
||||
is derived via `(typeof EMAIL_TEMPLATES_CODES)[keyof typeof ...]` so adding
|
||||
or removing a code automatically widens or narrows the union — the
|
||||
TypeScript compiler will refuse to throw an unknown code.
|
||||
|
||||
`name = "EmailTemplatesError"` is set in the constructor (not via static
|
||||
class-name reflection) so that downcasting via the standard
|
||||
`err.name === "EmailTemplatesError"` discriminant works under minified
|
||||
production bundles. `timestamp` is supplied by `SuaveplanError`.
|
||||
@@ -0,0 +1,96 @@
|
||||
# message
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createEmailMessage` glues the renderer to the addressing envelope, producing
|
||||
an `EmailMessageResult` plain object suitable for handing to an SMTP / SES /
|
||||
SendGrid / Mailgun client. The shape matches the union of common
|
||||
email-provider request bodies (`to`, `from`, `cc`, `bcc`, `replyTo`, `subject`,
|
||||
`html`, `text`), so a single helper covers the contract every transport
|
||||
implements.
|
||||
|
||||
This module is intentionally thin — it owns the recipient-envelope merge
|
||||
and threads the theme + render options through to `renderToHtml`. All
|
||||
domain logic lives in the renderer + template; this is the integration
|
||||
surface consumers wire into their job queues.
|
||||
|
||||
## Features
|
||||
|
||||
- Spreads the recipient envelope first (allowing the render result to
|
||||
shadow no addressing fields), then layers `{ subject, html, text }` on
|
||||
top.
|
||||
- Accepts `theme` and `renderOptions` independently. The `renderOptions`
|
||||
type explicitly excludes `theme` to prevent the caller from passing it
|
||||
twice through two different paths.
|
||||
- Returns a plain object — every transport accepts plain objects.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createEmailMessage } from "@suaveplan/email-templates/message";
|
||||
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
const msg = createEmailMessage(
|
||||
welcomeTemplate,
|
||||
{ recipientName: "Jane", ctaUrl: "https://app.example.com" },
|
||||
{ to: "jane@example.com", from: "hello@suaveplan.com" },
|
||||
);
|
||||
await mailer.send(msg);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Layer cc/bcc/replyTo with a custom theme and minified HTML:
|
||||
|
||||
```ts
|
||||
import { createEmailMessage } from "@suaveplan/email-templates/message";
|
||||
import { createTheme } from "@suaveplan/email-templates/theme";
|
||||
import { invoiceTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
const theme = createTheme({ branding: { companyName: "Acme" } });
|
||||
const msg = createEmailMessage(
|
||||
invoiceTemplate,
|
||||
{
|
||||
recipientName: "Jane",
|
||||
invoiceNumber: "001",
|
||||
invoiceDate: "2026-01-01",
|
||||
items: [{ description: "Widget", quantity: 2, unitPrice: 10, total: 20 }],
|
||||
subtotal: 20,
|
||||
total: 20,
|
||||
},
|
||||
{
|
||||
to: "jane@x",
|
||||
from: "billing@acme.test",
|
||||
cc: ["audit@acme.test"],
|
||||
bcc: "archive@acme.test",
|
||||
replyTo: "support@acme.test",
|
||||
},
|
||||
{ theme, renderOptions: { minify: true, previewText: "Your invoice" } },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| -------------------- | -------- | -------------------------------------------------------- |
|
||||
| `createEmailMessage` | function | Build an `EmailMessageResult` from a template + envelope. |
|
||||
|
||||
Types: `EmailRecipient`, `CreateEmailMessageOptions`, `EmailMessageResult`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function performs no validation — it trusts the caller. Address
|
||||
validation belongs to the transport layer (or to a dedicated validator
|
||||
upstream); attempting it here would either reject perfectly-legal
|
||||
addresses (e.g. RFC-5321 quoted local parts) or be too permissive to be
|
||||
useful. Subject is derived from `template.subject` via `resolveSubject`,
|
||||
so subject-line interpolation runs through the same code path as the
|
||||
HTML body.
|
||||
|
||||
The result object is a fresh allocation per call — no caching, no
|
||||
pooling, no mutation of either input. That makes the helper trivially
|
||||
parallelisable across worker threads.
|
||||
@@ -0,0 +1,107 @@
|
||||
# renderer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The renderer is the bridge between the `EmailTemplate<TProps>` contract and
|
||||
a final `{ html, text, subject }` triple ready to hand to an SMTP / SES / SendGrid
|
||||
client. It calls `template.component(merged_props)` to obtain a `VNode`,
|
||||
walks the tree through `renderVNode`, wraps the output in a full
|
||||
`<!doctype html>` document with the head boilerplate every email client
|
||||
expects, and (by default) derives a plain-text fallback from the HTML body
|
||||
via tag-stripping.
|
||||
|
||||
Every render emits OTel telemetry — `email_templates.render_total` counter
|
||||
tagged by `template` and `outcome`, plus a duration histogram. Errors raised
|
||||
by the component are caught and re-thrown as `EmailTemplatesError` with the
|
||||
`RENDER_FAILURE` code unless they were already a typed error from this
|
||||
package (those propagate unchanged).
|
||||
|
||||
## Features
|
||||
|
||||
- `renderToHtml(template, props, options?)` → `{ html, text, subject }`.
|
||||
- `wrapInDocument(body, options?)` — applies the doctype + head boilerplate.
|
||||
- `resolveSubject(template, props)` — pure helper for subject derivation.
|
||||
- `escapeHtml(input)` / `stripHtml(html)` — public utilities reused by
|
||||
upstream callers.
|
||||
- Injectable `theme` and `Clock` via render options.
|
||||
- Optional `minify: true` collapses whitespace between tags.
|
||||
- Optional `plainText: false` skips text-body generation.
|
||||
- MSO conditional comment (`<!--[if mso]>...<![endif]-->`) for Outlook
|
||||
PPI normalisation lives inside `wrapInDocument`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
const { html, text, subject } = renderToHtml(welcomeTemplate, {
|
||||
recipientName: "Jane",
|
||||
ctaUrl: "https://app.example.com",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a fake clock for deterministic footer year rendering and pre-build a
|
||||
tenant-specific theme:
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { createTheme } from "@suaveplan/email-templates/theme";
|
||||
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
const theme = createTheme({
|
||||
colors: { primary: "#ff6600" },
|
||||
branding: { companyName: "Acme" },
|
||||
});
|
||||
|
||||
const result = renderToHtml(
|
||||
welcomeTemplate,
|
||||
{ recipientName: "Jane", ctaUrl: "https://acme.test" },
|
||||
{
|
||||
theme,
|
||||
clock: { now: () => 1735689600000 }, // 2025-01-01T00:00:00Z
|
||||
previewText: "Welcome aboard, Jane!",
|
||||
minify: true,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ----------------- | -------- | ---------------------------------------------------- |
|
||||
| `renderToHtml` | function | Render a template to `{ html, text, subject }`. |
|
||||
| `wrapInDocument` | function | Wrap a body fragment in a full HTML document. |
|
||||
| `resolveSubject` | function | Resolve `template.subject` (literal or function). |
|
||||
| `escapeHtml` | function | Escape text for HTML inclusion. |
|
||||
| `stripHtml` | function | Tag-strip + entity-decode for plain-text fallback. |
|
||||
|
||||
Types: `EmailTemplate<TProps>`, `RenderOptions`, `RenderResult`,
|
||||
`HtmlDocumentOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The renderer is **not** idempotent across `Date.now()` calls — render
|
||||
duration is recorded as `Date.now() - start`. Components, however, never
|
||||
read wall-clock time directly: the renderer threads an injected `Clock`
|
||||
(falling back to a real-`Date.now()` clock) into the merged props bag, so
|
||||
template-level determinism is preserved.
|
||||
|
||||
`stripHtml` is a regex pass — it does NOT round-trip nested HTML entities
|
||||
or HTML5 ambiguous markup. Email body content is expected to be the
|
||||
output of `renderVNode` (well-formed) and is reduced to a coarse
|
||||
plain-text representation suitable for clients that fall back to the
|
||||
text part. Production-grade plain-text rendering (line wrapping, link
|
||||
extraction, list bullets) is the consumer's responsibility.
|
||||
|
||||
Errors raised inside `template.component` are caught, the `error` outcome
|
||||
counter is bumped, and the original cause is preserved on the
|
||||
`EmailTemplatesError`. Typed errors already raised by this package are
|
||||
re-thrown without re-wrapping so their original `code` survives the
|
||||
boundary intact.
|
||||
@@ -0,0 +1,81 @@
|
||||
# telemetry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Exposes the OpenTelemetry instrument set published by
|
||||
`@suaveplan/email-templates`. Every public render or registry mutation
|
||||
emits a counter (and where useful, a duration histogram) so operators can
|
||||
build dashboards without instrumenting consumers themselves.
|
||||
|
||||
The instrument set is constructed lazily on first access — importing this
|
||||
module does NOT create a meter or instruments, satisfying the
|
||||
"side-effects: false" contract in `package.json`. Once constructed, the
|
||||
set is cached and the same object reference is returned forever (the
|
||||
metric exporter receives stable instrument identity, which is required
|
||||
for cumulative-temporality counters).
|
||||
|
||||
## Features
|
||||
|
||||
- Four instruments: `render_total` (Counter), `render_duration_ms`
|
||||
(Histogram, unit `ms`), `registry_register_total` (Counter),
|
||||
`validation_failure_total` (Counter).
|
||||
- Stable meter name: `@suaveplan/email-templates`.
|
||||
- `resetEmailTemplatesInstrumentsCacheForTest()` helper to drop the cache
|
||||
between test runs that swap the global `MeterProvider`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getEmailTemplatesInstruments } from "@suaveplan/email-templates/otel";
|
||||
|
||||
const instruments = getEmailTemplatesInstruments();
|
||||
instruments.renderCounter.add(1, { template: "welcome", outcome: "ok" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a real metric exporter in test setup and assert the recorded series
|
||||
on every render call (the package uses this pattern internally):
|
||||
|
||||
```ts
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
|
||||
// ... drive the renderer ...
|
||||
|
||||
await reader.forceFlush();
|
||||
const series = exporter.getMetrics();
|
||||
// assert series contains email_templates.render_total ...
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| -------------------------------------------- | -------- | -------------------------------------------- |
|
||||
| `getEmailTemplatesInstruments()` | function | Returns the cached `EmailTemplatesInstruments`. |
|
||||
| `resetEmailTemplatesInstrumentsCacheForTest()` | function | Drops the module-local cache — test-only. |
|
||||
| `EmailTemplatesInstruments` | type | The shape of the instrument set. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The accessor must remain idempotent. Repeated calls return the same
|
||||
object reference; the test corpus asserts that fact because cumulative
|
||||
counters lose their backing storage if a meter is re-created mid-flight.
|
||||
|
||||
The module never reads `process.env`, `globalThis.window`, or any
|
||||
DOM-resident global — it is universal (`engines.bun`, `engines.node`,
|
||||
browser) without conditional code paths.
|
||||
@@ -0,0 +1,84 @@
|
||||
# template-builder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TemplateBuilder` is a fluent API for assembling an `EmailTemplate<TProps>`
|
||||
value step-by-step. It exists because constructing the template literal
|
||||
inline reads poorly when defaults and subject functions are non-trivial,
|
||||
and because the builder enforces presence of the three required fields
|
||||
(`name`, `subject`, `component`) at `build()` time with a typed error.
|
||||
|
||||
The class itself is a typical builder — each setter mutates the internal
|
||||
state and returns `this`. `build()` is the one terminal operation. Calls
|
||||
made after `build()` continue to work (the builder is not consumed), so a
|
||||
single builder can produce two slightly-different templates by switching
|
||||
one setter and calling `build()` twice.
|
||||
|
||||
## Features
|
||||
|
||||
- Fluent setters: `name`, `subject` (literal or function-of-props),
|
||||
`component`, `defaultProps`.
|
||||
- Runtime validation in `build()` — missing required fields raise
|
||||
`EmailTemplatesError(INVALID_TEMPLATE)` with a specific message key.
|
||||
- `createTemplateBuilder<TProps>()` factory for inference-friendly use.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createTemplateBuilder } from "@suaveplan/email-templates/template-builder";
|
||||
import { h } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
interface Props { name: string; ctaUrl: string }
|
||||
|
||||
const myTemplate = createTemplateBuilder<Props>()
|
||||
.name("custom-welcome")
|
||||
.subject((p) => `Hi ${p.name}`)
|
||||
.component((p) => h("a", { href: p.ctaUrl }, "Click me"))
|
||||
.build();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Reuse a single builder to produce a localised template family by mutating
|
||||
between `build()` calls:
|
||||
|
||||
```ts
|
||||
import { createTemplateBuilder } from "@suaveplan/email-templates/template-builder";
|
||||
import { h } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
interface Props { name: string }
|
||||
|
||||
const b = createTemplateBuilder<Props>()
|
||||
.component((p) => h("p", {}, `Hi ${p.name}`));
|
||||
|
||||
const en = b.name("greet-en").subject("Hello").build();
|
||||
const fr = b.name("greet-fr").subject("Bonjour").build();
|
||||
```
|
||||
|
||||
`en.subject === "Hello"` and `fr.subject === "Bonjour"` — the builder
|
||||
returns a fresh `EmailTemplate` object on each `build()` call.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ------------------------ | -------- | ---------------------------------------------------- |
|
||||
| `TemplateBuilder<T>` | class | The builder itself; instance methods are chainable. |
|
||||
| `createTemplateBuilder` | function | `<T>() => TemplateBuilder<T>` factory. |
|
||||
| `TemplateBuilderApi<T>` | type | The interface implemented by `TemplateBuilder<T>`. |
|
||||
| `EmailComponentFn<T>` | type | `(props: T & { theme?; clock? }) => VNode`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The builder never throws inside a setter — invariants are checked
|
||||
exclusively in `build()`. Empty-string `name` is treated as missing
|
||||
(consistent with upstream behaviour). The `defaultProps` field is
|
||||
optional; omitting it leaves it `undefined` on the produced template.
|
||||
|
||||
Because `build()` returns a fresh object every time, callers can mutate
|
||||
the builder and reuse it for closely-related templates — useful for
|
||||
i18n where the body component is identical and only the subject text
|
||||
changes by locale.
|
||||
@@ -0,0 +1,102 @@
|
||||
# template-registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TemplateRegistry` is a type-safe in-memory store for `EmailTemplate`
|
||||
values keyed by name. It supports per-name lookup, listing, category /
|
||||
tag / author search, and lifecycle removal — the surface a typical
|
||||
email-orchestration service needs to dispatch the right template by
|
||||
name without coupling to the template implementations themselves.
|
||||
|
||||
The registry is intentionally non-persistent: callers needing
|
||||
cross-process state (DB-backed templates, CDN-hosted JSON, etc.) wire
|
||||
their own store and `register()` into this registry on boot. Templates
|
||||
remain plain `EmailTemplate` values; the registry never copies or
|
||||
freezes them.
|
||||
|
||||
## Features
|
||||
|
||||
- `register(name, template, metadata?)` with duplicate detection and an
|
||||
OTel counter (`registry_register_total{outcome}`).
|
||||
- `get(name)` / `has(name)` / `list()` / `size` accessor.
|
||||
- `search(criteria)` with name (case-insensitive substring), category
|
||||
(exact), tag (any-intersection), author (exact) filters — combinable.
|
||||
- `remove(name)` / `clear()` for lifecycle management.
|
||||
- Optional `defaultTheme` exposed via a getter, useful as a per-registry
|
||||
fallback for the renderer.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRegistry } from "@suaveplan/email-templates/template-registry";
|
||||
import {
|
||||
invoiceTemplate,
|
||||
welcomeTemplate,
|
||||
} from "@suaveplan/email-templates/templates";
|
||||
|
||||
const registry = createRegistry();
|
||||
registry.register("welcome", welcomeTemplate, {
|
||||
category: "transactional",
|
||||
tags: ["onboarding"],
|
||||
});
|
||||
registry.register("invoice", invoiceTemplate, { category: "billing" });
|
||||
|
||||
const t = registry.get("welcome");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a typed map so `get(name)` returns the exact `EmailTemplate<TProps>`
|
||||
for that key:
|
||||
|
||||
```ts
|
||||
import { createRegistry } from "@suaveplan/email-templates/template-registry";
|
||||
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
|
||||
import type { WelcomeProps } from "@suaveplan/email-templates/templates";
|
||||
|
||||
interface MyMap {
|
||||
welcome: WelcomeProps;
|
||||
}
|
||||
|
||||
const registry = createRegistry<MyMap>();
|
||||
registry.register("welcome", welcomeTemplate);
|
||||
const welcomeT = registry.get("welcome"); // type: EmailTemplate<WelcomeProps> | undefined
|
||||
```
|
||||
|
||||
Search by tag intersection and category:
|
||||
|
||||
```ts
|
||||
registry.search({ tags: ["onboarding"], category: "transactional" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ------------------------- | -------- | ---------------------------------------------------- |
|
||||
| `TemplateRegistry<TMap>` | class | The registry itself; implements `TemplateRegistryApi`. |
|
||||
| `createRegistry<TMap>` | function | Convenience factory returning a new registry. |
|
||||
| `RegistryOptions` | type | Constructor options. |
|
||||
| `TemplateMetadata` | type | `{ category?, tags?, description?, author? }`. |
|
||||
| `RegistryEntry` | type | Internal entry shape (template + metadata + when). |
|
||||
| `SearchCriteria` | type | Search input shape. |
|
||||
| `SearchResult` | type | Search output shape. |
|
||||
| `TemplateMap` | type | `Record<string, unknown>` upper bound. |
|
||||
| `TemplateRegistryApi` | type | The interface implemented by `TemplateRegistry`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry stores its entries in a single `Map<string, RegistryEntry>`
|
||||
— ordered insertion is preserved for `list()` and `search()`. Duplicate
|
||||
registration raises `EmailTemplatesError(DUPLICATE_NAME)` deliberately:
|
||||
silent overwrite is the most common cause of "wrong template ended up in
|
||||
the inbox" bugs. Consumers needing replacement should `remove(name)`
|
||||
first.
|
||||
|
||||
`search` uses simple JS `Array.some` / `String.includes` — no fuzzy
|
||||
matching, no scoring. Tag matching is an OR (any tag in the criterion
|
||||
must appear in the entry's tags); name matching is substring; category
|
||||
and author are exact. The criteria are combined with logical AND.
|
||||
@@ -0,0 +1,93 @@
|
||||
# invoice
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `invoiceTemplate` is the receipt / invoice / order-confirmation
|
||||
email. It renders a heading carrying the invoice number, a metadata line
|
||||
(date, optional due date), a line-items table with description /
|
||||
quantity / unit-price / total columns, a subtotal-tax-total summary, and
|
||||
an optional "Pay Now" CTA when `paymentUrl` is supplied.
|
||||
|
||||
The template is intentionally generic — it does not assume a specific
|
||||
tax-jurisdiction or currency formatting; consumers pass pre-formatted
|
||||
strings (date, currency symbol) and pre-computed numbers (subtotal,
|
||||
tax, total). The template focuses on layout, not arithmetic.
|
||||
|
||||
## Features
|
||||
|
||||
- Line-items table with description, quantity, unit price, total columns.
|
||||
- Subtotal + optional tax + total summary at right-align.
|
||||
- Optional `paymentUrl` renders a "Pay Now" CTA.
|
||||
- Currency symbol is configurable (`currency` defaults to `"$"`).
|
||||
- Optional `dueDate` appears alongside the invoice date.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { invoiceTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
renderToHtml(invoiceTemplate, {
|
||||
recipientName: "Jane",
|
||||
invoiceNumber: "INV-001",
|
||||
invoiceDate: "2026-01-15",
|
||||
items: [
|
||||
{ description: "Pro plan – Jan 2026", quantity: 1, unitPrice: 29, total: 29 },
|
||||
],
|
||||
subtotal: 29,
|
||||
total: 29,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A multi-line invoice with tax, a due date, a payment CTA, and a
|
||||
non-USD currency:
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { invoiceTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
renderToHtml(invoiceTemplate, {
|
||||
recipientName: "Jane",
|
||||
invoiceNumber: "INV-001",
|
||||
invoiceDate: "2026-01-15",
|
||||
dueDate: "2026-01-31",
|
||||
currency: "€",
|
||||
items: [
|
||||
{ description: "Pro plan – Jan 2026", quantity: 1, unitPrice: 29, total: 29 },
|
||||
{ description: "Add-on: extra seat", quantity: 2, unitPrice: 5, total: 10 },
|
||||
],
|
||||
subtotal: 39,
|
||||
tax: 3.9,
|
||||
total: 42.9,
|
||||
paymentUrl: "https://billing.example.test/pay?invoice=INV-001",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ----------------- | -------- | ---------------------------------------------------- |
|
||||
| `invoiceTemplate` | const | `EmailTemplate<InvoiceProps>`. |
|
||||
| `InvoiceEmail` | function | Component returning a `VNode`. |
|
||||
| `InvoiceProps` | type | `{ recipientName, invoiceNumber, invoiceDate, dueDate?, items, subtotal, tax?, total, currency?, paymentUrl?, companyName? }` |
|
||||
| `InvoiceLineItem` | type | `{ description, quantity, unitPrice, total }`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Currency formatting uses `Number.prototype.toFixed(2)` — locale-agnostic
|
||||
two-decimal-place rendering. Consumers needing locale-aware grouping
|
||||
separators (e.g. `1,234.56` vs `1 234,56`) should pre-format the strings
|
||||
upstream; the template intentionally does not depend on `Intl.NumberFormat`
|
||||
because email-template environments span Node, Bun, and browsers where
|
||||
the Intl data sets vary.
|
||||
|
||||
The line-items table renders a single `<tbody>` per call; the table
|
||||
header sits in its own `<thead>` so accessibility scanners report the
|
||||
correct table-with-headers structure even though most email clients
|
||||
ignore the distinction.
|
||||
@@ -0,0 +1,83 @@
|
||||
# notification
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `notificationTemplate` is the generic system-notification email — a
|
||||
configurable severity callout suitable for product-health alerts,
|
||||
audit-trail events, account-status changes, and similar operational
|
||||
messages. The `level` prop drives both the accent border colour and the
|
||||
container background, giving recipients an at-a-glance severity cue.
|
||||
|
||||
## Features
|
||||
|
||||
- Four severity levels — `info`, `success`, `warning`, `error` — each
|
||||
selecting a distinct accent / background colour pair.
|
||||
- Optional `timestamp` (rendered as a muted caption above the action).
|
||||
- Optional `action` button (`{ label, url }`).
|
||||
- Subject derives from the `title` prop.
|
||||
- Validation: unknown level raises `EmailTemplatesError(INVALID_OPTIONS)`
|
||||
and bumps an OTel counter.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { notificationTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
renderToHtml(notificationTemplate, {
|
||||
recipientName: "Jane",
|
||||
title: "Heads up",
|
||||
message: "Your subscription renews tomorrow.",
|
||||
level: "info",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A warning notification with a timestamp and a follow-up action:
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { notificationTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
renderToHtml(notificationTemplate, {
|
||||
recipientName: "Jane",
|
||||
title: "Failed login from a new device",
|
||||
message: "We blocked an attempt from a device we don't recognise. If this was you, you can dismiss this message.",
|
||||
level: "warning",
|
||||
timestamp: "2026-05-25 14:32 UTC",
|
||||
action: {
|
||||
label: "Review device activity",
|
||||
url: "https://app.example.test/security/devices",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ----------------------- | -------- | ---------------------------------------------------- |
|
||||
| `notificationTemplate` | const | `EmailTemplate<NotificationProps>`. |
|
||||
| `NotificationEmail` | function | Component returning a `VNode`. |
|
||||
| `NotificationProps` | type | `{ recipientName, title, message, level?, action?, companyName?, timestamp? }` |
|
||||
| `NotificationLevel` | type | `"info" \| "success" \| "warning" \| "error"`. |
|
||||
| `NotificationAction` | type | `{ label, url }`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The accent colour for `info`/`success`/`warning`/`error` resolves from
|
||||
the theme's colour palette (`primary` for info; `success`/`warning`/`error`
|
||||
respectively). The container background, however, is a fixed lighter
|
||||
tint per level — those values are not in the theme because they're
|
||||
visually-tuned washes specific to this template's callout pattern.
|
||||
Consumers wanting a theme-driven wash can override via `EmailContainer`
|
||||
directly.
|
||||
|
||||
The accent-bar effect is achieved with a `<div>` carrying
|
||||
`border-left: 4px solid <accent>`. The bar lives inside the
|
||||
`EmailContainer` cell so the container's themed border-radius rounds the
|
||||
outer edges while the bar stays sharp.
|
||||
@@ -0,0 +1,83 @@
|
||||
# password-reset
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `passwordResetTemplate` is the canonical password-reset message. It
|
||||
explains the request, exposes a reset CTA, communicates the link's
|
||||
expiry, and includes a "you can safely ignore" caution to reassure
|
||||
recipients who did not initiate the request.
|
||||
|
||||
The template reproduces the reset URL as plain text underneath the CTA
|
||||
button — a deliberate redundancy that ensures recipients on
|
||||
text-stripping clients (some webmail security scanners) can still
|
||||
complete the flow.
|
||||
|
||||
## Features
|
||||
|
||||
- Themed reset CTA pointing at `resetUrl`.
|
||||
- Configurable `expiresInMinutes` (defaults to `60`).
|
||||
- Tinted callout container with the "safely ignore" caution copy.
|
||||
- Plain-URL fallback below the divider.
|
||||
- Footer with injectable `Clock`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { passwordResetTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
const { html, text, subject } = renderToHtml(passwordResetTemplate, {
|
||||
recipientName: "Jane",
|
||||
resetUrl: "https://app.example.test/reset?token=abc",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A 30-minute expiry tied to a tenant-specific brand:
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { createTheme } from "@suaveplan/email-templates/theme";
|
||||
import { passwordResetTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
const theme = createTheme({
|
||||
colors: { primary: "#22d3ee" },
|
||||
branding: { companyName: "Acme" },
|
||||
});
|
||||
|
||||
renderToHtml(
|
||||
passwordResetTemplate,
|
||||
{
|
||||
recipientName: "Jane",
|
||||
resetUrl: "https://acme.test/reset?token=abc",
|
||||
expiresInMinutes: 30,
|
||||
companyName: "Acme",
|
||||
},
|
||||
{ theme },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ------------------------ | -------- | ---------------------------------------------------- |
|
||||
| `passwordResetTemplate` | const | `EmailTemplate<PasswordResetProps>`. |
|
||||
| `PasswordResetEmail` | function | Component returning a `VNode`. |
|
||||
| `PasswordResetProps` | type | `{ recipientName, resetUrl, expiresInMinutes?, companyName? }` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 60-minute default mirrors the most common security-team-approved
|
||||
upper bound for password-reset links; tenants needing tighter security
|
||||
should pass an explicit `expiresInMinutes` and ensure their token-issue
|
||||
back-end uses the same value.
|
||||
|
||||
The "safely ignore" line lives inside the tinted callout to visually
|
||||
separate it from the CTA, which discourages anxious recipients from
|
||||
clicking the reset link they did not request — a known social-engineering
|
||||
attack vector when the caution is text-only.
|
||||
@@ -0,0 +1,83 @@
|
||||
# verification
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `verificationTemplate` is the standard email-address verification
|
||||
flow message. It greets the recipient, optionally displays a one-time
|
||||
code in a centred tinted container with tracked letter-spacing for
|
||||
readability, exposes the verify-link CTA, and includes an expiry
|
||||
notice.
|
||||
|
||||
The template handles both code-only and link-only verification flows by
|
||||
making the `code` field optional. When omitted, the code container is
|
||||
skipped entirely.
|
||||
|
||||
## Features
|
||||
|
||||
- Optional one-time code rendering in a tinted container (32 px,
|
||||
bold, 8 px letter-spacing — the conventional "OTP display" treatment).
|
||||
- Configurable `expiresInMinutes` (defaults to `30`).
|
||||
- Themed verify-email CTA pointing at `verificationUrl`.
|
||||
- Footer with injectable `Clock` for deterministic year.
|
||||
- "If you didn't create an account" caution paragraph.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { verificationTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
const { html, text, subject } = renderToHtml(verificationTemplate, {
|
||||
recipientName: "Jane",
|
||||
verificationUrl: "https://app.example.test/verify?token=abc",
|
||||
code: "ABC123",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Link-only verification with a tighter expiry and a tenant-specific
|
||||
brand:
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { createTheme } from "@suaveplan/email-templates/theme";
|
||||
import { verificationTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
const theme = createTheme({ branding: { companyName: "Acme" } });
|
||||
renderToHtml(
|
||||
verificationTemplate,
|
||||
{
|
||||
recipientName: "Jane",
|
||||
verificationUrl: "https://acme.test/verify?token=abc",
|
||||
expiresInMinutes: 5,
|
||||
companyName: "Acme",
|
||||
},
|
||||
{ theme },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ----------------------- | -------- | ------------------------------------------------- |
|
||||
| `verificationTemplate` | const | `EmailTemplate<VerificationProps>`. |
|
||||
| `VerificationEmail` | function | Component returning a `VNode`. |
|
||||
| `VerificationProps` | type | `{ recipientName, verificationUrl, code?, expiresInMinutes?, companyName? }` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The empty-string check on `code` is intentional: an upstream sanitiser
|
||||
that strips whitespace from a missing-value form field commonly produces
|
||||
`""` rather than `undefined`. Treating both as "no code" keeps the
|
||||
template stable against caller-side variance without requiring a Zod
|
||||
schema at the boundary.
|
||||
|
||||
The OTP container's `letter-spacing: 8px` is wide enough that 6-digit
|
||||
codes render as visually-grouped pairs in most clients, which improves
|
||||
copy-by-eye accuracy. The font size (32 px) is large enough to remain
|
||||
legible on a phone-sized email preview without dominating the layout.
|
||||
@@ -0,0 +1,84 @@
|
||||
# welcome
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `welcomeTemplate` is the canonical post-signup onboarding email. It
|
||||
greets the recipient by name, summarises why they received the email,
|
||||
exposes the primary CTA, and reproduces the CTA URL as plain text
|
||||
underneath in case the button does not survive a link-rewriting proxy.
|
||||
|
||||
The template is intentionally minimal — it covers the most common
|
||||
post-signup flow. Consumers needing additional structure (feature
|
||||
highlights, social-proof imagery, multi-step onboarding) should compose
|
||||
their own template using the same components.
|
||||
|
||||
## Features
|
||||
|
||||
- Themed heading + body copy + primary CTA + plain-URL fallback +
|
||||
divider + footer.
|
||||
- Configurable `ctaLabel` (defaults to `"Get Started"`).
|
||||
- Configurable `companyName` (defaults to `theme.branding.companyName`).
|
||||
- Subject function: `Welcome to ${companyName}!`.
|
||||
- Optional preview text via the `previewText` prop.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
const { html, text, subject } = renderToHtml(welcomeTemplate, {
|
||||
recipientName: "Jane",
|
||||
ctaUrl: "https://app.example.test/get-started",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Brand-customised with a preview text and a non-default CTA label:
|
||||
|
||||
```ts
|
||||
import { renderToHtml } from "@suaveplan/email-templates/renderer";
|
||||
import { createTheme } from "@suaveplan/email-templates/theme";
|
||||
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
|
||||
|
||||
const theme = createTheme({
|
||||
colors: { primary: "#22d3ee" },
|
||||
branding: { companyName: "Acme Corp" },
|
||||
});
|
||||
|
||||
const result = renderToHtml(
|
||||
welcomeTemplate,
|
||||
{
|
||||
recipientName: "Jane",
|
||||
ctaUrl: "https://acme.test/welcome",
|
||||
ctaLabel: "Continue",
|
||||
previewText: "You're in! Let's get you set up.",
|
||||
},
|
||||
{ theme },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ------------------ | -------- | ---------------------------------------------------- |
|
||||
| `welcomeTemplate` | const | `EmailTemplate<WelcomeProps>`. |
|
||||
| `WelcomeEmail` | function | Direct component access (returns a `VNode`). |
|
||||
| `WelcomeProps` | type | `{ recipientName, ctaUrl, ctaLabel?, companyName?, previewText? }` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The plain-URL fallback paragraph is rendered as a muted caption — small
|
||||
type, low contrast — so it doesn't compete with the CTA visually. The
|
||||
fallback line uses the same `ctaUrl` value as the button, so any later
|
||||
URL change touches one source of truth.
|
||||
|
||||
The footer's clock is intentionally not threaded through this template
|
||||
— consumers wanting a deterministic copyright year supply a `clock` to
|
||||
`renderToHtml`, which passes it to the component as part of the merged
|
||||
props bag.
|
||||
@@ -0,0 +1,89 @@
|
||||
# theme
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the theme tokens consumed by every email component — colours,
|
||||
typography, spacing, branding, border-radius, and max-width. Themes are
|
||||
immutable plain objects: `createTheme` returns a fresh object every call
|
||||
and never mutates the shipped `defaultTheme`, so the module is safe to
|
||||
import in cross-tenant contexts where two consumers compose distinct
|
||||
themes side-by-side.
|
||||
|
||||
The default palette is `#2563eb` primary (Tailwind blue-600), a slate
|
||||
secondary, and a 600 px max-width — sane email-client defaults that
|
||||
render correctly in Gmail, Apple Mail, Outlook 2016+, and the major web
|
||||
clients without further customisation.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultTheme`: frozen default `EmailTheme` instance.
|
||||
- `createTheme(options?)`: deep-merges `options` into `defaultTheme`,
|
||||
returning a new object.
|
||||
- `mergeThemes(base, override)`: shallow-merges each section into `base`.
|
||||
- Every section is independently overridable — partial colour overrides
|
||||
leave other colours intact.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createTheme } from "@suaveplan/email-templates/theme";
|
||||
|
||||
const theme = createTheme({
|
||||
colors: { primary: "#ff6600" },
|
||||
branding: { companyName: "Acme Corp" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose a per-locale theme by layering region-specific tokens onto a base:
|
||||
|
||||
```ts
|
||||
import { createTheme, mergeThemes } from "@suaveplan/email-templates/theme";
|
||||
|
||||
const base = createTheme({
|
||||
branding: { companyName: "Acme" },
|
||||
typography: {
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
},
|
||||
});
|
||||
|
||||
const jaTheme = mergeThemes(base, {
|
||||
typography: { fontFamily: "'Noto Sans JP', sans-serif" },
|
||||
});
|
||||
```
|
||||
|
||||
`mergeThemes` differs from `createTheme` in that it merges into the
|
||||
explicitly-supplied `base` (which may itself differ from `defaultTheme`)
|
||||
— useful when a multi-tenant pipeline pre-builds a per-tenant base and
|
||||
then layers a per-locale override on top.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| -------------- | -------- | ---------------------------------------------------------- |
|
||||
| `defaultTheme` | const | The shipped default `EmailTheme`. |
|
||||
| `createTheme` | function | `(options?) => EmailTheme` — overrides applied to default. |
|
||||
| `mergeThemes` | function | `(base, override) => EmailTheme`. |
|
||||
|
||||
Types: `EmailTheme`, `EmailThemeColors`, `EmailThemeTypography`,
|
||||
`EmailThemeSpacing`, `EmailThemeBranding`, `EmailThemeOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Token defaults are declared as module-local `const … as const`
|
||||
expressions so they cannot be re-bound; the exported `defaultTheme` is a
|
||||
spread of those tokens. The exported value is intentionally **not**
|
||||
`Object.freeze`'d — TypeScript's `readonly` already prevents accidental
|
||||
re-assignment at the type level, and freezing would interact badly with
|
||||
serialise / hydrate flows in downstream tests.
|
||||
|
||||
Spread-merging means deep nested fields cannot be partially overridden
|
||||
within the same `createTheme` call (e.g. you cannot override one branding
|
||||
field without losing the others) — each section is treated as a single
|
||||
unit. That matches upstream behaviour exactly; consumers who need a
|
||||
deeper merge can compose two `createTheme` calls with `mergeThemes`.
|
||||
@@ -0,0 +1,101 @@
|
||||
# vnode
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the universal VNode primitive — a `{ tag, attrs, children }`
|
||||
triple plus a pure-string HTML renderer — that lets `@suaveplan/email-templates`
|
||||
deliver the full upstream component surface without any React or DOM runtime
|
||||
dependency. Components emit VNode trees; the renderer is the single point at
|
||||
which children are HTML-escaped and attributes are serialised, removing the
|
||||
class of XSS bugs introduced when escaping is sprinkled per-component.
|
||||
|
||||
The module is the structural twin of `react-dom/server.renderToStaticMarkup`
|
||||
restricted to the HTML subset email clients accept: every element is one of
|
||||
the standard tags, every attribute is a primitive or a single `style` object,
|
||||
and the renderer always closes opened tags (no fragment / portal / boundary
|
||||
machinery).
|
||||
|
||||
## Features
|
||||
|
||||
- `h(tag, attrs, children, options?)` hyperscript factory with automatic
|
||||
self-closing detection for void HTML elements (`img`, `br`, `hr`, `meta`,
|
||||
`link`, `input`, `area`, `base`, `col`, `embed`, `source`, `track`, `wbr`).
|
||||
- `renderVNode(node)` walks the tree producing email-safe HTML, escaping text
|
||||
with the five-metacharacter HTML rule (`&<>"'`) and attribute values with
|
||||
the four-character attribute rule (`&"<>`).
|
||||
- `serialiseStyle(obj)` converts `{ borderRadius: '6px' }` to
|
||||
`border-radius:6px`, kebab-casing camelCase keys and omitting `undefined`.
|
||||
- `rawHtml(html)` escape hatch for the MSO conditional comment block emitted
|
||||
by `wrapInDocument`. Children are echoed verbatim — never pass user input.
|
||||
- `renderNodes(list)` concatenates a node list (used by the renderer).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
const html = renderVNode(
|
||||
h("p", { style: { color: "red" } }, "Hello, world"),
|
||||
);
|
||||
// '<p style="color:red">Hello, world</p>'
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose a full email body without React; `renderVNode` accepts arbitrary
|
||||
nesting, conditional children (via array entries with `undefined` / `null`
|
||||
/ `false` filtered out), and a numeric or boolean attribute set.
|
||||
|
||||
```ts
|
||||
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
|
||||
|
||||
const showFooter = true;
|
||||
const layout = h("table", { role: "presentation", cellpadding: 0 }, [
|
||||
h("tr", {}, h("td", { style: { padding: "16px" } }, "Hi Jane,")),
|
||||
showFooter && h("tr", {}, h("td", { align: "center" }, "Footer")),
|
||||
]);
|
||||
|
||||
renderVNode(layout);
|
||||
```
|
||||
|
||||
The renderer never inspects globals, never touches `process.env`, and never
|
||||
calls `Date.now()` — it runs identically in Bun, Node, and the browser.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| ------------------ | -------- | ----------------------------------------------------------------- |
|
||||
| `h` | function | Hyperscript factory returning a `VNode`. |
|
||||
| `renderVNode` | function | Walks a node or text, returning HTML. |
|
||||
| `renderNodes` | function | Renders a list of nodes/strings as concatenated HTML. |
|
||||
| `escapeHtml` | function | HTML text-content escape (`&<>"'`). |
|
||||
| `escapeAttr` | function | HTML attribute-value escape (`&"<>`). |
|
||||
| `serialiseStyle` | function | CSS object → inline string with kebab-cased keys. |
|
||||
| `rawHtml` | function | Escape-hatch raw-HTML embed for trusted internal fragments. |
|
||||
| `VNode` | type | The tree-node interface. |
|
||||
| `EmailNode` | type | `VNode \| string`. |
|
||||
| `EmailChildren` | type | Children accepted by `h(...)`. |
|
||||
| `StyleObject` | type | Style attribute shape. |
|
||||
| `AttrValue` | type | Permitted attribute value type. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The renderer deliberately serialises `style` objects with the **input**
|
||||
key order (insertion order, which is stable in modern JS engines). It does
|
||||
not sort or dedupe — components are expected to emit a single `style`
|
||||
object per element. CSS property values are coerced via `String(v)`, so
|
||||
numeric pixel-less values (e.g. `lineHeight: 1.5`) survive the round-trip
|
||||
intact.
|
||||
|
||||
The `__raw__` tag is reserved for internal use; if a component were to
|
||||
emit `h("__raw__", ...)` directly, the renderer would treat it the same as
|
||||
`rawHtml(...)` — children are emitted verbatim. The factory does NOT
|
||||
expose this tag publicly because it bypasses escaping.
|
||||
|
||||
Self-closing detection is a tag-name set lookup; the lookup is
|
||||
case-insensitive (`Img` → `img`). For non-HTML emitters the caller may
|
||||
override via the fourth argument.
|
||||
@@ -0,0 +1,89 @@
|
||||
# @suaveplan/emailer - Universal email service with plugin transports (Dummy / SMTP via injectable SmtpClientLike / Gmail REST via FetchLike), nine lifecycle hooks, retry, queue dispatch, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable Clock/Rng/Logger/FetchLike/TimersLike, stable defineCodes('EMAILER', [...]) error registry.
|
||||
|
||||
Universal email service with pluggable transports. Ships
|
||||
`EmailService`, three transports (Dummy / SMTP via injectable
|
||||
`SmtpClientLike` / Gmail REST via injectable `FetchLike`), nine lifecycle
|
||||
hooks, deterministic retry, structural queue dispatch, Zod-validated option
|
||||
boundaries, OpenTelemetry-instrumented hot paths, and a stable
|
||||
`defineCodes("EMAILER", [...])` error registry.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [email-service](./src/core/email-service/email-service.md) — `EmailService<TTransport>` is the public entry point for `@suaveplan/emailer`. It orchestrates message validation, life…
|
||||
- [base-email-transport](./src/core/email-service/base-email-transport.md) — `BaseEmailTransport` is the abstract helper that every concrete transport (`DummyTransport`, `SmtpTransport`, `GmailRes…
|
||||
- [email-service](./src/core/email-service/email-service.md) — `EmailService<TTransport>` is the public entry point for `@suaveplan/emailer`. It orchestrates message validation, life…
|
||||
- [retry](./src/core/retry/retry.md) — Deterministic retry helper used by `EmailService.send`. The helper takes a `RetryPolicy`, an injectable `TimersLike`, a…
|
||||
- [retry](./src/core/retry/retry.md) — Deterministic retry helper used by `EmailService.send`. The helper takes a `RetryPolicy`, an injectable `TimersLike`, a…
|
||||
- [validate](./src/core/validate/validate.md) — Shared address-formatting and message-validation helpers used by every concrete transport and by `EmailService` itself.…
|
||||
- [validate](./src/core/validate/validate.md) — Shared address-formatting and message-validation helpers used by every concrete transport and by `EmailService` itself.…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/emailer` ships a single stable error-code registry that every failure path in the package routes through. T…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/emailer` ships a single stable error-code registry that every failure path in the package routes through. T…
|
||||
- [fake-fetch](./src/fakes/fake-fetch.md) — Deterministic test double for `FetchLike`, used by `GmailRestTransport` tests. Queues scripted responses and captures e…
|
||||
- [fake-smtp-client](./src/fakes/fake-smtp-client.md) — Deterministic test double for `SmtpClientLike`. Tests queue scripted responses, invoke the wrapped `SmtpTransport`, and…
|
||||
- [otel](./src/otel/metrics.md) — Single source of truth for every OpenTelemetry instrument emitted by `@suaveplan/emailer`. Every production path that t…
|
||||
- [metrics](./src/otel/metrics.md) — Single source of truth for every OpenTelemetry instrument emitted by `@suaveplan/emailer`. Every production path that t…
|
||||
- [schemas](./src/schemas/schemas.md) — Centralised Zod schemas for every public option bundle and wire payload in `@suaveplan/emailer`. The schemas exist to e…
|
||||
- [schemas](./src/schemas/schemas.md) — Centralised Zod schemas for every public option bundle and wire payload in `@suaveplan/emailer`. The schemas exist to e…
|
||||
- [dummy](./src/transports/dummy/dummy-transport.md) — `DummyTransport` is a deterministic in-memory transport designed for tests, fixtures, and local development. It records…
|
||||
- [dummy-transport](./src/transports/dummy/dummy-transport.md) — `DummyTransport` is a deterministic in-memory transport designed for tests, fixtures, and local development. It records…
|
||||
- [gmail-rest](./src/transports/gmail-rest/gmail-rest-transport.md) — `GmailRestTransport` sends mail through Gmail's REST API directly, over an injectable `FetchLike`. The transport builds…
|
||||
- [gmail-rest-transport](./src/transports/gmail-rest/gmail-rest-transport.md) — `GmailRestTransport` sends mail through Gmail's REST API directly, over an injectable `FetchLike`. The transport builds…
|
||||
- [smtp](./src/transports/smtp/smtp-transport.md) — `SmtpTransport` is a structural SMTP transport: it accepts a caller-supplied `SmtpClientLike` adapter (the contract is…
|
||||
- [smtp-transport](./src/transports/smtp/smtp-transport.md) — `SmtpTransport` is a structural SMTP transport: it accepts a caller-supplied `SmtpClientLike` adapter (the contract is…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why universal
|
||||
|
||||
The upstream `@suaveplan/emailer` was server-only because it imported
|
||||
`nodemailer` and `googleapis` directly. The rewrite drops both hard
|
||||
dependencies in favour of injectable contracts:
|
||||
|
||||
- **SMTP**: callers supply a thin `SmtpClientLike` adapter so nodemailer
|
||||
(or any other library) lives in the consumer's app shell, not in this
|
||||
package.
|
||||
- **Gmail**: send is implemented directly against the Gmail REST API via
|
||||
`FetchLike`, so token acquisition is the caller's concern and the
|
||||
transport runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/core/email-service/email-service.md`](src/core/email-service/email-service.md) — `EmailService<TTransport>` orchestrator
|
||||
- [`src/core/email-service/base-email-transport.md`](src/core/email-service/base-email-transport.md) — abstract helper
|
||||
- [`src/core/retry/retry.md`](src/core/retry/retry.md) — deterministic retry helper
|
||||
- [`src/core/validate/validate.md`](src/core/validate/validate.md) — address-formatting helpers
|
||||
- [`src/transports/dummy/dummy-transport.md`](src/transports/dummy/dummy-transport.md) — in-memory test transport
|
||||
- [`src/transports/smtp/smtp-transport.md`](src/transports/smtp/smtp-transport.md) — SMTP via adapter
|
||||
- [`src/transports/smtp/smtp-client-like.types.md`](src/transports/smtp/smtp-client-like.types.md) — adapter contract
|
||||
- [`src/transports/gmail-rest/gmail-rest-transport.md`](src/transports/gmail-rest/gmail-rest-transport.md) — Gmail REST transport
|
||||
- [`src/error-codes.md`](src/error-codes.md) — error registry
|
||||
- [`src/schemas/schemas.md`](src/schemas/schemas.md) — Zod boundary schemas
|
||||
- [`src/types/emailer.types.md`](src/types/emailer.types.md) — public types
|
||||
- [`src/otel/metrics.md`](src/otel/metrics.md) — OTel instruments
|
||||
- [`src/fakes/fake-smtp-client.md`](src/fakes/fake-smtp-client.md) — deterministic SMTP fake
|
||||
- [`src/fakes/fake-fetch.md`](src/fakes/fake-fetch.md) — deterministic fetch fake
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { EmailService } from "@suaveplan/emailer";
|
||||
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
|
||||
|
||||
const transport = createDummyTransport();
|
||||
const service = new EmailService({
|
||||
transport,
|
||||
transportOptions: {},
|
||||
defaultFrom: "no-reply@example.com",
|
||||
});
|
||||
await service.initialize();
|
||||
const result = await service.send({
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
html: "<p>hi</p>",
|
||||
});
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# `base-email-transport.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`BaseEmailTransport` is the abstract helper that every concrete transport
|
||||
(`DummyTransport`, `SmtpTransport`, `GmailRestTransport`) extends. It
|
||||
centralises address formatting, message validation, initialisation
|
||||
bookkeeping, and result-construction so concrete transports stay focused
|
||||
on their wire format — not on cross-cutting concerns that would drift
|
||||
between implementations otherwise.
|
||||
|
||||
## Features
|
||||
|
||||
- `initialize(config)` records the config and flips an `initialized` flag;
|
||||
subclasses can override but typically just call `super.initialize`.
|
||||
- `assertInitialized()` raises `EmailerError(EMAILER.TRANSPORT_NOT_INITIALIZED)`
|
||||
if called before `initialize`, so every transport's `send` has a single
|
||||
guarded entry point.
|
||||
- `formatAddress`, `formatSingleAddress`, `extractEmail`, `toEmailArray` —
|
||||
thin proxies to `core/validate` so subclasses use the canonical
|
||||
formatters via `this.*`.
|
||||
- `createSuccessResult(messageId, response, metadata)` and
|
||||
`createErrorResult(error, metadata)` — produce `EmailSendResult` with the
|
||||
shape every transport returns. Conditional property assignment honours
|
||||
`exactOptionalPropertyTypes` from `tsconfig.base.json`.
|
||||
- `validateMessage(message)` — proxies to `core/validate.validateMessage`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BaseEmailTransport } from "@suaveplan/emailer/core/base-email-transport";
|
||||
|
||||
class NullTransport extends BaseEmailTransport<{}> {
|
||||
public readonly name = "null";
|
||||
public override async send(message) {
|
||||
this.assertInitialized();
|
||||
this.validateMessage(message);
|
||||
return this.createSuccessResult("null-1");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BaseEmailTransport } from "@suaveplan/emailer/core/base-email-transport";
|
||||
import type { EmailMessage, EmailSendResult } from "@suaveplan/emailer/types/emailer";
|
||||
|
||||
interface MyConfig {
|
||||
readonly target: URL;
|
||||
}
|
||||
|
||||
export class MyTransport extends BaseEmailTransport<MyConfig> {
|
||||
public readonly name = "my-transport";
|
||||
|
||||
public override async send(message: EmailMessage): Promise<EmailSendResult> {
|
||||
this.assertInitialized();
|
||||
try {
|
||||
this.validateMessage(message);
|
||||
const recipients = this.toEmailArray(message.to);
|
||||
const from = this.formatSingleAddress(message.from ?? "noreply@x.y");
|
||||
// ... actual wire send ...
|
||||
return this.createSuccessResult("ok", { recipients, from });
|
||||
} catch (error) {
|
||||
return this.createErrorResult(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `abstract class BaseEmailTransport<TConfig>`
|
||||
- `abstract readonly name: string`
|
||||
- `initialize(config: TConfig): Promise<void>`
|
||||
- `abstract send(message: EmailMessage): Promise<EmailSendResult>`
|
||||
- `verify(): Promise<boolean>` — defaults to the `initialized` flag.
|
||||
- `close(): Promise<void>` — flips `initialized` to `false`.
|
||||
- `assertInitialized(): void` (protected)
|
||||
- `formatSingleAddress`, `formatAddress`, `extractEmail`, `toEmailArray`,
|
||||
`validateMessage`, `createSuccessResult`, `createErrorResult` (protected)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The class is universal — every helper avoids Node-only APIs so the
|
||||
class compiles cleanly under the package's `"lib": ["ES2022", "DOM"]`
|
||||
target.
|
||||
- `createSuccessResult` and `createErrorResult` use the `-readonly` mapped
|
||||
type trick to build up the result object piece-by-piece without
|
||||
violating `exactOptionalPropertyTypes`.
|
||||
- `assertInitialized` is the only throw site in the base class; every
|
||||
other helper returns or proxies.
|
||||
@@ -0,0 +1,104 @@
|
||||
# `email-service.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EmailService<TTransport>` is the public entry point for `@suaveplan/emailer`.
|
||||
It orchestrates message validation, lifecycle hooks, retry with backoff,
|
||||
queue dispatch, OpenTelemetry emission, structured logging, and running
|
||||
statistics against any `EmailTransport` implementation. The service is
|
||||
generic over the transport so type-level capability (e.g. that a particular
|
||||
transport's config bag is `GmailRestTransportOptions`) propagates through
|
||||
`EmailService.initialize` without runtime widening.
|
||||
|
||||
## Features
|
||||
|
||||
- Validates `EmailerOptions` via Zod `safeParse` in the constructor; throws
|
||||
`EmailerError(EMAILER.INVALID_CONFIG)` carrying the `ZodError` on bad
|
||||
input.
|
||||
- Nine lifecycle hook arrays (`beforeValidate`, `afterValidate`,
|
||||
`beforeSend`, `afterSend`, `onError`, `afterError`, `beforeRetry`,
|
||||
`beforeQueue`, `afterQueue`) with `addHook` / `removeHook` / `clearHooks`.
|
||||
- Retry with linear or exponential backoff via injectable `TimersLike` and
|
||||
`AbortSignal`.
|
||||
- Optional queue dispatch via structural `QueueLike` (`{ add(fn) }`).
|
||||
- OTel emission on every send / error / retry / queue path.
|
||||
- Stats: `sent`, `failed`, `queued`, `retries`, `startTime`, `lastSendTime`.
|
||||
- `initialize` invokes `transport.initialize(transportOptions)` then
|
||||
`transport.verify()` if present; verification failure raises
|
||||
`EMAILER.VERIFY_FAILED`.
|
||||
- `close` calls the transport's `close` and emits `service:closed`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailService } from "@suaveplan/emailer/core/email-service";
|
||||
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
|
||||
|
||||
const service = new EmailService({
|
||||
transport: createDummyTransport(),
|
||||
transportOptions: {},
|
||||
defaultFrom: "no-reply@example.com",
|
||||
});
|
||||
await service.initialize();
|
||||
const result = await service.send({
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
text: "hi",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { EmailService } from "@suaveplan/emailer/core/email-service";
|
||||
import { GmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "abc123" } });
|
||||
|
||||
const ac = new AbortController();
|
||||
const service = new EmailService({
|
||||
transport: new GmailRestTransport(),
|
||||
transportOptions: { accessToken: "ya29.test", fetch: fake.fetch },
|
||||
retry: { maxAttempts: 3, delay: 50, backoff: "exponential" },
|
||||
signal: ac.signal,
|
||||
hooks: {
|
||||
beforeSend: [async (ctx) => logger.info({ to: ctx.message.to })],
|
||||
afterSend: [async (ctx) => audit.record(ctx.result.messageId)],
|
||||
},
|
||||
});
|
||||
await service.initialize();
|
||||
const result = await service.send({ to: "x@y.z", subject: "hi", text: "hi" });
|
||||
console.info(service.getStats().sent); // 1
|
||||
await service.close();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class EmailService<TTransport extends EmailTransport>`
|
||||
- `constructor(options: EmailerOptions<TTransport>)`
|
||||
- `initialize(): Promise<void>`
|
||||
- `send(message: EmailMessage): Promise<EmailSendResult>`
|
||||
- `queue(message: EmailMessage): Promise<void>`
|
||||
- `addHook<K>(event, hook)` / `removeHook<K>(event, hook)` / `clearHooks<K>(event)`
|
||||
- `getStats(): Readonly<EmailerStats>` / `resetStats(): void`
|
||||
- `close(): Promise<void>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `send` returns `EmailSendResult` (never throws) — failures surface as
|
||||
`{ success: false, error }` so callers can decide whether to log, retry,
|
||||
or escalate without a `try`/`catch`.
|
||||
- `queue` throws `EMAILER.QUEUE_NOT_CONFIGURED` if no `QueueLike` was
|
||||
supplied; that is the only public method that uses an exception for
|
||||
control flow.
|
||||
- The Zod validation in the constructor only checks the serialisable
|
||||
subset of options. Live objects (transport, logger, bus, signal) pass
|
||||
through unchanged because Zod cannot meaningfully validate them.
|
||||
- Stats are mutable internally but the `getStats()` snapshot is a copy so
|
||||
callers cannot observe a partially-mutated counter.
|
||||
@@ -0,0 +1,96 @@
|
||||
# `retry.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic retry helper used by `EmailService.send`. The helper takes a
|
||||
`RetryPolicy`, an injectable `TimersLike`, an optional `AbortSignal`, and a
|
||||
`RetryHooks` bag, then drives a bounded attempt loop with linear or
|
||||
exponential backoff. Injecting the timers means tests can drive the loop
|
||||
through synthetic delays without scheduling real `setTimeout` callbacks,
|
||||
which is essential for deterministic CI runs on WSL2.
|
||||
|
||||
## Features
|
||||
|
||||
- `computeBackoff(policy, attempt)` — returns the delay between `attempt`
|
||||
and `attempt + 1` (linear: constant `delay`; exponential: `delay * 2^(n-1)`).
|
||||
- `delay(timers, delayMs, signal)` — schedules a single delay and rejects
|
||||
early if `signal` is aborted.
|
||||
- `runWithRetry<TResult>(policy, timers, signal, hooks)` — top-level loop:
|
||||
invokes `hooks.attempt(n)`, checks `hooks.isSuccess`, fires
|
||||
`hooks.onError` / `hooks.onBeforeRetry`, increments retry stats via
|
||||
`hooks.onRetryIncrement`, and sleeps `computeBackoff` between failed
|
||||
attempts.
|
||||
- `defaultTimers` — host-default `setTimeout` / `clearTimeout` pair used
|
||||
when no `TimersLike` is supplied.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runWithRetry, defaultTimers } from "@suaveplan/emailer/core/retry";
|
||||
|
||||
const result = await runWithRetry(
|
||||
{ maxAttempts: 3, delay: 100, backoff: "exponential" },
|
||||
defaultTimers,
|
||||
undefined,
|
||||
{
|
||||
attempt: () => transport.send(message),
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: (r) => r.error,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { runWithRetry } from "@suaveplan/emailer/core/retry";
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
|
||||
const fakeTimers: TimersLike = {
|
||||
setTimer: (cb) => {
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
clearTimer: () => undefined,
|
||||
};
|
||||
|
||||
const ac = new AbortController();
|
||||
const result = await runWithRetry(
|
||||
{ maxAttempts: 5, delay: 50, backoff: "linear" },
|
||||
fakeTimers,
|
||||
ac.signal,
|
||||
{
|
||||
attempt: (n) => transport.send({ ...message, headers: { "X-Attempt": String(n) } }),
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: (r) => r.error,
|
||||
onError: async (err, n) => logger.warn("send-failed", { err, n }),
|
||||
onBeforeRetry: async (err, next) => logger.info("retrying", { next }),
|
||||
onRetryIncrement: () => stats.retries++,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `computeBackoff(policy: RetryPolicy, attempt: number): number`
|
||||
- `delay(timers: TimersLike, delayMs: number, signal: AbortSignal | undefined): Promise<void>`
|
||||
- `runWithRetry<TResult>(policy, timers, signal, hooks): Promise<TResult>`
|
||||
- `defaultTimers: TimersLike`
|
||||
- `RetryHooks<TResult>` — interface
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The helper does not throw on a failed-but-defined result; it returns it
|
||||
unchanged so the caller can decide whether to surface the result or
|
||||
raise. This is what enables `EmailService` to record a stat increment
|
||||
and emit `email:failed` while still returning a result to the caller.
|
||||
- `AbortSignal` is honoured at three points: before every attempt, before
|
||||
every sleep, and as a sleep listener. Aborting between attempts raises
|
||||
`EmailerError(EMAILER.ABORTED)` rather than the last transport error.
|
||||
- The default `RetryPolicy` (`{ maxAttempts: 1, delay: 0 }`) effectively
|
||||
disables retry. `EmailService` substitutes this when no `retry` option
|
||||
is passed; the helper runs once and returns the result unchanged.
|
||||
@@ -0,0 +1,81 @@
|
||||
# `validate.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared address-formatting and message-validation helpers used by every
|
||||
concrete transport and by `EmailService` itself. Keeping these helpers in
|
||||
one module means the wire-format contract for `to` / `from` / `cc` / `bcc`
|
||||
/ `replyTo` is identical across `DummyTransport`, `SmtpTransport`, and
|
||||
`GmailRestTransport`, and that the "to / subject / body" required-field
|
||||
check is enforced at a single throw site rather than five subtly-different
|
||||
copies.
|
||||
|
||||
## Features
|
||||
|
||||
- `formatSingleAddress` — `{name, email}` → `"name" <email>` per RFC 5322.
|
||||
- `formatAddress` — address-bag (string | object | array) → string |
|
||||
string[].
|
||||
- `extractEmail` — strip the display name and unwrap `<addr>`.
|
||||
- `toEmailArray` — address-bag → string[] (used by accept/reject lists).
|
||||
- `validateMessage` — throws `EmailerError(EMAILER.INVALID_MESSAGE)` if the
|
||||
message lacks a recipient, subject, or both bodies.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatSingleAddress,
|
||||
validateMessage,
|
||||
} from "@suaveplan/emailer/core/validate";
|
||||
|
||||
formatSingleAddress({ email: "a@b.c", name: "Alice" });
|
||||
// → "\"Alice\" <a@b.c>"
|
||||
|
||||
validateMessage({ to: "x@y.z", subject: "hello", text: "hi" });
|
||||
// → no throw
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
extractEmail,
|
||||
formatAddress,
|
||||
toEmailArray,
|
||||
} from "@suaveplan/emailer/core/validate";
|
||||
|
||||
const bag = ["alice@example.com", { email: "bob@example.com", name: "Bob" }];
|
||||
const wire = formatAddress(bag);
|
||||
// → ["alice@example.com", "\"Bob\" <bob@example.com>"]
|
||||
|
||||
const emails = toEmailArray(wire);
|
||||
// → ["alice@example.com", "bob@example.com"]
|
||||
|
||||
const raw = extractEmail("\"Charlie\" <c@e.com>");
|
||||
// → "c@e.com"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `formatSingleAddress(address: string | EmailAddress): string`
|
||||
- `formatAddress(address: AddressBag | undefined): string | string[] | undefined`
|
||||
- `extractEmail(address: string | EmailAddress): string`
|
||||
- `toEmailArray(address: AddressBag | undefined): string[]`
|
||||
- `validateMessage(message: EmailMessage): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `validateMessage` raises `EmailerError(EMAILER.INVALID_MESSAGE)` with the
|
||||
failing field path (`to` / `subject` / `body`) carried as `context.field`
|
||||
so downstream loggers know which field to call out.
|
||||
- `extractEmail` accepts bare email strings (returns unchanged), addresses
|
||||
in `Name <addr>` form (extracted), and `EmailAddress` objects (returns
|
||||
`.email`). It deliberately does not validate the extracted string; that
|
||||
is the boundary's job, not the formatter's.
|
||||
- `formatAddress` returns `undefined` (not an empty string) when the input
|
||||
is undefined so callers can spread the result into wire payloads without
|
||||
emitting an empty header.
|
||||
@@ -0,0 +1,88 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/emailer` ships a single stable error-code registry that every
|
||||
failure path in the package routes through. The registry is built via
|
||||
`defineCodes("EMAILER", [...])` from `@suaveplan/error`, which freezes the
|
||||
codes into a `{ KEY: "EMAILER.KEY" }` map at compile time and preserves the
|
||||
literal-typed union all the way through to downstream consumers.
|
||||
|
||||
This guarantees three properties that hand-rolled `string` constants cannot:
|
||||
|
||||
1. Every code carries the `EMAILER.` prefix so it sorts and filters cleanly
|
||||
alongside other `@suaveplan/*` registries in logs, metrics, and
|
||||
problem+json responses.
|
||||
2. The `EmailerErrorCode` union is a true literal-type union (never widened
|
||||
to `string`), so exhaustive-`switch` checks and discriminated-union
|
||||
handlers downstream catch missing branches at compile time.
|
||||
3. `EmailerError extends SuaveplanError<EmailerErrorCode>` preserves the
|
||||
typed code on every thrown instance, so the OTel error-span adapter
|
||||
tags the right `error.code` attribute without inspecting the message.
|
||||
|
||||
## Features
|
||||
|
||||
- Twelve registered codes covering every failure mode: configuration,
|
||||
validation, transport lifecycle, retry exhaustion, abort, auth, rate
|
||||
limiting, timeout, MIME build.
|
||||
- Every code is reachable from at least one production throw site —
|
||||
pinned by `src/error-codes.test.ts::"every registered code is reachable"`.
|
||||
- `EmailerError` constructor accepts `(code, messageKey, options)` so the
|
||||
i18n hook in `@suaveplan/error` can resolve the human-readable string at
|
||||
format time without ever lighting up `console.error` paths in this
|
||||
package.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EMAILER_CODES, EmailerError } from "@suaveplan/emailer/error-codes";
|
||||
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_MESSAGE,
|
||||
"emailer.errors.invalidMessage",
|
||||
{ context: { field: "to" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { EMAILER_CODES, type EmailerErrorCode } from "@suaveplan/emailer/error-codes";
|
||||
|
||||
function describe(code: EmailerErrorCode): string {
|
||||
switch (code) {
|
||||
case EMAILER_CODES.RATE_LIMITED:
|
||||
return "back off and retry with jitter";
|
||||
case EMAILER_CODES.AUTH_FAILED:
|
||||
return "refresh the access token";
|
||||
case EMAILER_CODES.TRANSPORT_FAILED:
|
||||
return "fall over to the next transport in the chain";
|
||||
// ... every other code, exhaustively
|
||||
default:
|
||||
const _exhaustive: never = code;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EMAILER_CODES` — frozen record of every code.
|
||||
- `EmailerErrorCode` — literal-type union of every fully-qualified code.
|
||||
- `EmailerError` — `class EmailerError extends SuaveplanError<EmailerErrorCode>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is the contract; never delete a code to satisfy a gate. If
|
||||
the conformance auditor reports a code as dead, wire it into the
|
||||
production path the proposal designed it for.
|
||||
- Throw `EmailerError`, never `new Error(...)`. The base class carries the
|
||||
OTel attributes, redaction, and i18n hook that downstream consumers rely
|
||||
on.
|
||||
- The package `src/index.ts` imports `./error-codes.js` for side effects so
|
||||
subpath consumers see the registered namespace even when they do not
|
||||
touch the main entry point.
|
||||
@@ -0,0 +1,76 @@
|
||||
# `fake-fetch.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic test double for `FetchLike`, used by `GmailRestTransport`
|
||||
tests. Queues scripted responses and captures every call's URL + init bag
|
||||
so assertions target exact payloads, headers, and methods. Shipping the
|
||||
fake from the package makes downstream tests against `GmailRestTransport`
|
||||
match the conventions used in the package's own test suite without
|
||||
re-implementing the contract.
|
||||
|
||||
## Features
|
||||
|
||||
- `push(response)` enqueues a scripted response: `status`, `headers`,
|
||||
`json` (auto-stringified), `text` (raw), or `reject` (forces a thrown
|
||||
fetch).
|
||||
- `calls` captures every invocation with the resolved URL and the init
|
||||
bag.
|
||||
- The exported `fetch` callable is `FetchLike`-shaped and directly
|
||||
injectable into any transport that accepts `FetchLike`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "abc" } });
|
||||
const response = await fake.fetch("https://example.com/api");
|
||||
console.info(await response.json()); // { id: "abc" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
import { GmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 429, json: { error: { message: "slow down" } } });
|
||||
|
||||
const transport = new GmailRestTransport();
|
||||
await transport.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await transport.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
// r.error.code === "EMAILER.RATE_LIMITED"
|
||||
console.info(fake.calls[0].url);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FakeFetch`
|
||||
- `interface FakeFetchResponse { status?; headers?; json?; text?; reject?; }`
|
||||
- `interface FakeFetchCall { url: string; init?: RequestInit; }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Body resolution order: `text` > `json` > empty string. `headers` always
|
||||
merge into the Response.
|
||||
- `reject` short-circuits the entire fetch — useful for asserting
|
||||
transport failure handling, abort wiring, and timeout classification.
|
||||
- The fake never spawns network IO, never opens sockets, and never holds
|
||||
timers; it is safe to instantiate inside synchronous setup and to share
|
||||
across describe blocks without per-test reset cost.
|
||||
- The captured `init` bag is the exact `RequestInit` the transport
|
||||
produced. Tests assert against it directly to confirm method, headers
|
||||
(including `Authorization`), and the JSON-encoded body. Because the
|
||||
capture is structural — not a string snapshot — refactors that change
|
||||
unrelated header ordering do not break the test.
|
||||
- The exported `fetch` is a bound arrow function so it can be passed
|
||||
directly into any `FetchLike` slot without `.bind(this)`. The class
|
||||
itself can be discarded once the fetch closure has been captured if
|
||||
the caller does not need to inspect the calls list.
|
||||
- A future enhancement may add per-response `delay` scheduling driven by
|
||||
an injectable `TimersLike` so consumers can assert backoff schedules
|
||||
end-to-end via `GmailRestTransport`. For now, that is the retry
|
||||
helper's job — see `src/core/retry/retry.md`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# `fake-smtp-client.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic test double for `SmtpClientLike`. Tests queue scripted
|
||||
responses, invoke the wrapped `SmtpTransport`, and inspect the captured
|
||||
calls — without `vi.mock`, without a real SMTP server, and without
|
||||
monkey-patching globals. Shipping the fake from the package itself means
|
||||
downstream consumers can test their integration against the same exact
|
||||
shape `SmtpTransport` produces, without re-implementing the protocol fake.
|
||||
|
||||
## Features
|
||||
|
||||
- `push(response)` queues a scripted outcome (`info` for success, `reject`
|
||||
for failure).
|
||||
- `calls` captures every `sendMail` invocation in FIFO order so
|
||||
assertions can target individual recipients / headers / attachments.
|
||||
- `scheduleVerify(true | false | Error)` programs the next `verify()` call.
|
||||
- `verifyCalls` and `closeCalls` track lifecycle so tests can assert
|
||||
cleanup behaviour.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
|
||||
import { SmtpTransport } from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
const adapter = new FakeSmtpClient();
|
||||
adapter.push({ info: { messageId: "<abc@host>" } });
|
||||
|
||||
const transport = new SmtpTransport();
|
||||
await transport.initialize({ client: adapter });
|
||||
const r = await transport.send({ to: "u@x.y", subject: "hi", text: "hi" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
|
||||
|
||||
const adapter = new FakeSmtpClient();
|
||||
adapter.push({ reject: new Error("connection refused") });
|
||||
adapter.push({ info: { messageId: "<1@h>", accepted: ["a@b.c"] } });
|
||||
adapter.scheduleVerify(true);
|
||||
|
||||
// First send fails, second succeeds, verify returns true.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FakeSmtpClient implements SmtpClientLike`
|
||||
- `interface FakeSmtpResponse { info?, reject? }`
|
||||
- `interface FakeSmtpCall { payload: SmtpSendPayload }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The fake is intentionally minimal — it carries no SMTP state, no auth,
|
||||
no connection pool. The point is that `SmtpTransport` only ever calls
|
||||
three methods (`sendMail`, `verify`, `close`), and the fake reproduces
|
||||
exactly those three semantics with FIFO scripting.
|
||||
- `scheduleVerify(Error)` is single-use — after the next `verify` throws,
|
||||
the reject queue clears. Subsequent calls return the most-recently
|
||||
scheduled boolean (default `true`).
|
||||
- `closeCalls` increments on every `close`, allowing tests to assert
|
||||
idempotent close behaviour without inspecting private state.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `metrics.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Single source of truth for every OpenTelemetry instrument emitted by
|
||||
`@suaveplan/emailer`. Every production path that touches the wire — send
|
||||
attempts, send failures, latency, retries, queue enqueues — records exactly
|
||||
one observation through one of the helpers exported here. Centralising the
|
||||
emission lets the spec-conformance test grep one file to assert that every
|
||||
declared instrument is wired into a production call.
|
||||
|
||||
## Features
|
||||
|
||||
- `recordSend(transport, outcome, latencyMs)` — counter + histogram pair
|
||||
for every send attempt.
|
||||
- `recordSendError(transport, code)` — counter keyed on the typed
|
||||
`EmailerErrorCode` so dashboards filter by code path, not message.
|
||||
- `recordRetry(transport)` — counter on every retry attempt.
|
||||
- `recordQueue(transport)` — counter on every enqueue.
|
||||
- `refreshEmailerMeters()` — re-binds every instrument against the current
|
||||
global `MeterProvider`; tests call this after installing an in-memory
|
||||
provider so subsequent emissions land on the test exporter.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordSend } from "@suaveplan/emailer/otel/metrics";
|
||||
|
||||
const startedAt = Date.now();
|
||||
// ... actual send ...
|
||||
recordSend("dummy", "ok", Date.now() - startedAt);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { refreshEmailerMeters, recordSend } from "@suaveplan/emailer/otel/metrics";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshEmailerMeters();
|
||||
|
||||
recordSend("dummy", "ok", 12);
|
||||
// `provider.collect()` now contains an `emailer.send.total` observation
|
||||
// with attributes `{ transport: "dummy", outcome: "ok" }`.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `refreshEmailerMeters(): void`
|
||||
- `recordSend(transport: string, outcome: "ok" | "error", latencyMs: number): void`
|
||||
- `recordSendError(transport: string, code: string): void`
|
||||
- `recordRetry(transport: string): void`
|
||||
- `recordQueue(transport: string): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The five instrument names — `emailer.send.total`,
|
||||
`emailer.send.errors`, `emailer.send.latency_ms`, `emailer.retry.total`,
|
||||
`emailer.queue.depth` — are part of the public surface. Renaming them
|
||||
silently breaks every downstream dashboard, so any change must move
|
||||
through OpenSpec.
|
||||
- `refreshEmailerMeters` is required because the OpenTelemetry SDK does
|
||||
not propagate provider changes to existing instruments. Tests that swap
|
||||
the global provider must call it before invoking the production path,
|
||||
otherwise observations land on the module-load meter and the test sees
|
||||
zero data.
|
||||
- All instruments are universally available — the helpers carry no IO and
|
||||
are safe to call from synchronous hot paths.
|
||||
@@ -0,0 +1,89 @@
|
||||
# `schemas.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralised Zod schemas for every public option bundle and wire payload in
|
||||
`@suaveplan/emailer`. The schemas exist to enforce a hard boundary between
|
||||
the package and its callers: every option object passed into the service or
|
||||
into a concrete transport is `safeParse`d before it is trusted. On failure
|
||||
the relevant constructor raises `EmailerError(EMAILER.INVALID_CONFIG)` with
|
||||
the `ZodError` carried as the `cause` so observability, logging, and
|
||||
problem+json responders downstream can render the offending field path.
|
||||
|
||||
## Features
|
||||
|
||||
- `EmailAddressSchema`, `AddressBagSchema`, `EmailAttachmentSchema`,
|
||||
`EmailMessageSchema` — wire-level payloads.
|
||||
- `RetryPolicySchema`, `EmailerOptionsShapeSchema` — service config.
|
||||
- `DummyTransportOptionsSchema`, `SmtpTransportOptionsSchema`,
|
||||
`GmailRestTransportOptionsSchema` — per-transport configs.
|
||||
- All schemas use Zod 4's default import (`import z from "zod"`) per the
|
||||
Bun-safe convention codified in MEMORY.md.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailMessageSchema } from "@suaveplan/emailer/schemas/schemas";
|
||||
|
||||
const result = EmailMessageSchema.safeParse({
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
text: "hi",
|
||||
});
|
||||
if (!result.success) {
|
||||
throw result.error;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { GmailRestTransportOptionsSchema } from "@suaveplan/emailer/schemas/schemas";
|
||||
import { EMAILER_CODES, EmailerError } from "@suaveplan/emailer/error-codes";
|
||||
|
||||
function loadGmailConfigFromEnv(env: Record<string, string | undefined>) {
|
||||
const parsed = GmailRestTransportOptionsSchema.safeParse({
|
||||
accessToken: env["GMAIL_ACCESS_TOKEN"],
|
||||
userId: env["GMAIL_USER_ID"],
|
||||
});
|
||||
if (!parsed.success) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_CONFIG,
|
||||
"emailer.errors.invalidConfig",
|
||||
{ cause: parsed.error, context: { source: "env" } },
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EmailAddressSchema`
|
||||
- `AddressBagSchema`
|
||||
- `EmailAttachmentSchema`
|
||||
- `EmailMessageSchema`
|
||||
- `RetryPolicySchema`
|
||||
- `EmailerOptionsShapeSchema`
|
||||
- `DummyTransportOptionsSchema`
|
||||
- `SmtpTransportOptionsSchema`
|
||||
- `GmailRestTransportOptionsSchema`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `EmailerOptionsShapeSchema` only validates the serialisable subset of
|
||||
`EmailerOptions`; the live objects (transport instance, logger, event
|
||||
bus, queue, signal) are passed through unchanged because Zod cannot
|
||||
meaningfully assert "this object implements `EmailTransport`."
|
||||
- `AddressBagSchema` accepts either a single string, a structured
|
||||
`EmailAddress`, or a non-empty array of either; this matches the wire
|
||||
shape every transport accepts.
|
||||
- `EmailAttachmentSchema` accepts `Uint8Array` (universal) or `string`; the
|
||||
upstream `NodeJS.ReadableStream` form is deliberately dropped because it
|
||||
would force a Node dependency.
|
||||
- `RetryPolicySchema` enforces `maxAttempts >= 1` so the policy is always
|
||||
attempt-count-meaningful — a `0` value would deadlock the runner.
|
||||
@@ -0,0 +1,96 @@
|
||||
# `dummy-transport.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DummyTransport` is a deterministic in-memory transport designed for tests,
|
||||
fixtures, and local development. It records every message it receives,
|
||||
forwards lifecycle events to an injected `EventBusLike`, and can simulate
|
||||
forced failure, fixed latency, and verification toggles — all without
|
||||
lighting up any real network or holding any IO handle.
|
||||
|
||||
## Features
|
||||
|
||||
- `getSentEmails()`, `getLastEmail()`, `getSendCount()` — inspect the
|
||||
recorded history.
|
||||
- `clearSentEmails()` — reset the recorder.
|
||||
- `shouldFail: true` — every `send` returns `{ success: false, error }`.
|
||||
- `delayMs > 0` — simulated latency scheduled via injectable `TimersLike`.
|
||||
- `messageIdPrefix` — controls the prefix used in generated message-ids.
|
||||
- `verifySuccess` — controls the return value of `verify`.
|
||||
- `events` — injectable `EventBusLike`; `provider:beforeSend`,
|
||||
`provider:sendSuccess`, `provider:sendError`, `provider:validationError`,
|
||||
`provider:verify`, `provider:closing`, `provider:closed`,
|
||||
`provider:cleared` are forwarded.
|
||||
- `rng` — injectable `Rng` for the message-id suffix; falls back to
|
||||
`Date.now()` if not supplied so consumers don't need a CSPRNG just to
|
||||
smoke-test.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
|
||||
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({});
|
||||
const result = await transport.send({
|
||||
to: "user@example.com",
|
||||
subject: "hi",
|
||||
text: "hello",
|
||||
});
|
||||
console.info(transport.getSentEmails().length); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
|
||||
import type { EventBusLike, Rng, TimersLike } from "@suaveplan/types";
|
||||
|
||||
const events: EventBusLike = { emit: (e, p) => bus.publish(e, p) };
|
||||
const rng: Rng = {
|
||||
nextUint32: () => 0xdeadbeef,
|
||||
nextFloat: () => 0.5,
|
||||
};
|
||||
const timers: TimersLike = {
|
||||
setTimer: (cb) => {
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
clearTimer: () => undefined,
|
||||
};
|
||||
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({
|
||||
shouldFail: false,
|
||||
delayMs: 100,
|
||||
messageIdPrefix: "test",
|
||||
verifySuccess: true,
|
||||
events,
|
||||
rng,
|
||||
timers,
|
||||
});
|
||||
const r = await transport.send({ to: "x@y.z", subject: "hi", text: "hi" });
|
||||
// r.messageId === "test-1-deadbeef"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class DummyTransport extends BaseEmailTransport<DummyTransportOptions>`
|
||||
- `createDummyTransport(): DummyTransport`
|
||||
- `interface DummyTransportOptions`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The transport extends `BaseEmailTransport` and reuses every helper, so
|
||||
the wire-shape rules are identical to the SMTP and Gmail transports.
|
||||
- `delayMs` uses the injected timer's `setTimer` directly; tests can pass
|
||||
a synchronous `setTimer` to drop the delay entirely.
|
||||
- Message-id construction is deterministic when `rng` is supplied, which
|
||||
lets snapshot tests assert against exact strings.
|
||||
- The recorded history is exposed as a defensive copy
|
||||
(`[...this.sentEmails]`) so callers cannot mutate the transport's
|
||||
internal state.
|
||||
@@ -0,0 +1,98 @@
|
||||
# `gmail-rest-transport.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GmailRestTransport` sends mail through Gmail's REST API directly, over an
|
||||
injectable `FetchLike`. The transport builds a minimal RFC 822 envelope,
|
||||
base64url-encodes it, and POSTs it to `users.messages.send`. Crucially the
|
||||
package does not depend on `googleapis` or `nodemailer` — every token-
|
||||
acquisition concern (OAuth2 refresh, service-account JWT, app passwords) is
|
||||
the caller's, so this transport stays universal and runs unchanged in
|
||||
Bun, Node, and the browser.
|
||||
|
||||
## Features
|
||||
|
||||
- Universal: only requires `FetchLike` plus a caller-supplied access
|
||||
token.
|
||||
- `toBase64Url` exported as a pure helper for downstream consumers that
|
||||
need the same encoding.
|
||||
- Status-code → error-code classifier:
|
||||
- 401 / 403 → `EMAILER.AUTH_FAILED`
|
||||
- 429 → `EMAILER.RATE_LIMITED`
|
||||
- any other non-2xx → `EMAILER.TRANSPORT_FAILED`
|
||||
- Carries the HTTP status and error detail on the thrown `EmailerError`'s
|
||||
`context` bag so downstream loggers render structured detail without
|
||||
scraping the message string.
|
||||
- Zod validation on initialise via `GmailRestTransportOptionsSchema`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createGmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
|
||||
const transport = createGmailRestTransport();
|
||||
await transport.initialize({ accessToken: "ya29.a0..." });
|
||||
const r = await transport.send({
|
||||
to: "user@example.com",
|
||||
subject: "hi",
|
||||
text: "hello",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createGmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
|
||||
// Test wiring with a deterministic fetch double:
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "1816fc...", threadId: "1816fc..." } });
|
||||
|
||||
const transport = createGmailRestTransport();
|
||||
await transport.initialize({
|
||||
accessToken: "ya29.test",
|
||||
userId: "alice@example.com",
|
||||
endpoint: "https://gmail.googleapis.com/gmail/v1",
|
||||
fetch: fake.fetch,
|
||||
});
|
||||
const r = await transport.send({
|
||||
to: "bob@example.com",
|
||||
subject: "hi",
|
||||
html: "<p>hello</p>",
|
||||
});
|
||||
const captured = fake.calls[0]!;
|
||||
console.info(captured.url);
|
||||
// → https://gmail.googleapis.com/gmail/v1/users/alice%40example.com/messages/send
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class GmailRestTransport extends BaseEmailTransport<GmailRestTransportOptions>`
|
||||
- `createGmailRestTransport(): GmailRestTransport`
|
||||
- `interface GmailRestTransportOptions { accessToken; userId?; endpoint?; fetch?; }`
|
||||
- `toBase64Url(input: string): string`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The endpoint URL is constructed via `encodeURIComponent(userId)` so
|
||||
user-ids with `@` characters (e.g. delegated mailboxes) are escaped
|
||||
correctly. The default `userId` is `"me"`, matching the Gmail REST
|
||||
convention.
|
||||
- Token refresh is deliberately outside the transport's scope; the access
|
||||
token is a snapshot of the caller's identity at construction time.
|
||||
Long-lived services should re-initialise (or re-construct) the
|
||||
transport when the token expires.
|
||||
- The MIME builder is intentionally minimal — `text/plain` or `text/html`
|
||||
with optional caller-supplied headers. Multipart, inline images, and
|
||||
attachment encoding are deferred to a follow-up because Gmail's REST
|
||||
API itself accepts any valid MIME envelope, so the caller can pre-build
|
||||
one and pass it via `message.headers` if they need more than the
|
||||
built-in format.
|
||||
- `toBase64Url` prefers the WHATWG `btoa` route so the transport works in
|
||||
the browser; the Node `Buffer` fallback exists only for environments
|
||||
that strip `btoa`.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `smtp-client-like.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the minimal structural contract every SMTP adapter must satisfy.
|
||||
The contract is intentionally tiny — `sendMail`, optional `verify`, optional
|
||||
`close` — so any SMTP library (nodemailer, lettre over a Node binding,
|
||||
mailparser, a custom Bun/Node wrapper, ...) can be plugged in via a thin
|
||||
wrapper without the emailer package ever importing the library directly.
|
||||
|
||||
## Features
|
||||
|
||||
- `SmtpClientLike` — the adapter contract: `sendMail(payload)`, optional
|
||||
`verify()`, optional `close()`.
|
||||
- `SmtpSendPayload` — the wire shape every adapter receives: `from`, `to`,
|
||||
`cc`, `bcc`, `replyTo`, `subject`, `html`, `text`, `attachments`,
|
||||
`headers`, `priority`, `messageId`.
|
||||
- `SmtpSendInfo` — the response shape the adapter returns: `messageId`,
|
||||
`accepted`, `rejected`, `response`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SmtpClientLike,
|
||||
SmtpSendInfo,
|
||||
SmtpSendPayload,
|
||||
} from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
const adapter: SmtpClientLike = {
|
||||
sendMail: async (payload: SmtpSendPayload): Promise<SmtpSendInfo> => {
|
||||
// Forward `payload` to the underlying library
|
||||
return { messageId: "abc" };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SmtpClientLike,
|
||||
SmtpSendInfo,
|
||||
SmtpSendPayload,
|
||||
} from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
class TracingAdapter implements SmtpClientLike {
|
||||
constructor(private readonly inner: SmtpClientLike) {}
|
||||
async sendMail(payload: SmtpSendPayload): Promise<SmtpSendInfo> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const info = await this.inner.sendMail(payload);
|
||||
tracer.recordSend(info.messageId, Date.now() - started);
|
||||
return info;
|
||||
} catch (err) {
|
||||
tracer.recordError(err, Date.now() - started);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
verify = () => this.inner.verify?.() ?? Promise.resolve(true);
|
||||
close = () => this.inner.close?.();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SmtpClientLike`
|
||||
- `interface SmtpSendPayload`
|
||||
- `interface SmtpSendInfo`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The contract uses `readonly` properties on the payload so adapters can
|
||||
safely store references without worrying about mutation by the
|
||||
transport.
|
||||
- `attachments` is typed without a `NodeJS.ReadableStream` member so the
|
||||
package compiles without `@types/node` runtime types; if a caller needs
|
||||
to stream from disk, they can buffer first or supply a `Uint8Array`.
|
||||
- The interface intentionally omits connection pool / max-messages /
|
||||
max-connections — those concerns belong to the adapter, not the
|
||||
transport contract. Surfacing them here would couple the emailer to one
|
||||
specific SMTP library's tuning knobs.
|
||||
@@ -0,0 +1,86 @@
|
||||
# `smtp-transport.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SmtpTransport` is a structural SMTP transport: it accepts a caller-supplied
|
||||
`SmtpClientLike` adapter (the contract is small — `sendMail`, optional
|
||||
`verify`, optional `close`) and forwards every send call through it. The
|
||||
emailer package itself never imports nodemailer, lettre, or any other SMTP
|
||||
library; consumers wire the library of their choice in their own app
|
||||
shell and pass a thin adapter. This keeps the emailer package universal
|
||||
(zero Node-only runtime deps) while preserving the upstream SMTP feature
|
||||
parity.
|
||||
|
||||
## Features
|
||||
|
||||
- `sendMail` payload covers from / to / cc / bcc / replyTo / subject / html
|
||||
/ text / attachments / headers / priority / messageId — the same surface
|
||||
the upstream nodemailer transport accepted.
|
||||
- `verify` delegates to the adapter's `verify` if present; absent, returns
|
||||
the `initialized` flag.
|
||||
- `close` delegates to the adapter's `close` if present.
|
||||
- Zod validation on initialise via `SmtpTransportOptionsSchema`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSmtpTransport } from "@suaveplan/emailer/transports/smtp";
|
||||
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
|
||||
|
||||
const adapter = new FakeSmtpClient();
|
||||
adapter.push({ info: { messageId: "<abc@host>" } });
|
||||
|
||||
const transport = createSmtpTransport();
|
||||
await transport.initialize({ client: adapter });
|
||||
const result = await transport.send({
|
||||
to: "user@example.com",
|
||||
subject: "hi",
|
||||
text: "hello",
|
||||
});
|
||||
// result.messageId === "<abc@host>"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Caller adapts nodemailer in their own app shell:
|
||||
import nodemailer from "nodemailer";
|
||||
import { createSmtpTransport } from "@suaveplan/emailer/transports/smtp";
|
||||
import type { SmtpClientLike } from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
const nm = nodemailer.createTransport({ host: "smtp.fastmail.com", port: 465, secure: true });
|
||||
const adapter: SmtpClientLike = {
|
||||
sendMail: async (payload) => {
|
||||
const info = await nm.sendMail(payload as unknown as Parameters<typeof nm.sendMail>[0]);
|
||||
return { messageId: info.messageId, accepted: info.accepted, rejected: info.rejected };
|
||||
},
|
||||
verify: () => nm.verify().then(() => true).catch(() => false),
|
||||
close: () => nm.close(),
|
||||
};
|
||||
const transport = createSmtpTransport();
|
||||
await transport.initialize({ client: adapter, host: "smtp.fastmail.com", port: 465, secure: true });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SmtpTransport extends BaseEmailTransport<SmtpTransportOptions>`
|
||||
- `createSmtpTransport(): SmtpTransport`
|
||||
- `interface SmtpTransportOptions { client: SmtpClientLike; host?; port?; secure?; user?; }`
|
||||
- `interface SmtpClientLike` (re-exported from `./smtp-client-like.types`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The transport never bundles nodemailer — the dependency stays in the
|
||||
caller's app shell. This keeps the emailer package universal and
|
||||
publishable to npm without bloating the install footprint of consumers
|
||||
who use HTTP-based transports.
|
||||
- `host` / `port` / `secure` / `user` are informational only — they are
|
||||
recorded for OTel attribute purposes but the adapter owns the actual
|
||||
connection.
|
||||
- `send` catches every thrown error from the adapter and converts it into
|
||||
a failed `EmailSendResult`. This matches the public contract of
|
||||
`EmailTransport.send` (never throw on send failure).
|
||||
@@ -0,0 +1,97 @@
|
||||
# `emailer.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Single source of truth for every public type surface in `@suaveplan/emailer`.
|
||||
The file pins the shape of email messages, transports, hooks, options, stats,
|
||||
and address bags so every concrete transport and every downstream consumer
|
||||
programs against one contract — never a near-identical local alias. Keeping
|
||||
the types in one file also means the spec-conformance test can assert literal
|
||||
type-unions without crawling every implementation module.
|
||||
|
||||
## Features
|
||||
|
||||
- `EmailAddress`, `EmailAttachment`, `EmailMessage`, `EmailSendResult` —
|
||||
wire-level shapes shared by every transport.
|
||||
- `EmailTransport<TConfig>` — pluggable contract with `initialize`, `send`,
|
||||
optional `verify`, and optional `close`. Concrete transports (Dummy / SMTP
|
||||
/ Gmail REST) implement this directly.
|
||||
- `EmailerOptions<TTransport>` — top-level service config; the transport
|
||||
parameter is generic so `EmailService<TTransport>` carries the transport's
|
||||
capability through its public methods.
|
||||
- `EmailerHooks` — nine lifecycle hook arrays with typed context unions.
|
||||
- `EmailerStats` — running counters (`sent`, `failed`, `queued`, `retries`).
|
||||
- `RetryPolicy`, `QueueLike`, `EventBusLike` — structural shims so callers
|
||||
inject their own implementations.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { EmailMessage, EmailerOptions } from "@suaveplan/emailer/types/emailer";
|
||||
|
||||
const message: EmailMessage = {
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
html: "<p>hi</p>",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
EmailTransport,
|
||||
EmailerHooks,
|
||||
EmailerOptions,
|
||||
} from "@suaveplan/emailer/types/emailer";
|
||||
|
||||
interface MyConfig {
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
class MyTransport implements EmailTransport<MyConfig> {
|
||||
public readonly name = "my-transport";
|
||||
public async initialize(_: MyConfig) {
|
||||
/* ... */
|
||||
}
|
||||
public async send() {
|
||||
return { success: true, messageId: "x", timestamp: new Date() };
|
||||
}
|
||||
}
|
||||
|
||||
const hooks: EmailerHooks = {
|
||||
beforeSend: [
|
||||
async (ctx) => {
|
||||
console.info(`about to send ${ctx.message.subject}`);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: EmailerOptions<MyTransport> = {
|
||||
transport: new MyTransport(),
|
||||
transportOptions: { endpoint: "https://my.example.com" },
|
||||
hooks,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EmailAddress`, `EmailAttachment`, `EmailMessage`, `EmailSendResult`
|
||||
- `EmailTransport<TConfig>`
|
||||
- `EmailerOptions<TTransport>`
|
||||
- `EmailerHooks`, `HookFunction`, `EmailHookContext`, `EmailResultHookContext`,
|
||||
`EmailErrorHookContext`, `MutableEmailerHooks`
|
||||
- `EmailerStats`
|
||||
- `RetryPolicy`, `QueueLike`, `EventBusLike`, `AddressBag`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All shapes use `readonly` where the field is settable only at construction
|
||||
time. `EmailMessage` itself is mutable because the service may inject
|
||||
`defaultFrom` between validation and send.
|
||||
- `EmailerOptions<TTransport>` uses `infer` to derive the transport's config
|
||||
parameter so `transportOptions` is typed against the supplied transport,
|
||||
not just `unknown`.
|
||||
- The hook context types are nominal (`EmailHookContext` vs
|
||||
`EmailResultHookContext` vs `EmailErrorHookContext`) so an `onError` hook
|
||||
registered against the `afterSend` array fails at compile time.
|
||||
@@ -0,0 +1,79 @@
|
||||
# @suaveplan/suppression - Email suppression list management: in-memory store, bulk import/export (CSV/JSON), domain-level suppression, and OTel instrumentation
|
||||
|
||||
## Summary
|
||||
|
||||
Email suppression list management for Suaveplan. Stores and checks email addresses that should not receive outgoing mail — unsubscribes, hard/soft bounces, complaints, manual suppressions, and invalid addresses. Supports domain-level blocking, bulk CSV/JSON import and export, OTel instrumentation, and full serialisation round-trips.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [csv-exporter](./src/core/exporter/csv-exporter.md) — `exportToCsv` converts an array of `SuppressionEntry` objects into a properly-formatted CSV string suitable for downloa…
|
||||
- [json-exporter](./src/core/exporter/json-exporter.md) — `exportToJson` and `importFromJson` provide full-store persistence for `SuppressionStore` instances. They wrap the stor…
|
||||
- [importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [csv-importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [suppression-store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [suppression-error](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [email-utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [csv-importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_- [store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-error](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [csv-importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_- [store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-error](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [csv-importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_- [store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [suppression-error](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [csv-importer](./src/core/importer/csv-importer.md) — <!-- begin:auto-docs --> _(no submodules)_- [store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_ - [suppression-store](./src/core/store/suppression-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [suppression-error](./src/errors/suppression-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ - [email-utils](./src/utils/email-utils.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Packages
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [`src/core/store/suppression-store.ts`](src/core/store/suppression-store.md) | In-memory suppression store with CRUD, domain blocking, stats, and serialisation |
|
||||
| [`src/core/importer/csv-importer.ts`](src/core/importer/csv-importer.md) | Bulk import from CSV with header-driven column mapping |
|
||||
| [`src/core/exporter/csv-exporter.ts`](src/core/exporter/csv-exporter.md) | Export entries to RFC 4180-compliant CSV |
|
||||
| [`src/core/exporter/json-exporter.ts`](src/core/exporter/json-exporter.md) | Full-store JSON serialisation and deserialisation |
|
||||
| [`src/utils/email-utils.ts`](src/utils/email-utils.md) | Email normalisation, domain extraction, and validation helpers |
|
||||
| [`src/errors/suppression-error.ts`](src/errors/suppression-error.md) | Typed error class with stable code registry |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createSuppressionStore } from "@suaveplan/suppression/store";
|
||||
import { importFromCsv } from "@suaveplan/suppression/importer";
|
||||
import { exportToCsv, exportToJson } from "@suaveplan/suppression/exporter";
|
||||
|
||||
// Create a store
|
||||
const store = createSuppressionStore({ domainSuppressionEnabled: true });
|
||||
|
||||
// Add individual entries
|
||||
store.add("user@example.com", "unsubscribe", "newsletter-june");
|
||||
store.addDomain("spam.example", "bounce-hard");
|
||||
|
||||
// Check before sending
|
||||
if (!store.isSuppressed(recipient)) {
|
||||
await sendEmail(recipient);
|
||||
}
|
||||
|
||||
// Bulk import from ESP export
|
||||
const entries = importFromCsv(csvString);
|
||||
store.fromJSON({ entries, domains: [] });
|
||||
|
||||
// Persist
|
||||
const json = exportToJson(store);
|
||||
await db.set("suppression_snapshot", json);
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/suppression
|
||||
```
|
||||
|
||||
## Suppression Reasons
|
||||
|
||||
| Reason | Description |
|
||||
|---|---|
|
||||
| `unsubscribe` | User explicitly opted out |
|
||||
| `bounce-hard` | Permanent delivery failure |
|
||||
| `bounce-soft` | Temporary delivery failure |
|
||||
| `complaint` | Spam complaint recorded |
|
||||
| `manual` | Manually added by operator |
|
||||
| `invalid` | Structurally invalid address |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,77 @@
|
||||
# csv-exporter
|
||||
|
||||
## Purpose
|
||||
|
||||
`exportToCsv` converts an array of `SuppressionEntry` objects into a properly-formatted CSV string suitable for downloading, archiving, or importing into a third-party ESP (Email Service Provider) suppression list. It is the counterpart to `importFromCsv` and shares the same four-column layout: `email`, `reason`, `suppressedAt`, `source`.
|
||||
|
||||
The function is intentionally stateless — it operates on plain arrays and does not require a `SuppressionStore` instance, making it easy to use with filtered subsets of entries.
|
||||
|
||||
## Features
|
||||
|
||||
- Four-column output: `email`, `reason`, `suppressedAt`, `source`
|
||||
- Always emits a header row for round-trip compatibility with `importFromCsv`
|
||||
- RFC 4180-compliant field quoting: commas, double-quotes, and newlines inside values are escaped
|
||||
- Returns only the header row when the input array is empty
|
||||
- Throws `SuppressionError` (`EXPORT_FAILED`) for entries with missing email values
|
||||
- LF line endings throughout (compatible with all major ESP import tools)
|
||||
- No external dependencies
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { exportToCsv } from "@suaveplan/suppression/exporter";
|
||||
import type { SuppressionEntry } from "@suaveplan/suppression";
|
||||
|
||||
const entries: SuppressionEntry[] = [
|
||||
{ email: "alice@example.com", reason: "unsubscribe", suppressedAt: "2024-01-01T00:00:00Z" },
|
||||
{ email: "bob@spam.io", reason: "bounce-hard", suppressedAt: "2024-01-02T00:00:00Z", source: "campaign-1" },
|
||||
];
|
||||
|
||||
const csv = exportToCsv(entries);
|
||||
console.log(csv);
|
||||
// email,reason,suppressedAt,source
|
||||
// alice@example.com,unsubscribe,2024-01-01T00:00:00Z,
|
||||
// bob@spam.io,bounce-hard,2024-01-02T00:00:00Z,campaign-1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { exportToCsv } from "@suaveplan/suppression/exporter";
|
||||
import { importFromCsv } from "@suaveplan/suppression/importer";
|
||||
import { createSuppressionStore } from "@suaveplan/suppression/store";
|
||||
|
||||
const store = createSuppressionStore();
|
||||
store.add("user@example.com", "complaint", "promo-may");
|
||||
store.add("other@example.com", "manual");
|
||||
|
||||
// Export only complaint entries
|
||||
const complainers = store.getByReason("complaint");
|
||||
const csv = exportToCsv(complainers);
|
||||
|
||||
// Round-trip: re-import the exported CSV
|
||||
const reimported = importFromCsv(csv);
|
||||
console.log(reimported[0].email); // user@example.com
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `exportToCsv(entries: SuppressionEntry[]): string`
|
||||
|
||||
Serialises entries to a CSV string.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `entries: SuppressionEntry[]` — entries to serialise; may be empty
|
||||
|
||||
**Returns:** `string` — CSV with header row and LF line endings
|
||||
|
||||
**Throws:** `SuppressionError` with code `EXPORT_FAILED` when any entry contains a falsy `email` field.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Field escaping follows RFC 4180: a field is double-quoted if and only if it contains a comma, double-quote, or newline. Double-quotes inside a field are doubled (`"` → `""`). This approach keeps clean fields unquoted, reducing file size for typical suppression exports that contain only plain email addresses.
|
||||
|
||||
LF (`\n`) line endings are used (not CRLF) because all modern ESP import tools accept LF, and LF files are smaller and display correctly in Unix-based tooling. If CRLF is required by a downstream system, callers can replace `\n` with `\r\n` on the output string.
|
||||
|
||||
The `source` column defaults to an empty string when not present on an entry, so the column count is always consistent across rows.
|
||||
@@ -0,0 +1,93 @@
|
||||
# json-exporter
|
||||
|
||||
## Purpose
|
||||
|
||||
`exportToJson` and `importFromJson` provide full-store persistence for `SuppressionStore` instances. They wrap the store's own `toJSON` / `fromJSON` methods with validation and structured error handling, producing a well-known `SuppressionStoreSnapshot` JSON format that is safe to write to disk, a database JSONB column, or an S3 object.
|
||||
|
||||
Together with `exportToCsv` and `importFromCsv`, they form the complete serialisation surface of `@suaveplan/suppression`.
|
||||
|
||||
## Features
|
||||
|
||||
- `exportToJson(store)` — full serialisation including both email entries and domain blocks
|
||||
- `importFromJson(json, store)` — replaces store contents; validates top-level structure before delegating
|
||||
- Structured error wrapping: `EXPORT_FAILED` for serialisation errors, `IMPORT_FAILED` for parse and structural failures
|
||||
- Tolerates snapshots with missing `domains` array (defaults to empty)
|
||||
- No external dependencies
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { exportToJson, importFromJson } from "@suaveplan/suppression/exporter";
|
||||
import { createSuppressionStore } from "@suaveplan/suppression/store";
|
||||
|
||||
const store = createSuppressionStore({ domainSuppressionEnabled: true });
|
||||
store.add("user@example.com", "unsubscribe");
|
||||
store.addDomain("spam.example", "bounce-hard");
|
||||
|
||||
const json = exportToJson(store);
|
||||
|
||||
// Later, in a fresh process:
|
||||
const restored = createSuppressionStore({ domainSuppressionEnabled: true });
|
||||
importFromJson(json, restored);
|
||||
console.log(restored.isSuppressed("user@example.com")); // true
|
||||
console.log(restored.isSuppressed("anything@spam.example")); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { exportToJson, importFromJson } from "@suaveplan/suppression/exporter";
|
||||
import { createSuppressionStore } from "@suaveplan/suppression/store";
|
||||
import { SuppressionError, SUPPRESSION_CODES } from "@suaveplan/suppression/errors";
|
||||
|
||||
async function loadFromDatabase(db: { get(key: string): Promise<string | null> }) {
|
||||
const raw = await db.get("suppression_snapshot");
|
||||
const store = createSuppressionStore();
|
||||
if (raw) {
|
||||
try {
|
||||
importFromJson(raw, store);
|
||||
} catch (err) {
|
||||
if (err instanceof SuppressionError && err.code === SUPPRESSION_CODES.IMPORT_FAILED) {
|
||||
console.warn("Suppression snapshot corrupted, starting fresh", err.context);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
return store;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `exportToJson(store: SuppressionStore): string`
|
||||
|
||||
Serialises the store to a JSON string via `store.toJSON()`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `store` — any `SuppressionStore` instance
|
||||
|
||||
**Returns:** JSON string encoding `{ entries: SuppressionEntry[], domains: string[] }`
|
||||
|
||||
**Throws:** `SuppressionError` with code `EXPORT_FAILED` if `JSON.stringify` throws (e.g. circular reference injected by a subclass).
|
||||
|
||||
### `importFromJson(json: string, store: SuppressionStore): void`
|
||||
|
||||
Parses `json` and replaces the store's contents via `store.fromJSON(snapshot)`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `json` — JSON string from `exportToJson`
|
||||
- `store` — target store to populate
|
||||
|
||||
**Throws:** `SuppressionError` with code `IMPORT_FAILED` when:
|
||||
|
||||
- The string is not valid JSON
|
||||
- The parsed value is not an object with an `entries` array
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`importFromJson` coerces a missing `domains` array to `[]` so that snapshots serialised from older store versions (before domain suppression was added) can be loaded without error. All other structural validation is delegated to `store.fromJSON`, which skips invalid entries rather than throwing, keeping partial imports resilient.
|
||||
|
||||
Both functions wrap native `JSON.parse` / `JSON.stringify` in try/catch to ensure that any platform-level error surfaces as a typed `SuppressionError` rather than a raw `SyntaxError`, maintaining consistent error taxonomy across the package.
|
||||
@@ -0,0 +1,85 @@
|
||||
# csv-importer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`importFromCsv` is the bulk-import entry point for loading existing suppression lists into a `SuppressionStore`. It parses a UTF-8 CSV string (with a mandatory header row) into an array of `SuppressionEntry` objects that can then be fed to `store.fromJSON` or added individually via `store.add`.
|
||||
|
||||
The function implements a minimal but correct CSV parser — it handles double-quoted fields, escaped double-quotes inside fields (`""`), and CRLF/LF line endings without depending on an external CSV library, keeping the package's dependency surface small.
|
||||
|
||||
## Features
|
||||
|
||||
- Header-row-driven column mapping: `email`, `reason`, `suppressedAt`, `source`
|
||||
- Column names are case-insensitive (`SuppressedAt`, `suppressedat`, and `SUPPRESSEDAT` all work)
|
||||
- Missing columns default gracefully (`reason` falls back to `defaultReason`, timestamps default to now)
|
||||
- Double-quoted fields with embedded commas and `""` escapes supported
|
||||
- Rows with an empty `email` field are silently skipped
|
||||
- Throws `SuppressionError` (`IMPORT_FAILED`) for an empty CSV or unrecognised `reason` values
|
||||
- No external parsing dependencies
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { importFromCsv } from "@suaveplan/suppression/importer";
|
||||
import { createSuppressionStore } from "@suaveplan/suppression/store";
|
||||
|
||||
const csv = `email,reason,suppressedAt
|
||||
alice@example.com,unsubscribe,2024-01-01T00:00:00Z
|
||||
bob@spam.io,bounce-hard,2024-01-02T00:00:00Z`;
|
||||
|
||||
const entries = importFromCsv(csv);
|
||||
const store = createSuppressionStore();
|
||||
store.fromJSON({ entries, domains: [] });
|
||||
console.log(store.isSuppressed("alice@example.com")); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { importFromCsv } from "@suaveplan/suppression/importer";
|
||||
|
||||
// CSV without a reason column — defaultReason applied to every row
|
||||
const csv = `email,suppressedAt,source
|
||||
bulk1@example.com,2024-06-01T12:00:00Z,gdpr-cleanup
|
||||
bulk2@example.com,2024-06-01T12:00:00Z,gdpr-cleanup`;
|
||||
|
||||
const entries = importFromCsv(csv, "manual");
|
||||
console.log(entries[0].reason); // "manual"
|
||||
console.log(entries[0].source); // "gdpr-cleanup"
|
||||
|
||||
// CSV with quoted fields containing commas
|
||||
const withQuotes = `email,source
|
||||
"user+tag@example.com","campaign,2024"`;
|
||||
const q = importFromCsv(withQuotes);
|
||||
console.log(q[0].email); // user+tag@example.com
|
||||
console.log(q[0].source); // campaign,2024
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `importFromCsv(csv, defaultReason?): SuppressionEntry[]`
|
||||
|
||||
Parses a CSV string into suppression entries.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `csv: string` — full CSV text including header row
|
||||
- `defaultReason?: SuppressionReason` — reason applied to rows with no `reason` column or empty reason value; defaults to `"manual"`
|
||||
|
||||
**Returns:** `SuppressionEntry[]` — array of parsed entries; empty rows are excluded
|
||||
|
||||
**Throws:** `SuppressionError` with code `IMPORT_FAILED` when:
|
||||
|
||||
- The CSV is empty (no non-blank lines)
|
||||
- The header row contains no `email` column
|
||||
- A row contains a non-empty `reason` value that is not a valid `SuppressionReason`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The inline CSV parser uses a character-by-character state machine (`inQuote` flag) rather than a regex to handle `""` escapes correctly. This approach avoids edge cases caused by greedy regex quantifiers and is easy to trace under debugging.
|
||||
|
||||
CRLF line endings are normalised via `split(/\r?\n/)` before processing. Newlines embedded inside quoted fields are not supported — this is an intentional limitation to keep the implementation simple; production CSV exports from mail platforms do not embed newlines in suppression records.
|
||||
|
||||
The function returns plain objects rather than class instances to simplify serialisation and allow easy aggregation with entries produced by other means (e.g. `importFromJson`).
|
||||
@@ -0,0 +1,136 @@
|
||||
# suppression-store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`SuppressionStore` is the core data structure of `@suaveplan/suppression`. It maintains an in-memory Map of suppressed email addresses indexed by their normalised form, plus an optional Set of suppressed domains for domain-wide blocking. The store is designed to be the single source of truth for all suppression decisions, with a straightforward serialisation contract (`toJSON` / `fromJSON`) that enables persistence to any backing store (database, Redis, filesystem).
|
||||
|
||||
The class is intentionally free of I/O — it operates entirely in memory, which makes it fast, testable, and portable across server, edge, and serverless runtimes.
|
||||
|
||||
## Features
|
||||
|
||||
- `add(email, reason, source?)` — validate, normalise, and record a new suppression entry
|
||||
- `addDomain(domain, reason)` — add an entire domain to the block-list
|
||||
- `isSuppressed(email)` — O(1) lookup with optional domain-level check
|
||||
- `get(email)` — retrieve the full `SuppressionEntry` for a given address
|
||||
- `remove(email)` / `removeDomain(domain)` — remove individual entries
|
||||
- `getAll()` / `getByReason(reason)` — enumerate entries
|
||||
- `getStats()` — aggregate counts by reason plus unique domain count
|
||||
- `clear()` — wipe all entries and domain blocks
|
||||
- `toJSON()` / `fromJSON(snapshot)` — lossless round-trip serialisation
|
||||
- `createSuppressionStore(options?)` — factory for DI-friendly construction
|
||||
- OTel counters: `suppression.add.total{reason}`, `suppression.check.total{result}`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createSuppressionStore } from "@suaveplan/suppression/store";
|
||||
|
||||
const store = createSuppressionStore();
|
||||
store.add("user@example.com", "unsubscribe", "newsletter-june");
|
||||
|
||||
console.log(store.isSuppressed("user@example.com")); // true
|
||||
console.log(store.isSuppressed("other@example.com")); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createSuppressionStore } from "@suaveplan/suppression/store";
|
||||
|
||||
const store = createSuppressionStore({ domainSuppressionEnabled: true });
|
||||
|
||||
// Suppress a single address
|
||||
store.add("alice@spam.io", "complaint");
|
||||
|
||||
// Suppress entire domain
|
||||
store.addDomain("malicious.example", "bounce-hard");
|
||||
|
||||
console.log(store.isSuppressed("anyone@malicious.example")); // true (domain match)
|
||||
console.log(store.isSuppressed("alice@spam.io")); // true (entry match)
|
||||
|
||||
// Persistence round-trip
|
||||
const snapshot = store.toJSON();
|
||||
const store2 = createSuppressionStore({ domainSuppressionEnabled: true });
|
||||
store2.fromJSON(snapshot);
|
||||
console.log(store2.isSuppressed("anyone@malicious.example")); // true
|
||||
|
||||
// Statistics
|
||||
const stats = store.getStats();
|
||||
console.log(stats.total); // 1
|
||||
console.log(stats.byReason.complaint); // 1
|
||||
console.log(stats.domains); // 1 (spam.io)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SuppressionStore`
|
||||
|
||||
**Constructor:** `new SuppressionStore(options?: SuppressionStoreOptions)`
|
||||
|
||||
- `options.normalizeEmail` — default `true`; lowercase + trim before storage
|
||||
- `options.domainSuppressionEnabled` — default `false`; enables domain-level blocking
|
||||
|
||||
#### `add(email, reason, source?, metadata?): SuppressionEntry`
|
||||
|
||||
Validates, normalises, and records the address. Overwrites any existing entry for the same normalised address. Increments `suppression.add.total{reason}`.
|
||||
|
||||
**Throws:** `SuppressionError` (`INVALID_EMAIL`) for structurally invalid addresses.
|
||||
|
||||
#### `addDomain(domain, reason): void`
|
||||
|
||||
Adds the lower-cased, trimmed domain string to the domain block-list.
|
||||
|
||||
#### `isSuppressed(email): boolean`
|
||||
|
||||
Returns `true` if the email exists in the store, or (when `domainSuppressionEnabled` is `true`) the extracted domain is in the domain block-list. Increments `suppression.check.total{result}`.
|
||||
|
||||
#### `get(email): SuppressionEntry | undefined`
|
||||
|
||||
Returns the entry or `undefined`.
|
||||
|
||||
#### `remove(email): boolean`
|
||||
|
||||
Returns `true` if the entry was deleted.
|
||||
|
||||
#### `removeDomain(domain): boolean`
|
||||
|
||||
Returns `true` if the domain was in the block-list and was removed.
|
||||
|
||||
#### `getAll(): SuppressionEntry[]`
|
||||
|
||||
Returns all entries in insertion order.
|
||||
|
||||
#### `getByReason(reason): SuppressionEntry[]`
|
||||
|
||||
Filters entries by reason.
|
||||
|
||||
#### `getStats(): SuppressionStats`
|
||||
|
||||
Returns `{ total, byReason, domains }`.
|
||||
|
||||
#### `clear(): void`
|
||||
|
||||
Clears all entries and domain blocks.
|
||||
|
||||
#### `toJSON(): SuppressionStoreSnapshot`
|
||||
|
||||
Serialises to `{ entries: SuppressionEntry[], domains: string[] }`.
|
||||
|
||||
#### `fromJSON(data): void`
|
||||
|
||||
Replaces store contents with snapshot. Skips invalid entries (those failing `isValidEmail`).
|
||||
|
||||
### `createSuppressionStore(options?): SuppressionStore`
|
||||
|
||||
Factory function. Equivalent to `new SuppressionStore(options)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Email addresses are stored under their normalised key (when `normalizeEmail` is `true`) using a `Map<string, SuppressionEntry>`, which provides O(1) average-case lookup and deletion. Domain checks use a `Set<string>` for the same reason.
|
||||
|
||||
OTel instrumentation uses the global `metrics.getMeter` singleton so counter instances are shared with the application's configured provider; no provider setup is required in the package itself — instrumentation is a no-op when no provider is registered.
|
||||
|
||||
`fromJSON` intentionally replaces rather than merges state to avoid phantom entries after a reload. Callers that need incremental loading should call `add()` directly in a loop.
|
||||
@@ -0,0 +1,94 @@
|
||||
# suppression-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`SuppressionError` is the typed error class for the entire `@suaveplan/suppression` package. It extends the project-wide `SuaveplanError` base class with a narrow `SuppressionCode` type parameter, enabling callers to discriminate error origins via `instanceof` and switch exhaustively on the `code` property. All four codes cover the realistic failure surface: invalid input, import/export failures, and missing entries.
|
||||
|
||||
Centralising error definitions in the frozen `SUPPRESSION_CODES` registry means consumers can handle every failure mode without consulting source code, and the registry value is tree-shakeable — unused codes have zero runtime cost.
|
||||
|
||||
## Features
|
||||
|
||||
- Inherits the full `SuaveplanError` surface: `code`, `context`, `timestamp`, `traceId`, `cause`
|
||||
- Four stable error codes covering invalid email, import failure, export failure, and missing entry
|
||||
- `defineCodes` freezes the registry and infers the exact union type at build time
|
||||
- Compatible with `walkCauses`, `toSpanEvent`, and the `@suaveplan/error` wire format
|
||||
- `name` is always `"SuppressionError"` for reliable `instanceof`-free identification in serialised payloads
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SuppressionError, SUPPRESSION_CODES } from "@suaveplan/suppression/errors";
|
||||
|
||||
throw new SuppressionError(
|
||||
SUPPRESSION_CODES.INVALID_EMAIL,
|
||||
"suppression.invalid_email",
|
||||
{ context: { email: "not-valid" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SuppressionError, SUPPRESSION_CODES } from "@suaveplan/suppression/errors";
|
||||
import { walkCauses } from "@suaveplan/error";
|
||||
|
||||
function guardImport(raw: string): void {
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
} catch (cause) {
|
||||
throw new SuppressionError(
|
||||
SUPPRESSION_CODES.IMPORT_FAILED,
|
||||
"suppression.import_failed",
|
||||
{ cause: cause as Error, context: { bytes: String(raw.length) } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
guardImport("{bad json}");
|
||||
} catch (err) {
|
||||
if (err instanceof SuppressionError) {
|
||||
for (const e of walkCauses(err)) {
|
||||
console.error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SUPPRESSION_CODES`
|
||||
|
||||
`Record<"INVALID_EMAIL" | "IMPORT_FAILED" | "EXPORT_FAILED" | "NOT_FOUND", string>`
|
||||
|
||||
Frozen registry of all suppression error codes. Values are fully-qualified strings of the form `SUPPRESSION.<KEY>`.
|
||||
|
||||
### `SuppressionCode`
|
||||
|
||||
`type SuppressionCode = (typeof SUPPRESSION_CODES)[keyof typeof SUPPRESSION_CODES]`
|
||||
|
||||
Union of all valid suppression error code strings.
|
||||
|
||||
### `class SuppressionError extends SuaveplanError<SuppressionCode>`
|
||||
|
||||
**Constructor:** `new SuppressionError(code: SuppressionCode, messageKey: string, options?: SuaveplanErrorOptions)`
|
||||
|
||||
- `code` — one of the values in `SUPPRESSION_CODES`
|
||||
- `messageKey` — i18n key passed to the `resolveMessage` hook (if configured)
|
||||
- `options` — optional `{ cause, context, clock, traceId }`
|
||||
|
||||
**Inherited properties:** `code`, `context`, `timestamp`, `traceId`, `cause`, `name`, `stack`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The four-code registry was designed to cover all realistic suppression operations without over-engineering:
|
||||
|
||||
- `INVALID_EMAIL` guards email normalisation and validation paths
|
||||
- `IMPORT_FAILED` wraps any CSV or JSON parse error during bulk import
|
||||
- `EXPORT_FAILED` wraps serialisation errors during export operations
|
||||
- `NOT_FOUND` is used when a caller requests a specific entry that does not exist
|
||||
|
||||
The `defineCodes` helper ensures the object is frozen at module load time, preventing accidental mutation in long-lived processes. The explicit `this.name = "SuppressionError"` assignment makes the class identifiable even after cross-realm transport where `instanceof` may fail.
|
||||
@@ -0,0 +1,128 @@
|
||||
# suppression.types
|
||||
|
||||
## Purpose
|
||||
|
||||
`suppression.types` defines the complete public type surface for `@suaveplan/suppression`. All types are pure TypeScript interfaces and type aliases with zero runtime footprint — they exist solely to provide static guarantees to consumers and to ensure that the store, importers, exporters, and error classes speak the same data language.
|
||||
|
||||
Centralising types in a single file avoids the circular-import hazard that arises when implementation modules import types from each other, and makes the package's data contract immediately visible without reading implementation code.
|
||||
|
||||
## Features
|
||||
|
||||
- `SuppressionReason` — exhaustive union of six suppression classifications
|
||||
- `SuppressionEntry` — single suppression record with email, reason, ISO timestamp, optional source, and optional metadata
|
||||
- `SuppressionStats` — aggregate counts by reason plus unique domain count
|
||||
- `SuppressionStoreOptions` — constructor options controlling email normalisation and domain-suppression behaviour
|
||||
- `SuppressionStoreSnapshot` — wire format for `toJSON` / `fromJSON` persistence
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SuppressionReason,
|
||||
SuppressionEntry,
|
||||
} from "@suaveplan/suppression";
|
||||
|
||||
const reason: SuppressionReason = "bounce-hard";
|
||||
const entry: SuppressionEntry = {
|
||||
email: "user@example.com",
|
||||
reason,
|
||||
suppressedAt: new Date().toISOString(),
|
||||
source: "campaign-june",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SuppressionStoreOptions,
|
||||
SuppressionStoreSnapshot,
|
||||
SuppressionStats,
|
||||
} from "@suaveplan/suppression";
|
||||
|
||||
const options: SuppressionStoreOptions = {
|
||||
normalizeEmail: true,
|
||||
domainSuppressionEnabled: true,
|
||||
};
|
||||
|
||||
// Snapshot used for database persistence
|
||||
const snapshot: SuppressionStoreSnapshot = {
|
||||
entries: [
|
||||
{ email: "a@example.com", reason: "unsubscribe", suppressedAt: "2024-01-01T00:00:00Z" },
|
||||
],
|
||||
domains: ["spam.example"],
|
||||
};
|
||||
|
||||
// Stats returned by store.getStats()
|
||||
const stats: SuppressionStats = {
|
||||
total: 1,
|
||||
byReason: {
|
||||
unsubscribe: 1,
|
||||
"bounce-hard": 0,
|
||||
"bounce-soft": 0,
|
||||
complaint: 0,
|
||||
manual: 0,
|
||||
invalid: 0,
|
||||
},
|
||||
domains: 1,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SuppressionReason`
|
||||
|
||||
`type SuppressionReason = "unsubscribe" | "bounce-hard" | "bounce-soft" | "complaint" | "manual" | "invalid"`
|
||||
|
||||
Classifies why an address was added to the suppression list:
|
||||
|
||||
- `unsubscribe` — explicit opt-out request
|
||||
- `bounce-hard` — permanent delivery failure (mailbox does not exist)
|
||||
- `bounce-soft` — transient delivery failure (mailbox temporarily unavailable)
|
||||
- `complaint` — spam complaint via FBL
|
||||
- `manual` — operator-initiated suppression
|
||||
- `invalid` — structurally invalid address rejected at validation time
|
||||
|
||||
### `SuppressionEntry`
|
||||
|
||||
Interface representing a single suppression record:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `email` | `string` | yes | Normalised email address |
|
||||
| `reason` | `SuppressionReason` | yes | Classification |
|
||||
| `suppressedAt` | `string` | yes | ISO 8601 timestamp |
|
||||
| `source` | `string` | no | Campaign or list identifier |
|
||||
| `metadata` | `Record<string, string>` | no | Arbitrary key/value pairs |
|
||||
|
||||
### `SuppressionStats`
|
||||
|
||||
Aggregate store statistics:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `total` | `number` | Total entries in the store |
|
||||
| `byReason` | `Record<SuppressionReason, number>` | Per-reason counts |
|
||||
| `domains` | `number` | Unique email-level domain count |
|
||||
|
||||
### `SuppressionStoreOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `normalizeEmail` | `boolean` | `true` | Lowercase + trim before storage |
|
||||
| `domainSuppressionEnabled` | `boolean` | `false` | Enable domain-level blocking |
|
||||
|
||||
### `SuppressionStoreSnapshot`
|
||||
|
||||
Wire format for `toJSON` / `fromJSON`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `entries` | `SuppressionEntry[]` | All suppressed email records |
|
||||
| `domains` | `string[]` | Suppressed domain strings |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All types use TypeScript interfaces rather than classes so that serialisation (JSON round-trips) works naturally without any `instanceof` checks or prototype restoration. The `SuppressionStoreSnapshot` type mirrors the output of `SuppressionStore.toJSON()` exactly, making it the canonical interchange format between in-memory state and persistent storage.
|
||||
|
||||
`SuppressionReason` is a string union rather than an enum to ensure that serialised values are human-readable in JSON payloads and that the set can be extended in a non-breaking way in future minor versions.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user