Initial Commit
This commit is contained in:
20
packages/communication/emails/README.md
Normal file
20
packages/communication/emails/README.md
Normal file
@@ -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… |
|
||||
| [`@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… |
|
||||
| [`@suaveplan/emailer`](./emailer/README.md) | Universal email service with pluggable transports. Ships `EmailService`, three transports (Dummy / SMTP via injectable `SmtpClientLike` / Gmail REST via i… |
|
||||
| [`@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 -->
|
||||
1
packages/communication/emails/_intro.md
Normal file
1
packages/communication/emails/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Email delivery, DMARC parsing, templates, campaigns, and suppression lists.
|
||||
94
packages/communication/emails/campaigns/README.md
Normal file
94
packages/communication/emails/campaigns/README.md
Normal file
@@ -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.
|
||||
|
||||
109
packages/communication/emails/campaigns/package.json
Normal file
109
packages/communication/emails/campaigns/package.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"name": "@suaveplan/campaigns",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./engine": {
|
||||
"import": {
|
||||
"types": "./dist/engine/index.d.ts",
|
||||
"default": "./dist/engine/index.js"
|
||||
}
|
||||
},
|
||||
"./state-machine": {
|
||||
"import": {
|
||||
"types": "./dist/state-machine/index.d.ts",
|
||||
"default": "./dist/state-machine/index.js"
|
||||
}
|
||||
},
|
||||
"./batch-planner": {
|
||||
"import": {
|
||||
"types": "./dist/batch-planner/index.d.ts",
|
||||
"default": "./dist/batch-planner/index.js"
|
||||
}
|
||||
},
|
||||
"./workflow": {
|
||||
"import": {
|
||||
"types": "./dist/workflow/index.d.ts",
|
||||
"default": "./dist/workflow/index.js"
|
||||
}
|
||||
},
|
||||
"./store": {
|
||||
"import": {
|
||||
"types": "./dist/store/index.d.ts",
|
||||
"default": "./dist/store/index.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./telemetry": {
|
||||
"import": {
|
||||
"types": "./dist/telemetry/index.d.ts",
|
||||
"default": "./dist/telemetry/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,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,233 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import { BatchPlanner } from "./batch-planner.js";
|
||||
|
||||
function makeContactIds(count: number): string[] {
|
||||
return Array.from({ length: count }, (_, i) => `contact-${i}`);
|
||||
}
|
||||
|
||||
const FIXED_NOW = 1_700_000_000_000;
|
||||
function fixedClock(): number {
|
||||
return FIXED_NOW;
|
||||
}
|
||||
|
||||
describe("BatchPlanner", () => {
|
||||
describe("construction", () => {
|
||||
it("accepts default options", { timeout: 5000 }, () => {
|
||||
const planner = new BatchPlanner();
|
||||
expect(planner).toBeInstanceOf(BatchPlanner);
|
||||
});
|
||||
|
||||
it("accepts injected clock", { timeout: 5000 }, () => {
|
||||
const planner = new BatchPlanner({ clock: fixedClock });
|
||||
const batches = planner.planBatches(makeContactIds(200), {
|
||||
batchSize: 100,
|
||||
delayBetweenBatches: 1000,
|
||||
});
|
||||
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 1000);
|
||||
});
|
||||
|
||||
it("rejects unknown option keys", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new BatchPlanner({ bogus: 1 } as unknown as never);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("planBatches", () => {
|
||||
const planner = new BatchPlanner({ clock: fixedClock });
|
||||
|
||||
it("returns empty for no contacts", { timeout: 5000 }, () => {
|
||||
expect(planner.planBatches([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates single batch for small lists", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const batches = planner.planBatches(makeContactIds(50), {
|
||||
batchSize: 100,
|
||||
});
|
||||
expect(batches).toHaveLength(1);
|
||||
expect(batches[0].batchIndex).toBe(0);
|
||||
expect(batches[0].contactIds).toHaveLength(50);
|
||||
expect(batches[0].scheduledAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("splits into multiple batches", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(makeContactIds(250), {
|
||||
batchSize: 100,
|
||||
});
|
||||
expect(batches).toHaveLength(3);
|
||||
expect(batches[0].contactIds).toHaveLength(100);
|
||||
expect(batches[2].contactIds).toHaveLength(50);
|
||||
});
|
||||
|
||||
it("uses default batch size of 100", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(makeContactIds(150));
|
||||
expect(batches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("schedules with explicit delay", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(makeContactIds(300), {
|
||||
batchSize: 100,
|
||||
delayBetweenBatches: 1000,
|
||||
});
|
||||
expect(batches[0].scheduledAt).toBeUndefined();
|
||||
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 1000);
|
||||
expect(batches[2].scheduledAt?.getTime()).toBe(FIXED_NOW + 2000);
|
||||
});
|
||||
|
||||
it("respects messagesPerSecond throttle", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(
|
||||
makeContactIds(200),
|
||||
{ batchSize: 100 },
|
||||
{ messagesPerSecond: 50 }
|
||||
);
|
||||
expect(batches).toHaveLength(2);
|
||||
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 2000);
|
||||
});
|
||||
|
||||
it("respects messagesPerMinute throttle", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(
|
||||
makeContactIds(200),
|
||||
{ batchSize: 100 },
|
||||
{ messagesPerMinute: 100 }
|
||||
);
|
||||
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 60_000);
|
||||
});
|
||||
|
||||
it("respects messagesPerHour throttle", { timeout: 5000 }, () => {
|
||||
const batches = planner.planBatches(
|
||||
makeContactIds(200),
|
||||
{ batchSize: 100 },
|
||||
{ messagesPerHour: 1000 }
|
||||
);
|
||||
expect(batches[1].scheduledAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to baseDelay when throttle has only ramp fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const batches = planner.planBatches(
|
||||
makeContactIds(200),
|
||||
{ batchSize: 100, delayBetweenBatches: 500 },
|
||||
{ rampUp: true, rampUpDuration: 1000 }
|
||||
);
|
||||
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 500);
|
||||
});
|
||||
|
||||
it("rejects invalid batch config", { timeout: 5000 }, () => {
|
||||
try {
|
||||
planner.planBatches(makeContactIds(10), { batchSize: -1 });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_BATCH_CONFIG);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid throttle", { timeout: 5000 }, () => {
|
||||
try {
|
||||
planner.planBatches(
|
||||
makeContactIds(10),
|
||||
{ batchSize: 5 },
|
||||
{ messagesPerSecond: -1 }
|
||||
);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(
|
||||
CAMPAIGNS_CODES.INVALID_THROTTLE_CONFIG
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateTotalDuration", () => {
|
||||
const planner = new BatchPlanner({ clock: fixedClock });
|
||||
|
||||
it("returns 0 for no contacts", { timeout: 5000 }, () => {
|
||||
expect(planner.calculateTotalDuration(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for negative contact count", { timeout: 5000 }, () => {
|
||||
expect(planner.calculateTotalDuration(-5)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for single batch", { timeout: 5000 }, () => {
|
||||
expect(planner.calculateTotalDuration(50, { batchSize: 100 })).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it("computes from batches + delay", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
planner.calculateTotalDuration(300, {
|
||||
batchSize: 100,
|
||||
delayBetweenBatches: 1000,
|
||||
})
|
||||
).toBe(2000);
|
||||
});
|
||||
|
||||
it("computes with throttle", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
planner.calculateTotalDuration(
|
||||
200,
|
||||
{ batchSize: 100 },
|
||||
{ messagesPerSecond: 50 }
|
||||
)
|
||||
).toBe(2000);
|
||||
});
|
||||
|
||||
it("uses max of explicit + throttle-derived delay", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
planner.calculateTotalDuration(
|
||||
200,
|
||||
{ batchSize: 100, delayBetweenBatches: 5000 },
|
||||
{ messagesPerSecond: 50 }
|
||||
)
|
||||
).toBe(5000);
|
||||
});
|
||||
|
||||
it("rejects invalid batch config", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
planner.calculateTotalDuration(10, { batchSize: 0 })
|
||||
).toThrow(CampaignsError);
|
||||
});
|
||||
|
||||
it("rejects invalid throttle config", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
planner.calculateTotalDuration(10, {}, { messagesPerMinute: 0 })
|
||||
).toThrow(CampaignsError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default clock", () => {
|
||||
it("uses Date.now when no clock is injected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const planner = new BatchPlanner();
|
||||
const before = Date.now();
|
||||
const batches = planner.planBatches(makeContactIds(200), {
|
||||
batchSize: 100,
|
||||
delayBetweenBatches: 1000,
|
||||
});
|
||||
const after = Date.now();
|
||||
const t = batches[1].scheduledAt!.getTime();
|
||||
expect(t).toBeGreaterThanOrEqual(before + 1000);
|
||||
expect(t).toBeLessThanOrEqual(after + 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
recordBatchesPlanned,
|
||||
recordBatchPlanSize,
|
||||
recordSendDuration,
|
||||
} from "../telemetry/telemetry.js";
|
||||
import {
|
||||
type BatchConfig,
|
||||
DEFAULT_BATCH_CONFIG,
|
||||
type ThrottleConfig,
|
||||
} from "../types/campaign.types.js";
|
||||
|
||||
/** A single batch in a campaign send plan. */
|
||||
export interface Batch {
|
||||
readonly batchIndex: number;
|
||||
readonly contactIds: readonly string[];
|
||||
readonly scheduledAt?: Date;
|
||||
}
|
||||
|
||||
/** Construction options for {@link BatchPlanner}. */
|
||||
export interface BatchPlannerOptions {
|
||||
/** Injectable wall-clock source. Defaults to `Date.now`. */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const BatchPlannerOptionsSchema = z
|
||||
.object({
|
||||
clock: z.custom<Clock>().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const BatchConfigSchema = z
|
||||
.object({
|
||||
batchSize: z.number().int().positive().optional(),
|
||||
delayBetweenBatches: z.number().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ThrottleConfigSchema = z
|
||||
.object({
|
||||
messagesPerSecond: z.number().positive().optional(),
|
||||
messagesPerMinute: z.number().positive().optional(),
|
||||
messagesPerHour: z.number().positive().optional(),
|
||||
rampUp: z.boolean().optional(),
|
||||
rampUpDuration: z.number().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Splits a contact list into sized batches with throttle-aware send
|
||||
* timestamps.
|
||||
*
|
||||
* The planner is purely deterministic given a fixed clock. The clock is
|
||||
* injectable so callers (and tests) can pin the `t=0` of a plan to an
|
||||
* arbitrary point.
|
||||
*/
|
||||
export class BatchPlanner {
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(options: BatchPlannerOptions = {}) {
|
||||
const parsed = BatchPlannerOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_OPTIONS,
|
||||
"Invalid BatchPlanner options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.clock = parsed.data.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
/** Split `contactIds` into batches respecting `config` + `throttle`. */
|
||||
public planBatches(
|
||||
contactIds: readonly string[],
|
||||
config: Partial<BatchConfig> = {},
|
||||
throttle?: ThrottleConfig
|
||||
): Batch[] {
|
||||
this.validateConfig(config);
|
||||
this.validateThrottle(throttle);
|
||||
|
||||
if (contactIds.length === 0) {
|
||||
recordBatchPlanSize(0);
|
||||
recordBatchesPlanned(0);
|
||||
return [];
|
||||
}
|
||||
|
||||
const { batchSize, delayBetweenBatches } = {
|
||||
...DEFAULT_BATCH_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
const effectiveDelay = this.resolveDelay(
|
||||
delayBetweenBatches,
|
||||
batchSize,
|
||||
throttle
|
||||
);
|
||||
const batches: Batch[] = [];
|
||||
const now = this.clock();
|
||||
|
||||
for (let i = 0; i < contactIds.length; i += batchSize) {
|
||||
const batchIndex = batches.length;
|
||||
const slice = contactIds.slice(i, i + batchSize);
|
||||
const batch: Batch =
|
||||
effectiveDelay > 0 && batchIndex > 0
|
||||
? {
|
||||
batchIndex,
|
||||
contactIds: slice,
|
||||
scheduledAt: new Date(
|
||||
now + batchIndex * effectiveDelay
|
||||
),
|
||||
}
|
||||
: { batchIndex, contactIds: slice };
|
||||
batches.push(batch);
|
||||
}
|
||||
|
||||
recordBatchPlanSize(contactIds.length);
|
||||
recordBatchesPlanned(batches.length);
|
||||
if (batches.length > 1) {
|
||||
recordSendDuration((batches.length - 1) * effectiveDelay);
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
/** Estimate total send-window duration in milliseconds. */
|
||||
public calculateTotalDuration(
|
||||
totalContacts: number,
|
||||
config: Partial<BatchConfig> = {},
|
||||
throttle?: ThrottleConfig
|
||||
): number {
|
||||
this.validateConfig(config);
|
||||
this.validateThrottle(throttle);
|
||||
|
||||
if (totalContacts <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { batchSize, delayBetweenBatches } = {
|
||||
...DEFAULT_BATCH_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
const effectiveDelay = this.resolveDelay(
|
||||
delayBetweenBatches,
|
||||
batchSize,
|
||||
throttle
|
||||
);
|
||||
const totalBatches = Math.ceil(totalContacts / batchSize);
|
||||
const duration =
|
||||
totalBatches > 1 ? (totalBatches - 1) * effectiveDelay : 0;
|
||||
recordSendDuration(duration);
|
||||
return duration;
|
||||
}
|
||||
|
||||
private validateConfig(config: Partial<BatchConfig>): void {
|
||||
const parsed = BatchConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_BATCH_CONFIG,
|
||||
"Invalid batch config",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private validateThrottle(throttle: ThrottleConfig | undefined): void {
|
||||
if (!throttle) return;
|
||||
const parsed = ThrottleConfigSchema.safeParse(throttle);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_THROTTLE_CONFIG,
|
||||
"Invalid throttle config",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDelay(
|
||||
baseDelay: number,
|
||||
batchSize: number,
|
||||
throttle?: ThrottleConfig
|
||||
): number {
|
||||
if (!throttle) {
|
||||
return baseDelay;
|
||||
}
|
||||
if (throttle.messagesPerSecond) {
|
||||
const secondsPerBatch = batchSize / throttle.messagesPerSecond;
|
||||
return Math.max(baseDelay, secondsPerBatch * 1000);
|
||||
}
|
||||
if (throttle.messagesPerMinute) {
|
||||
const minutesPerBatch = batchSize / throttle.messagesPerMinute;
|
||||
return Math.max(baseDelay, minutesPerBatch * 60_000);
|
||||
}
|
||||
if (throttle.messagesPerHour) {
|
||||
const hoursPerBatch = batchSize / throttle.messagesPerHour;
|
||||
return Math.max(baseDelay, hoursPerBatch * 3_600_000);
|
||||
}
|
||||
return baseDelay;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { Batch, BatchPlannerOptions } from "./batch-planner.js";
|
||||
export { BatchPlanner } from "./batch-planner.js";
|
||||
60
packages/communication/emails/campaigns/src/engine/engine.md
Normal file
60
packages/communication/emails/campaigns/src/engine/engine.md
Normal file
@@ -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,282 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import { InMemoryCampaignStore } from "../store/store.js";
|
||||
import type { CreateCampaignInput } from "../types/campaign.types.js";
|
||||
import { CampaignEngine } from "./engine.js";
|
||||
|
||||
function makeInput(
|
||||
overrides: Partial<CreateCampaignInput> = {}
|
||||
): CreateCampaignInput {
|
||||
return {
|
||||
tenantId: "tenant-1",
|
||||
name: "Test Campaign",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["group-1"] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CampaignEngine", () => {
|
||||
let engine: CampaignEngine;
|
||||
let counter = 0;
|
||||
let now = 1_700_000_000_000;
|
||||
const logs: { level: string; message: string }[] = [];
|
||||
const logger = {
|
||||
debug: (m: string) => logs.push({ level: "debug", message: m }),
|
||||
info: (m: string) => logs.push({ level: "info", message: m }),
|
||||
warn: (m: string) => logs.push({ level: "warn", message: m }),
|
||||
error: (m: string) => logs.push({ level: "error", message: m }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
counter = 0;
|
||||
now = 1_700_000_000_000;
|
||||
logs.length = 0;
|
||||
engine = new CampaignEngine(new InMemoryCampaignStore(), {
|
||||
clock: () => now,
|
||||
idGenerator: () => `cmp-${++counter}`,
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("rejects invalid options", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new CampaignEngine(new InMemoryCampaignStore(), {
|
||||
bogus: 1,
|
||||
} as unknown as never);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("works with default options (no clock/id/logger)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new CampaignEngine(new InMemoryCampaignStore());
|
||||
const c = await e.create(makeInput());
|
||||
expect(c.id).toMatch(/^cmp_/);
|
||||
expect(c.status).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("creates in draft status with empty stats", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
expect(c.id).toBe("cmp-1");
|
||||
expect(c.status).toBe("draft");
|
||||
expect(c.stats.sent).toBe(0);
|
||||
expect(c.metadata).toEqual({});
|
||||
expect(
|
||||
logs.find((l) => l.message === "campaign.created")
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves metadata", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(
|
||||
makeInput({ metadata: { source: "api" } })
|
||||
);
|
||||
expect(c.metadata).toEqual({ source: "api" });
|
||||
});
|
||||
|
||||
it("preserves all optional fields when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = await engine.create(
|
||||
makeInput({
|
||||
templateId: "tmpl-1",
|
||||
templateData: { name: "Paul" },
|
||||
schedule: { type: "immediate" },
|
||||
abTestConfig: {
|
||||
variants: [
|
||||
{
|
||||
id: "a",
|
||||
name: "A",
|
||||
templateId: "t-a",
|
||||
weight: 50,
|
||||
},
|
||||
],
|
||||
splitType: "even",
|
||||
},
|
||||
throttle: { messagesPerSecond: 10 },
|
||||
})
|
||||
);
|
||||
expect(c.templateId).toBe("tmpl-1");
|
||||
expect(c.templateData).toEqual({ name: "Paul" });
|
||||
expect(c.schedule).toEqual({ type: "immediate" });
|
||||
expect(c.abTestConfig?.splitType).toBe("even");
|
||||
expect(c.throttle?.messagesPerSecond).toBe(10);
|
||||
});
|
||||
|
||||
it("rejects invalid input (empty tenantId)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
try {
|
||||
await engine.create({ ...makeInput(), tenantId: "" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_INPUT);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule", () => {
|
||||
it("schedules a draft campaign", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
const s = await engine.schedule(c.id, {
|
||||
type: "scheduled",
|
||||
sendAt: new Date("2026-04-01"),
|
||||
});
|
||||
expect(s.status).toBe("scheduled");
|
||||
expect(s.schedule?.type).toBe("scheduled");
|
||||
});
|
||||
|
||||
it("throws when scheduling a sending campaign", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
await expect(
|
||||
engine.schedule(c.id, { type: "immediate" })
|
||||
).rejects.toThrow(/Invalid transition/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("send", () => {
|
||||
it("draft -> sending", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
const sent = await engine.send(c.id);
|
||||
expect(sent.status).toBe("sending");
|
||||
});
|
||||
|
||||
it("throws for non-existent campaign", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
try {
|
||||
await engine.send("nonexistent");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("pause/resume", () => {
|
||||
it("pause + resume", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
const paused = await engine.pause(c.id);
|
||||
expect(paused.status).toBe("paused");
|
||||
const resumed = await engine.resume(c.id);
|
||||
expect(resumed.status).toBe("sending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel", () => {
|
||||
it("cancel a scheduled campaign", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.schedule(c.id, { type: "immediate" });
|
||||
expect((await engine.cancel(c.id)).status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("cancel a paused campaign", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
await engine.pause(c.id);
|
||||
expect((await engine.cancel(c.id)).status).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("complete", () => {
|
||||
it("sending -> completed", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
expect((await engine.complete(c.id)).status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail", () => {
|
||||
it("sending -> failed with reason", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
await engine.fail(c.id, "Provider error");
|
||||
const fetched = await engine.getById(c.id);
|
||||
expect(fetched?.status).toBe("failed");
|
||||
expect(fetched?.metadata).toEqual({
|
||||
failureReason: "Provider error",
|
||||
});
|
||||
expect(
|
||||
logs.find((l) => l.message === "campaign.failed")
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("fail without reason", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
await engine.send(c.id);
|
||||
const failed = await engine.fail(c.id);
|
||||
expect(failed.status).toBe("failed");
|
||||
expect(failed.metadata).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicate", () => {
|
||||
it("draft copy with reset stats and (copy) suffix", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = await engine.create(makeInput({ name: "Original" }));
|
||||
await engine.send(c.id);
|
||||
const copy = await engine.duplicate(c.id);
|
||||
expect(copy.id).not.toBe(c.id);
|
||||
expect(copy.name).toBe("Original (copy)");
|
||||
expect(copy.status).toBe("draft");
|
||||
expect(copy.stats.sent).toBe(0);
|
||||
expect(copy.sentAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateStats", () => {
|
||||
it("merges stats", { timeout: 5000 }, async () => {
|
||||
const c = await engine.create(makeInput());
|
||||
const updated = await engine.updateStats(c.id, {
|
||||
sent: 50,
|
||||
delivered: 48,
|
||||
});
|
||||
expect(updated.stats.sent).toBe(50);
|
||||
expect(updated.stats.delivered).toBe(48);
|
||||
expect(updated.stats.opened).toBe(0);
|
||||
});
|
||||
|
||||
it("throws for missing", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
engine.updateStats("nope", { sent: 1 })
|
||||
).rejects.toBeInstanceOf(CampaignsError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list + getById", () => {
|
||||
it("filters by channel", { timeout: 5000 }, async () => {
|
||||
await engine.create(makeInput({ name: "A", channel: "email" }));
|
||||
await engine.create(makeInput({ name: "B", channel: "sms" }));
|
||||
const emails = await engine.list({ channel: "email" });
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0].name).toBe("A");
|
||||
});
|
||||
|
||||
it("getById null for missing", { timeout: 5000 }, async () => {
|
||||
expect(await engine.getById("nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
275
packages/communication/emails/campaigns/src/engine/engine.ts
Normal file
275
packages/communication/emails/campaigns/src/engine/engine.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import type { Clock, IdGenerator, LoggerLike } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import { CampaignStateMachine } from "../state-machine/state-machine.js";
|
||||
import type { CampaignStore } from "../store/store.js";
|
||||
import {
|
||||
recordCampaignCreated,
|
||||
recordFailure,
|
||||
recordSend,
|
||||
} from "../telemetry/telemetry.js";
|
||||
import type {
|
||||
Campaign,
|
||||
CampaignQuery,
|
||||
CampaignSchedule,
|
||||
CampaignStats,
|
||||
CreateCampaignInput,
|
||||
} from "../types/campaign.types.js";
|
||||
|
||||
/** Construction options for {@link CampaignEngine}. */
|
||||
export interface CampaignEngineOptions {
|
||||
/** Injectable wall-clock source. Defaults to `Date.now`. */
|
||||
readonly clock?: Clock;
|
||||
/** Injectable id generator. Defaults to a `cmp_<timestamp>_<rand>` factory. */
|
||||
readonly idGenerator?: IdGenerator;
|
||||
/** Optional logger for state transitions + failure traces. */
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
const EngineOptionsSchema = z
|
||||
.object({
|
||||
clock: z.custom<Clock>().optional(),
|
||||
idGenerator: z.custom<IdGenerator>().optional(),
|
||||
logger: z.custom<LoggerLike>().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const CreateInputSchema = z
|
||||
.object({
|
||||
tenantId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
type: z.enum(["broadcast", "triggered", "ab_test", "drip"]),
|
||||
channel: z.string().min(1),
|
||||
audienceConfig: z.object({
|
||||
groupIds: z.array(z.string()).readonly(),
|
||||
excludeGroupIds: z.array(z.string()).readonly().optional(),
|
||||
excludeContactIds: z.array(z.string()).readonly().optional(),
|
||||
segmentIds: z.array(z.string()).readonly().optional(),
|
||||
suppressionListIds: z.array(z.string()).readonly().optional(),
|
||||
}),
|
||||
templateId: z.string().optional(),
|
||||
templateData: z.record(z.string(), z.unknown()).optional(),
|
||||
schedule: z
|
||||
.object({
|
||||
type: z.enum(["immediate", "scheduled", "recurring"]),
|
||||
sendAt: z.date().optional(),
|
||||
cronExpression: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
abTestConfig: z.unknown().optional(),
|
||||
throttle: z.unknown().optional(),
|
||||
metadata: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function emptyStats(): CampaignStats {
|
||||
return {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultIdGenerator(): string {
|
||||
return `cmp_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level CRUD + lifecycle orchestrator for campaigns. Delegates state
|
||||
* validation to {@link CampaignStateMachine} and persistence to the
|
||||
* injected {@link CampaignStore}.
|
||||
*/
|
||||
export class CampaignEngine {
|
||||
private readonly store: CampaignStore;
|
||||
private readonly stateMachine: CampaignStateMachine;
|
||||
private readonly clock: Clock;
|
||||
private readonly idGenerator: IdGenerator;
|
||||
private readonly logger?: LoggerLike;
|
||||
|
||||
public constructor(
|
||||
store: CampaignStore,
|
||||
options: CampaignEngineOptions = {}
|
||||
) {
|
||||
const parsed = EngineOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_OPTIONS,
|
||||
"Invalid CampaignEngine options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.store = store;
|
||||
this.stateMachine = new CampaignStateMachine();
|
||||
this.clock = parsed.data.clock ?? (() => Date.now());
|
||||
this.idGenerator = parsed.data.idGenerator ?? defaultIdGenerator;
|
||||
if (parsed.data.logger !== undefined) {
|
||||
this.logger = parsed.data.logger;
|
||||
}
|
||||
}
|
||||
|
||||
public async create(input: CreateCampaignInput): Promise<Campaign> {
|
||||
const parsed = CreateInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_INPUT,
|
||||
"Invalid CreateCampaignInput",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const now = new Date(this.clock());
|
||||
const campaign: Campaign = {
|
||||
id: this.idGenerator(),
|
||||
tenantId: input.tenantId,
|
||||
name: input.name,
|
||||
status: "draft",
|
||||
type: input.type,
|
||||
channel: input.channel,
|
||||
audienceConfig: input.audienceConfig,
|
||||
metadata: input.metadata ?? {},
|
||||
stats: emptyStats(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (input.templateId !== undefined)
|
||||
campaign.templateId = input.templateId;
|
||||
if (input.templateData !== undefined)
|
||||
campaign.templateData = input.templateData;
|
||||
if (input.schedule !== undefined) campaign.schedule = input.schedule;
|
||||
if (input.abTestConfig !== undefined)
|
||||
campaign.abTestConfig = input.abTestConfig;
|
||||
if (input.throttle !== undefined) campaign.throttle = input.throttle;
|
||||
const created = await this.store.create(campaign);
|
||||
recordCampaignCreated(created.type, created.channel);
|
||||
this.logger?.info("campaign.created", {
|
||||
id: created.id,
|
||||
type: created.type,
|
||||
channel: created.channel,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
public async schedule(
|
||||
campaignId: string,
|
||||
schedule: CampaignSchedule
|
||||
): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
this.stateMachine.transition(campaign.status, "schedule");
|
||||
await this.store.update(campaignId, { schedule });
|
||||
return this.store.updateStatus(campaignId, "scheduled");
|
||||
}
|
||||
|
||||
public async send(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(campaign.status, "send");
|
||||
const updated = await this.store.updateStatus(campaignId, newStatus);
|
||||
recordSend(updated.channel);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async pause(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(
|
||||
campaign.status,
|
||||
"pause"
|
||||
);
|
||||
return this.store.updateStatus(campaignId, newStatus);
|
||||
}
|
||||
|
||||
public async resume(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(
|
||||
campaign.status,
|
||||
"resume"
|
||||
);
|
||||
return this.store.updateStatus(campaignId, newStatus);
|
||||
}
|
||||
|
||||
public async cancel(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(
|
||||
campaign.status,
|
||||
"cancel"
|
||||
);
|
||||
return this.store.updateStatus(campaignId, newStatus);
|
||||
}
|
||||
|
||||
public async complete(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(
|
||||
campaign.status,
|
||||
"complete"
|
||||
);
|
||||
return this.store.updateStatus(campaignId, newStatus);
|
||||
}
|
||||
|
||||
public async fail(campaignId: string, reason?: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const newStatus = this.stateMachine.transition(campaign.status, "fail");
|
||||
if (reason !== undefined) {
|
||||
await this.store.update(campaignId, {
|
||||
metadata: { ...campaign.metadata, failureReason: reason },
|
||||
});
|
||||
}
|
||||
const updated = await this.store.updateStatus(campaignId, newStatus);
|
||||
recordFailure(updated.channel);
|
||||
this.logger?.warn("campaign.failed", {
|
||||
id: campaignId,
|
||||
reason,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async duplicate(campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.requireCampaign(campaignId);
|
||||
const now = new Date(this.clock());
|
||||
const copy: Campaign = {
|
||||
...campaign,
|
||||
id: this.idGenerator(),
|
||||
name: `${campaign.name} (copy)`,
|
||||
status: "draft",
|
||||
stats: emptyStats(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
delete copy.sentAt;
|
||||
delete copy.completedAt;
|
||||
const created = await this.store.create(copy);
|
||||
recordCampaignCreated(created.type, created.channel);
|
||||
return created;
|
||||
}
|
||||
|
||||
public async updateStats(
|
||||
campaignId: string,
|
||||
stats: Partial<CampaignStats>
|
||||
): Promise<Campaign> {
|
||||
await this.requireCampaign(campaignId);
|
||||
return this.store.updateStats(campaignId, stats);
|
||||
}
|
||||
|
||||
public async getById(campaignId: string): Promise<Campaign | null> {
|
||||
return this.store.getById(campaignId);
|
||||
}
|
||||
|
||||
public async list(query: CampaignQuery): Promise<Campaign[]> {
|
||||
return this.store.list(query);
|
||||
}
|
||||
|
||||
private async requireCampaign(id: string): Promise<Campaign> {
|
||||
const campaign = await this.store.getById(id);
|
||||
if (!campaign) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
|
||||
`Campaign not found: ${id}`,
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
return campaign;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CampaignEngineOptions } from "./engine.js";
|
||||
export { CampaignEngine } from "./engine.js";
|
||||
@@ -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,67 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "./error-codes.js";
|
||||
|
||||
describe("CAMPAIGNS_CODES", () => {
|
||||
it("exposes every documented code", { timeout: 5000 }, () => {
|
||||
expect(CAMPAIGNS_CODES.INVALID_OPTIONS).toBe(
|
||||
"CAMPAIGNS.INVALID_OPTIONS"
|
||||
);
|
||||
expect(CAMPAIGNS_CODES.INVALID_INPUT).toBe("CAMPAIGNS.INVALID_INPUT");
|
||||
expect(CAMPAIGNS_CODES.INVALID_TRANSITION).toBe(
|
||||
"CAMPAIGNS.INVALID_TRANSITION"
|
||||
);
|
||||
expect(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND).toBe(
|
||||
"CAMPAIGNS.CAMPAIGN_NOT_FOUND"
|
||||
);
|
||||
expect(CAMPAIGNS_CODES.STORE_FAILURE).toBe("CAMPAIGNS.STORE_FAILURE");
|
||||
expect(CAMPAIGNS_CODES.INVALID_BATCH_CONFIG).toBe(
|
||||
"CAMPAIGNS.INVALID_BATCH_CONFIG"
|
||||
);
|
||||
expect(CAMPAIGNS_CODES.INVALID_THROTTLE_CONFIG).toBe(
|
||||
"CAMPAIGNS.INVALID_THROTTLE_CONFIG"
|
||||
);
|
||||
expect(CAMPAIGNS_CODES.WORKFLOW_STEP_FAILED).toBe(
|
||||
"CAMPAIGNS.WORKFLOW_STEP_FAILED"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CampaignsError", () => {
|
||||
it("constructs with code + message", { timeout: 5000 }, () => {
|
||||
const err = new CampaignsError(
|
||||
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
|
||||
"missing"
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND);
|
||||
expect(err.message).toBe("missing");
|
||||
expect(err.name).toBe("CampaignsError");
|
||||
});
|
||||
|
||||
it("threads cause + context", { timeout: 5000 }, () => {
|
||||
const cause = new Error("upstream");
|
||||
const err = new CampaignsError(CAMPAIGNS_CODES.STORE_FAILURE, "boom", {
|
||||
cause,
|
||||
context: { id: "c-1" },
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ id: "c-1" });
|
||||
});
|
||||
|
||||
it("supports switch discrimination on code", { timeout: 5000 }, () => {
|
||||
const err = new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_TRANSITION,
|
||||
"bad"
|
||||
);
|
||||
let hit = false;
|
||||
switch (err.code) {
|
||||
case CAMPAIGNS_CODES.INVALID_TRANSITION:
|
||||
hit = true;
|
||||
break;
|
||||
default:
|
||||
hit = false;
|
||||
}
|
||||
expect(hit).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/campaigns`.
|
||||
*
|
||||
* Every failure path in the package surfaces a `CampaignsError` whose
|
||||
* `code` is drawn from this registry. Consumers SHOULD discriminate on
|
||||
* `error.code` rather than `instanceof` so the namespace survives module
|
||||
* boundaries and runtime serialisation (see `@suaveplan/error`).
|
||||
*
|
||||
* The registry covers Zod boundary failures (`INVALID_OPTIONS`,
|
||||
* `INVALID_INPUT`), illegal state-machine transitions
|
||||
* (`INVALID_TRANSITION`), missing-record lookups (`CAMPAIGN_NOT_FOUND`),
|
||||
* store contract violations (`STORE_FAILURE`), batch planner argument
|
||||
* validation (`INVALID_BATCH_CONFIG`, `INVALID_THROTTLE_CONFIG`), and
|
||||
* workflow execution failures (`WORKFLOW_STEP_FAILED`).
|
||||
*/
|
||||
export const CAMPAIGNS_CODES = defineCodes("CAMPAIGNS", [
|
||||
"INVALID_OPTIONS",
|
||||
"INVALID_INPUT",
|
||||
"INVALID_TRANSITION",
|
||||
"CAMPAIGN_NOT_FOUND",
|
||||
"STORE_FAILURE",
|
||||
"INVALID_BATCH_CONFIG",
|
||||
"INVALID_THROTTLE_CONFIG",
|
||||
"WORKFLOW_STEP_FAILED",
|
||||
] as const);
|
||||
|
||||
/** Discriminating union literal for {@link CampaignsError.code}. */
|
||||
export type CampaignsCode =
|
||||
(typeof CAMPAIGNS_CODES)[keyof typeof CAMPAIGNS_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for every `@suaveplan/campaigns` failure path.
|
||||
*
|
||||
* Extends `SuaveplanError<CampaignsCode>` so the `code` field narrows
|
||||
* against the literal union and consumers get full type-checking on
|
||||
* `switch (err.code)` blocks. Carries a public constructor so that
|
||||
* `new CampaignsError(...)` works at the call site — `SuaveplanError`'s
|
||||
* own constructor is protected.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { CAMPAIGNS_CODES, CampaignsError } from "@suaveplan/campaigns";
|
||||
*
|
||||
* try {
|
||||
* await engine.send("missing");
|
||||
* } catch (err) {
|
||||
* if (err instanceof CampaignsError && err.code === CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND) {
|
||||
* console.error("no such campaign", err.context);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CampaignsError extends SuaveplanError<CampaignsCode> {
|
||||
public constructor(
|
||||
code: CampaignsCode,
|
||||
message: string,
|
||||
options?: { cause?: unknown; context?: Record<string, unknown> }
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "CampaignsError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CampaignsCode } from "./error-codes.js";
|
||||
export { CAMPAIGNS_CODES, CampaignsError } from "./error-codes.js";
|
||||
54
packages/communication/emails/campaigns/src/index.ts
Normal file
54
packages/communication/emails/campaigns/src/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type {
|
||||
Batch,
|
||||
BatchPlannerOptions,
|
||||
} from "./batch-planner/batch-planner.js";
|
||||
export { BatchPlanner } from "./batch-planner/batch-planner.js";
|
||||
export type { CampaignEngineOptions } from "./engine/engine.js";
|
||||
export { CampaignEngine } from "./engine/engine.js";
|
||||
export type { CampaignsCode } from "./error-codes/error-codes.js";
|
||||
export { CAMPAIGNS_CODES, CampaignsError } from "./error-codes/error-codes.js";
|
||||
export type { CampaignEvent } from "./state-machine/state-machine.js";
|
||||
export { CampaignStateMachine } from "./state-machine/state-machine.js";
|
||||
export type {
|
||||
CampaignStore,
|
||||
InMemoryCampaignStoreOptions,
|
||||
} from "./store/store.js";
|
||||
export { InMemoryCampaignStore } from "./store/store.js";
|
||||
export {
|
||||
batchesPlannedCounter,
|
||||
batchPlanSizeHistogram,
|
||||
campaignFailuresCounter,
|
||||
campaignSendsCounter,
|
||||
campaignsCreatedCounter,
|
||||
campaignTransitionsCounter,
|
||||
recordBatchesPlanned,
|
||||
recordBatchPlanSize,
|
||||
recordCampaignCreated,
|
||||
recordFailure,
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordTransition,
|
||||
sendDurationHistogram,
|
||||
} from "./telemetry/telemetry.js";
|
||||
export type {
|
||||
ABTestConfig,
|
||||
ABTestVariant,
|
||||
AudienceConfig,
|
||||
BatchConfig,
|
||||
Campaign,
|
||||
CampaignQuery,
|
||||
CampaignSchedule,
|
||||
CampaignStats,
|
||||
CampaignStatus,
|
||||
CampaignType,
|
||||
CreateCampaignInput,
|
||||
ThrottleConfig,
|
||||
UpdateCampaignInput,
|
||||
} from "./types/campaign.types.js";
|
||||
export { DEFAULT_BATCH_CONFIG } from "./types/campaign.types.js";
|
||||
export type {
|
||||
CampaignWorkflowOptions,
|
||||
WorkflowContext,
|
||||
WorkflowStep,
|
||||
} from "./workflow/workflow.js";
|
||||
export { CampaignWorkflow } from "./workflow/workflow.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CampaignEvent } from "./state-machine.js";
|
||||
export { CampaignStateMachine } from "./state-machine.js";
|
||||
@@ -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,133 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import { CampaignStateMachine } from "./state-machine.js";
|
||||
|
||||
describe("CampaignStateMachine", () => {
|
||||
const sm = new CampaignStateMachine();
|
||||
|
||||
describe("transition", () => {
|
||||
it("draft -> scheduled via schedule", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("draft", "schedule")).toBe("scheduled");
|
||||
});
|
||||
|
||||
it("draft -> sending via send", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("draft", "send")).toBe("sending");
|
||||
});
|
||||
|
||||
it("scheduled -> sending via start", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("scheduled", "start")).toBe("sending");
|
||||
});
|
||||
|
||||
it("scheduled -> cancelled via cancel", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("scheduled", "cancel")).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("sending -> paused via pause", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("sending", "pause")).toBe("paused");
|
||||
});
|
||||
|
||||
it("sending -> completed via complete", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("sending", "complete")).toBe("completed");
|
||||
});
|
||||
|
||||
it("sending -> failed via fail", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("sending", "fail")).toBe("failed");
|
||||
});
|
||||
|
||||
it("paused -> sending via resume", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("paused", "resume")).toBe("sending");
|
||||
});
|
||||
|
||||
it("paused -> cancelled via cancel", { timeout: 5000 }, () => {
|
||||
expect(sm.transition("paused", "cancel")).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("throws CampaignsError on invalid transition", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
sm.transition("draft", "pause");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_TRANSITION);
|
||||
expect(err.context).toEqual({
|
||||
from: "draft",
|
||||
event: "pause",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws from completed", { timeout: 5000 }, () => {
|
||||
expect(() => sm.transition("completed", "send")).toThrow(
|
||||
/Invalid transition/
|
||||
);
|
||||
});
|
||||
|
||||
it("throws from cancelled", { timeout: 5000 }, () => {
|
||||
expect(() => sm.transition("cancelled", "resume")).toThrow(
|
||||
/Invalid transition/
|
||||
);
|
||||
});
|
||||
|
||||
it("throws from failed", { timeout: 5000 }, () => {
|
||||
expect(() => sm.transition("failed", "send")).toThrow(
|
||||
/Invalid transition/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canTransition", () => {
|
||||
it("true for valid", { timeout: 5000 }, () => {
|
||||
expect(sm.canTransition("draft", "schedule")).toBe(true);
|
||||
expect(sm.canTransition("draft", "send")).toBe(true);
|
||||
expect(sm.canTransition("sending", "pause")).toBe(true);
|
||||
});
|
||||
|
||||
it("false for invalid", { timeout: 5000 }, () => {
|
||||
expect(sm.canTransition("draft", "pause")).toBe(false);
|
||||
expect(sm.canTransition("completed", "send")).toBe(false);
|
||||
expect(sm.canTransition("failed", "resume")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableEvents", () => {
|
||||
it("draft", { timeout: 5000 }, () => {
|
||||
const events = sm.getAvailableEvents("draft");
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining(["schedule", "send"])
|
||||
);
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("scheduled", { timeout: 5000 }, () => {
|
||||
const events = sm.getAvailableEvents("scheduled");
|
||||
expect(events).toEqual(expect.arrayContaining(["start", "cancel"]));
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sending", { timeout: 5000 }, () => {
|
||||
const events = sm.getAvailableEvents("sending");
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining(["pause", "complete", "fail"])
|
||||
);
|
||||
expect(events).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("paused", { timeout: 5000 }, () => {
|
||||
const events = sm.getAvailableEvents("paused");
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining(["resume", "cancel"])
|
||||
);
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("terminal states are empty", { timeout: 5000 }, () => {
|
||||
expect(sm.getAvailableEvents("completed")).toHaveLength(0);
|
||||
expect(sm.getAvailableEvents("cancelled")).toHaveLength(0);
|
||||
expect(sm.getAvailableEvents("failed")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import { recordTransition } from "../telemetry/telemetry.js";
|
||||
import type { CampaignStatus } from "../types/campaign.types.js";
|
||||
|
||||
/**
|
||||
* Discrete lifecycle events that can be applied to a campaign's status to
|
||||
* transition it to a new status.
|
||||
*/
|
||||
export type CampaignEvent =
|
||||
| "schedule"
|
||||
| "send"
|
||||
| "start"
|
||||
| "cancel"
|
||||
| "pause"
|
||||
| "complete"
|
||||
| "fail"
|
||||
| "resume";
|
||||
|
||||
interface Transition {
|
||||
readonly from: CampaignStatus;
|
||||
readonly event: CampaignEvent;
|
||||
readonly to: CampaignStatus;
|
||||
}
|
||||
|
||||
const TRANSITIONS: readonly Transition[] = [
|
||||
{ from: "draft", event: "schedule", to: "scheduled" },
|
||||
{ from: "draft", event: "send", to: "sending" },
|
||||
{ from: "scheduled", event: "start", to: "sending" },
|
||||
{ from: "scheduled", event: "cancel", to: "cancelled" },
|
||||
{ from: "sending", event: "pause", to: "paused" },
|
||||
{ from: "sending", event: "complete", to: "completed" },
|
||||
{ from: "sending", event: "fail", to: "failed" },
|
||||
{ from: "paused", event: "resume", to: "sending" },
|
||||
{ from: "paused", event: "cancel", to: "cancelled" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates and executes campaign-status transitions.
|
||||
*
|
||||
* Use {@link transition} to apply an event or throw on invalid input,
|
||||
* {@link canTransition} for predicate-only checks (UI affordances), and
|
||||
* {@link getAvailableEvents} to drive menus / action buttons.
|
||||
*/
|
||||
export class CampaignStateMachine {
|
||||
/**
|
||||
* Apply `event` to `currentStatus` and return the resulting status.
|
||||
*
|
||||
* @throws {CampaignsError} with code `CAMPAIGNS.INVALID_TRANSITION` if
|
||||
* no transition rule matches.
|
||||
*/
|
||||
public transition(
|
||||
currentStatus: CampaignStatus,
|
||||
event: CampaignEvent
|
||||
): CampaignStatus {
|
||||
const match = TRANSITIONS.find(
|
||||
(t) => t.from === currentStatus && t.event === event
|
||||
);
|
||||
if (!match) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.INVALID_TRANSITION,
|
||||
`Invalid transition: cannot apply '${event}' to status '${currentStatus}'`,
|
||||
{ context: { from: currentStatus, event } }
|
||||
);
|
||||
}
|
||||
recordTransition(match.from, match.to, match.event);
|
||||
return match.to;
|
||||
}
|
||||
|
||||
/** Returns `true` if applying `event` to `currentStatus` is legal. */
|
||||
public canTransition(
|
||||
currentStatus: CampaignStatus,
|
||||
event: CampaignEvent
|
||||
): boolean {
|
||||
return TRANSITIONS.some(
|
||||
(t) => t.from === currentStatus && t.event === event
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns every event legal in `currentStatus`. */
|
||||
public getAvailableEvents(currentStatus: CampaignStatus): CampaignEvent[] {
|
||||
return TRANSITIONS.filter((t) => t.from === currentStatus).map(
|
||||
(t) => t.event
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CampaignStore, InMemoryCampaignStoreOptions } from "./store.js";
|
||||
export { InMemoryCampaignStore } from "./store.js";
|
||||
56
packages/communication/emails/campaigns/src/store/store.md
Normal file
56
packages/communication/emails/campaigns/src/store/store.md
Normal file
@@ -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.
|
||||
159
packages/communication/emails/campaigns/src/store/store.test.ts
Normal file
159
packages/communication/emails/campaigns/src/store/store.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import type { Campaign } from "../types/campaign.types.js";
|
||||
import { InMemoryCampaignStore } from "./store.js";
|
||||
|
||||
function makeCampaign(overrides: Partial<Campaign> = {}): Campaign {
|
||||
return {
|
||||
id: "cmp-1",
|
||||
tenantId: "tenant-1",
|
||||
name: "Test",
|
||||
status: "draft",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["g"] },
|
||||
metadata: {},
|
||||
stats: {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
},
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("InMemoryCampaignStore", () => {
|
||||
it("create + getById round-trip", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
const created = await store.create(makeCampaign());
|
||||
expect(created.id).toBe("cmp-1");
|
||||
const fetched = await store.getById("cmp-1");
|
||||
expect(fetched).toEqual(created);
|
||||
});
|
||||
|
||||
it("getById returns null for missing", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
expect(await store.getById("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("update mutates fields + bumps updatedAt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 1000;
|
||||
const store = new InMemoryCampaignStore({ clock: () => now });
|
||||
await store.create(makeCampaign());
|
||||
now = 2000;
|
||||
const updated = await store.update("cmp-1", { name: "renamed" });
|
||||
expect(updated.name).toBe("renamed");
|
||||
expect(updated.updatedAt.getTime()).toBe(2000);
|
||||
});
|
||||
|
||||
it("update throws on missing", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
try {
|
||||
await store.update("nope", { name: "x" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("delete returns true/false", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await store.create(makeCampaign());
|
||||
expect(await store.delete("cmp-1")).toBe(true);
|
||||
expect(await store.delete("cmp-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("list filters by tenantId/status/type/channel + paginates", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await store.create(
|
||||
makeCampaign({ id: "a", tenantId: "t1", channel: "email" })
|
||||
);
|
||||
await store.create(
|
||||
makeCampaign({ id: "b", tenantId: "t1", channel: "sms" })
|
||||
);
|
||||
await store.create(
|
||||
makeCampaign({
|
||||
id: "c",
|
||||
tenantId: "t2",
|
||||
status: "sending",
|
||||
type: "ab_test",
|
||||
})
|
||||
);
|
||||
|
||||
expect(await store.list({ tenantId: "t1" })).toHaveLength(2);
|
||||
expect(await store.list({ status: "sending" })).toHaveLength(1);
|
||||
expect(await store.list({ type: "ab_test" })).toHaveLength(1);
|
||||
expect(await store.list({ channel: "sms" })).toHaveLength(1);
|
||||
expect(await store.list({ limit: 1, offset: 1 })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("updateStats merges partial", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await store.create(makeCampaign());
|
||||
const updated = await store.updateStats("cmp-1", {
|
||||
sent: 5,
|
||||
delivered: 4,
|
||||
});
|
||||
expect(updated.stats.sent).toBe(5);
|
||||
expect(updated.stats.delivered).toBe(4);
|
||||
expect(updated.stats.opened).toBe(0);
|
||||
});
|
||||
|
||||
it("updateStats throws on missing", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await expect(store.updateStats("nope", { sent: 1 })).rejects.toThrow(
|
||||
CampaignsError
|
||||
);
|
||||
});
|
||||
|
||||
it("updateStatus sets status + bumps updatedAt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await store.create(makeCampaign());
|
||||
const updated = await store.updateStatus("cmp-1", "sending");
|
||||
expect(updated.status).toBe("sending");
|
||||
});
|
||||
|
||||
it("updateStatus throws on missing", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await expect(
|
||||
store.updateStatus("nope", "sending")
|
||||
).rejects.toBeInstanceOf(CampaignsError);
|
||||
});
|
||||
|
||||
it("default clock works (Date.now)", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
await store.create(makeCampaign());
|
||||
const before = Date.now();
|
||||
const updated = await store.update("cmp-1", { name: "x" });
|
||||
const after = Date.now();
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(updated.updatedAt.getTime()).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("returned objects are defensively cloned", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryCampaignStore();
|
||||
const created = await store.create(makeCampaign());
|
||||
created.name = "tampered";
|
||||
const fetched = await store.getById("cmp-1");
|
||||
expect(fetched?.name).toBe("Test");
|
||||
});
|
||||
});
|
||||
133
packages/communication/emails/campaigns/src/store/store.ts
Normal file
133
packages/communication/emails/campaigns/src/store/store.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
Campaign,
|
||||
CampaignQuery,
|
||||
CampaignStats,
|
||||
CampaignStatus,
|
||||
UpdateCampaignInput,
|
||||
} from "../types/campaign.types.js";
|
||||
|
||||
/**
|
||||
* Persistence contract for campaigns. Implementations may be in-memory
|
||||
* (see {@link InMemoryCampaignStore}), SurrealDB-backed, or Cloudflare
|
||||
* Durable-Object-backed — the engine is store-agnostic.
|
||||
*/
|
||||
export interface CampaignStore {
|
||||
create(campaign: Campaign): Promise<Campaign>;
|
||||
getById(id: string): Promise<Campaign | null>;
|
||||
update(id: string, input: UpdateCampaignInput): Promise<Campaign>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
list(query: CampaignQuery): Promise<Campaign[]>;
|
||||
updateStats(id: string, stats: Partial<CampaignStats>): Promise<Campaign>;
|
||||
updateStatus(id: string, status: CampaignStatus): Promise<Campaign>;
|
||||
}
|
||||
|
||||
/** Options for {@link InMemoryCampaignStore}. */
|
||||
export interface InMemoryCampaignStoreOptions {
|
||||
/** Injectable wall-clock used to stamp `updatedAt`. Defaults to `Date.now`. */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference {@link CampaignStore} implementation backed by a `Map`. Suitable
|
||||
* for unit tests, prototype workloads, and the default in-process engine.
|
||||
*
|
||||
* Every mutation returns a defensive clone so callers cannot tamper with
|
||||
* internal records by reference.
|
||||
*/
|
||||
export class InMemoryCampaignStore implements CampaignStore {
|
||||
private readonly campaigns = new Map<string, Campaign>();
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(options: InMemoryCampaignStoreOptions = {}) {
|
||||
this.clock = options.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public async create(campaign: Campaign): Promise<Campaign> {
|
||||
this.campaigns.set(campaign.id, { ...campaign });
|
||||
return { ...campaign };
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<Campaign | null> {
|
||||
const campaign = this.campaigns.get(id);
|
||||
return campaign ? { ...campaign } : null;
|
||||
}
|
||||
|
||||
public async update(
|
||||
id: string,
|
||||
input: UpdateCampaignInput
|
||||
): Promise<Campaign> {
|
||||
const existing = this.requireExisting(id);
|
||||
const updated: Campaign = {
|
||||
...existing,
|
||||
...input,
|
||||
updatedAt: new Date(this.clock()),
|
||||
};
|
||||
this.campaigns.set(id, updated);
|
||||
return { ...updated };
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<boolean> {
|
||||
return this.campaigns.delete(id);
|
||||
}
|
||||
|
||||
public async list(query: CampaignQuery): Promise<Campaign[]> {
|
||||
let results = Array.from(this.campaigns.values());
|
||||
if (query.tenantId !== undefined) {
|
||||
results = results.filter((c) => c.tenantId === query.tenantId);
|
||||
}
|
||||
if (query.status !== undefined) {
|
||||
results = results.filter((c) => c.status === query.status);
|
||||
}
|
||||
if (query.type !== undefined) {
|
||||
results = results.filter((c) => c.type === query.type);
|
||||
}
|
||||
if (query.channel !== undefined) {
|
||||
results = results.filter((c) => c.channel === query.channel);
|
||||
}
|
||||
const offset = query.offset ?? 0;
|
||||
const limit = query.limit ?? 50;
|
||||
return results.slice(offset, offset + limit).map((c) => ({ ...c }));
|
||||
}
|
||||
|
||||
public async updateStats(
|
||||
id: string,
|
||||
stats: Partial<CampaignStats>
|
||||
): Promise<Campaign> {
|
||||
const existing = this.requireExisting(id);
|
||||
const updated: Campaign = {
|
||||
...existing,
|
||||
stats: { ...existing.stats, ...stats },
|
||||
updatedAt: new Date(this.clock()),
|
||||
};
|
||||
this.campaigns.set(id, updated);
|
||||
return { ...updated };
|
||||
}
|
||||
|
||||
public async updateStatus(
|
||||
id: string,
|
||||
status: CampaignStatus
|
||||
): Promise<Campaign> {
|
||||
const existing = this.requireExisting(id);
|
||||
const updated: Campaign = {
|
||||
...existing,
|
||||
status,
|
||||
updatedAt: new Date(this.clock()),
|
||||
};
|
||||
this.campaigns.set(id, updated);
|
||||
return { ...updated };
|
||||
}
|
||||
|
||||
private requireExisting(id: string): Campaign {
|
||||
const existing = this.campaigns.get(id);
|
||||
if (!existing) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
|
||||
`Campaign not found: ${id}`,
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
batchesPlannedCounter,
|
||||
batchPlanSizeHistogram,
|
||||
campaignFailuresCounter,
|
||||
campaignSendsCounter,
|
||||
campaignsCreatedCounter,
|
||||
campaignTransitionsCounter,
|
||||
recordBatchesPlanned,
|
||||
recordBatchPlanSize,
|
||||
recordCampaignCreated,
|
||||
recordFailure,
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordTransition,
|
||||
sendDurationHistogram,
|
||||
} from "./telemetry.js";
|
||||
@@ -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,43 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
batchesPlannedCounter,
|
||||
batchPlanSizeHistogram,
|
||||
campaignFailuresCounter,
|
||||
campaignSendsCounter,
|
||||
campaignsCreatedCounter,
|
||||
campaignTransitionsCounter,
|
||||
recordBatchesPlanned,
|
||||
recordBatchPlanSize,
|
||||
recordCampaignCreated,
|
||||
recordFailure,
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
recordTransition,
|
||||
sendDurationHistogram,
|
||||
} from "./telemetry.js";
|
||||
|
||||
describe("campaigns telemetry", () => {
|
||||
it("exposes counters + histograms", { timeout: 5000 }, () => {
|
||||
expect(campaignsCreatedCounter).toBeDefined();
|
||||
expect(campaignTransitionsCounter).toBeDefined();
|
||||
expect(campaignSendsCounter).toBeDefined();
|
||||
expect(batchesPlannedCounter).toBeDefined();
|
||||
expect(campaignFailuresCounter).toBeDefined();
|
||||
expect(batchPlanSizeHistogram).toBeDefined();
|
||||
expect(sendDurationHistogram).toBeDefined();
|
||||
});
|
||||
|
||||
it("every record helper is callable without a registered provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordCampaignCreated("broadcast", "email")).not.toThrow();
|
||||
expect(() =>
|
||||
recordTransition("draft", "sending", "send")
|
||||
).not.toThrow();
|
||||
expect(() => recordSend("email")).not.toThrow();
|
||||
expect(() => recordBatchesPlanned(3)).not.toThrow();
|
||||
expect(() => recordFailure("email")).not.toThrow();
|
||||
expect(() => recordBatchPlanSize(1000)).not.toThrow();
|
||||
expect(() => recordSendDuration(42)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/campaigns";
|
||||
|
||||
/**
|
||||
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
|
||||
* first access. When no provider is registered the OTel API returns a
|
||||
* no-op meter, so instruments are safe to import in production code that
|
||||
* does not opt into telemetry.
|
||||
*/
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
/** Counter for created campaigns. */
|
||||
export const campaignsCreatedCounter: Counter = meter.createCounter(
|
||||
"campaigns_created_total",
|
||||
{
|
||||
description:
|
||||
"Total campaigns created via CampaignEngine.create(), tagged by type/channel",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter for campaign lifecycle transitions. */
|
||||
export const campaignTransitionsCounter: Counter = meter.createCounter(
|
||||
"campaigns_transitions_total",
|
||||
{
|
||||
description:
|
||||
"Total successful campaign state-machine transitions, tagged by from/to/event",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter for campaign send dispatches. */
|
||||
export const campaignSendsCounter: Counter = meter.createCounter(
|
||||
"campaigns_sends_total",
|
||||
{
|
||||
description:
|
||||
"Total campaign sends initiated via CampaignEngine.send(), tagged by channel",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter for batches emitted by the BatchPlanner. */
|
||||
export const batchesPlannedCounter: Counter = meter.createCounter(
|
||||
"campaigns_batches_planned_total",
|
||||
{
|
||||
description: "Total batches produced by BatchPlanner.planBatches()",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter for campaign failures. */
|
||||
export const campaignFailuresCounter: Counter = meter.createCounter(
|
||||
"campaigns_failures_total",
|
||||
{
|
||||
description:
|
||||
"Total campaign failures registered via CampaignEngine.fail()",
|
||||
}
|
||||
);
|
||||
|
||||
/** Histogram of total recipients per batch plan. */
|
||||
export const batchPlanSizeHistogram: Histogram = meter.createHistogram(
|
||||
"campaigns_batch_plan_size",
|
||||
{
|
||||
description: "Total contact count fed into a single planBatches() call",
|
||||
unit: "{contact}",
|
||||
}
|
||||
);
|
||||
|
||||
/** Histogram of computed total send duration in milliseconds. */
|
||||
export const sendDurationHistogram: Histogram = meter.createHistogram(
|
||||
"campaigns_send_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Estimated total send-window duration in milliseconds, recorded on planBatches()/calculateTotalDuration()",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Record one created-campaign event. */
|
||||
export function recordCampaignCreated(type: string, channel: string): void {
|
||||
campaignsCreatedCounter.add(1, { type, channel });
|
||||
}
|
||||
|
||||
/** Record one successful state transition. */
|
||||
export function recordTransition(
|
||||
from: string,
|
||||
to: string,
|
||||
event: string
|
||||
): void {
|
||||
campaignTransitionsCounter.add(1, { from, to, event });
|
||||
}
|
||||
|
||||
/** Record one send-dispatch event. */
|
||||
export function recordSend(channel: string): void {
|
||||
campaignSendsCounter.add(1, { channel });
|
||||
}
|
||||
|
||||
/** Record one batch-plan emission, attaching the batch count. */
|
||||
export function recordBatchesPlanned(batchCount: number): void {
|
||||
batchesPlannedCounter.add(batchCount);
|
||||
}
|
||||
|
||||
/** Record one fail-dispatch event. */
|
||||
export function recordFailure(channel: string): void {
|
||||
campaignFailuresCounter.add(1, { channel });
|
||||
}
|
||||
|
||||
/** Record the total-recipient size of one plan. */
|
||||
export function recordBatchPlanSize(totalContacts: number): void {
|
||||
batchPlanSizeHistogram.record(totalContacts);
|
||||
}
|
||||
|
||||
/** Record an estimated send-window duration in milliseconds. */
|
||||
export function recordSendDuration(durationMs: number): void {
|
||||
sendDurationHistogram.record(durationMs);
|
||||
}
|
||||
@@ -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,150 @@
|
||||
/**
|
||||
* Lifecycle status for a campaign.
|
||||
*
|
||||
* - `draft` — created but not yet scheduled or sent.
|
||||
* - `scheduled` — awaiting a future send time.
|
||||
* - `sending` — actively dispatching batches.
|
||||
* - `paused` — temporarily halted; can resume.
|
||||
* - `completed` — terminal; all batches dispatched.
|
||||
* - `cancelled` — terminal; user-cancelled before completion.
|
||||
* - `failed` — terminal; aborted due to an error.
|
||||
*/
|
||||
export type CampaignStatus =
|
||||
| "draft"
|
||||
| "scheduled"
|
||||
| "sending"
|
||||
| "paused"
|
||||
| "completed"
|
||||
| "cancelled"
|
||||
| "failed";
|
||||
|
||||
/** High-level send pattern for a campaign. */
|
||||
export type CampaignType = "broadcast" | "triggered" | "ab_test" | "drip";
|
||||
|
||||
/** Audience targeting configuration. */
|
||||
export interface AudienceConfig {
|
||||
readonly groupIds: readonly string[];
|
||||
readonly excludeGroupIds?: readonly string[];
|
||||
readonly excludeContactIds?: readonly string[];
|
||||
readonly segmentIds?: readonly string[];
|
||||
readonly suppressionListIds?: readonly string[];
|
||||
}
|
||||
|
||||
/** When and how a campaign should be dispatched. */
|
||||
export interface CampaignSchedule {
|
||||
readonly type: "immediate" | "scheduled" | "recurring";
|
||||
readonly sendAt?: Date;
|
||||
readonly cronExpression?: string;
|
||||
readonly timezone?: string;
|
||||
}
|
||||
|
||||
/** Throttle controls for batch delivery. */
|
||||
export interface ThrottleConfig {
|
||||
readonly messagesPerSecond?: number;
|
||||
readonly messagesPerMinute?: number;
|
||||
readonly messagesPerHour?: number;
|
||||
readonly rampUp?: boolean;
|
||||
readonly rampUpDuration?: number;
|
||||
}
|
||||
|
||||
/** Single variant in an A/B test configuration. */
|
||||
export interface ABTestVariant {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly templateId: string;
|
||||
readonly templateData?: Record<string, unknown>;
|
||||
readonly weight?: number;
|
||||
}
|
||||
|
||||
/** Full A/B test configuration for a campaign. */
|
||||
export interface ABTestConfig {
|
||||
readonly variants: readonly ABTestVariant[];
|
||||
readonly splitType: "even" | "weighted";
|
||||
readonly testDuration?: number;
|
||||
readonly winnerCriteria?: "open_rate" | "click_rate" | "conversion";
|
||||
}
|
||||
|
||||
/** Aggregate delivery + engagement statistics for a campaign. */
|
||||
export interface CampaignStats {
|
||||
totalRecipients: number;
|
||||
sent: number;
|
||||
delivered: number;
|
||||
opened: number;
|
||||
clicked: number;
|
||||
bounced: number;
|
||||
unsubscribed: number;
|
||||
complained: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/** Full campaign object as stored. */
|
||||
export interface Campaign<TMeta = Record<string, unknown>> {
|
||||
readonly id: string;
|
||||
readonly tenantId: string;
|
||||
name: string;
|
||||
status: CampaignStatus;
|
||||
type: CampaignType;
|
||||
channel: string;
|
||||
audienceConfig: AudienceConfig;
|
||||
templateId?: string;
|
||||
templateData?: Record<string, unknown>;
|
||||
schedule?: CampaignSchedule;
|
||||
abTestConfig?: ABTestConfig;
|
||||
throttle?: ThrottleConfig;
|
||||
metadata: TMeta;
|
||||
stats: CampaignStats;
|
||||
readonly createdAt: Date;
|
||||
updatedAt: Date;
|
||||
sentAt?: Date;
|
||||
completedAt?: Date;
|
||||
}
|
||||
|
||||
/** Input for creating a new campaign. */
|
||||
export interface CreateCampaignInput<TMeta = Record<string, unknown>> {
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly type: CampaignType;
|
||||
readonly channel: string;
|
||||
readonly audienceConfig: AudienceConfig;
|
||||
readonly templateId?: string;
|
||||
readonly templateData?: Record<string, unknown>;
|
||||
readonly schedule?: CampaignSchedule;
|
||||
readonly abTestConfig?: ABTestConfig;
|
||||
readonly throttle?: ThrottleConfig;
|
||||
readonly metadata?: TMeta;
|
||||
}
|
||||
|
||||
/** Input for updating an existing campaign. */
|
||||
export interface UpdateCampaignInput<TMeta = Record<string, unknown>> {
|
||||
readonly name?: string;
|
||||
readonly channel?: string;
|
||||
readonly audienceConfig?: AudienceConfig;
|
||||
readonly templateId?: string;
|
||||
readonly templateData?: Record<string, unknown>;
|
||||
readonly schedule?: CampaignSchedule;
|
||||
readonly abTestConfig?: ABTestConfig;
|
||||
readonly throttle?: ThrottleConfig;
|
||||
readonly metadata?: TMeta;
|
||||
}
|
||||
|
||||
/** Filter parameters for listing campaigns. */
|
||||
export interface CampaignQuery {
|
||||
readonly tenantId?: string;
|
||||
readonly status?: CampaignStatus;
|
||||
readonly type?: CampaignType;
|
||||
readonly channel?: string;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
}
|
||||
|
||||
/** Batch sizing + delay configuration. */
|
||||
export interface BatchConfig {
|
||||
readonly batchSize: number;
|
||||
readonly delayBetweenBatches: number;
|
||||
}
|
||||
|
||||
/** Default batch configuration used when no overrides are supplied. */
|
||||
export const DEFAULT_BATCH_CONFIG: BatchConfig = {
|
||||
batchSize: 100,
|
||||
delayBetweenBatches: 0,
|
||||
};
|
||||
16
packages/communication/emails/campaigns/src/types/index.ts
Normal file
16
packages/communication/emails/campaigns/src/types/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type {
|
||||
ABTestConfig,
|
||||
ABTestVariant,
|
||||
AudienceConfig,
|
||||
BatchConfig,
|
||||
Campaign,
|
||||
CampaignQuery,
|
||||
CampaignSchedule,
|
||||
CampaignStats,
|
||||
CampaignStatus,
|
||||
CampaignType,
|
||||
CreateCampaignInput,
|
||||
ThrottleConfig,
|
||||
UpdateCampaignInput,
|
||||
} from "./campaign.types.js";
|
||||
export { DEFAULT_BATCH_CONFIG } from "./campaign.types.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
CampaignWorkflowOptions,
|
||||
WorkflowContext,
|
||||
WorkflowStep,
|
||||
} from "./workflow.js";
|
||||
export { CampaignWorkflow } from "./workflow.js";
|
||||
@@ -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,215 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import type { Campaign } from "../types/campaign.types.js";
|
||||
import {
|
||||
CampaignWorkflow,
|
||||
type WorkflowContext,
|
||||
type WorkflowStep,
|
||||
} from "./workflow.js";
|
||||
|
||||
function makeCampaign(): Campaign {
|
||||
return {
|
||||
id: "cmp-1",
|
||||
tenantId: "tenant-1",
|
||||
name: "Test Campaign",
|
||||
status: "sending",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["group-1"] },
|
||||
metadata: {},
|
||||
stats: {
|
||||
totalRecipients: 0,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
bounced: 0,
|
||||
unsubscribed: 0,
|
||||
complained: 0,
|
||||
failed: 0,
|
||||
},
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
}
|
||||
|
||||
describe("CampaignWorkflow", () => {
|
||||
const workflow = new CampaignWorkflow();
|
||||
|
||||
describe("buildSendPipeline", () => {
|
||||
it("returns 6 steps in canonical order", { timeout: 5000 }, () => {
|
||||
const steps = workflow.buildSendPipeline(makeCampaign());
|
||||
expect(steps.map((s) => s.id)).toEqual([
|
||||
"resolve-audience",
|
||||
"apply-suppression",
|
||||
"plan-batches",
|
||||
"send-batches",
|
||||
"update-stats",
|
||||
"complete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("every step has name + description + execute", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const step of workflow.buildSendPipeline(makeCampaign())) {
|
||||
expect(typeof step.execute).toBe("function");
|
||||
expect(step.name).toBeTruthy();
|
||||
expect(step.description).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("execute returns enriched context", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const result = await steps[0].execute({ campaign: c });
|
||||
expect(result.contactIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("apply-suppression preserves existing contactIds", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const result = await steps[1].execute({
|
||||
campaign: c,
|
||||
contactIds: ["a", "b"],
|
||||
});
|
||||
expect(result.contactIds).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("apply-suppression defaults to [] when no contactIds", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const result = await steps[1].execute({ campaign: c });
|
||||
expect(result.contactIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("plan-batches preserves existing batches", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const existing = [{ batchIndex: 0, contactIds: ["a"] }];
|
||||
const result = await steps[2].execute({
|
||||
campaign: c,
|
||||
batches: existing,
|
||||
});
|
||||
expect(result.batches).toEqual(existing);
|
||||
});
|
||||
|
||||
it("plan-batches defaults to []", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const r = await steps[2].execute({ campaign: c });
|
||||
expect(r.batches).toEqual([]);
|
||||
});
|
||||
|
||||
it("send-batches/update-stats/complete pass context through", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = makeCampaign();
|
||||
const steps = workflow.buildSendPipeline(c);
|
||||
const ctx = {
|
||||
campaign: c,
|
||||
contactIds: ["a"],
|
||||
results: { ok: 1 },
|
||||
};
|
||||
for (const step of steps.slice(3)) {
|
||||
const r = await step.execute(ctx);
|
||||
expect(r.results).toEqual({ ok: 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("run", () => {
|
||||
it("executes every step in order", { timeout: 5000 }, async () => {
|
||||
const order: string[] = [];
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
id: "a",
|
||||
name: "A",
|
||||
description: "first",
|
||||
execute: async (ctx) => {
|
||||
order.push("a");
|
||||
return { ...ctx, results: { ...ctx.results, a: 1 } };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
name: "B",
|
||||
description: "second",
|
||||
execute: async (ctx) => {
|
||||
order.push("b");
|
||||
return { ...ctx, results: { ...ctx.results, b: 2 } };
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = await workflow.run(steps, {
|
||||
campaign: makeCampaign(),
|
||||
});
|
||||
expect(order).toEqual(["a", "b"]);
|
||||
expect(result.results).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it("wraps step errors in WORKFLOW_STEP_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cause = new Error("boom");
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
id: "broken",
|
||||
name: "Broken",
|
||||
description: "fails",
|
||||
execute: async () => {
|
||||
throw cause;
|
||||
},
|
||||
},
|
||||
];
|
||||
try {
|
||||
await workflow.run(steps, { campaign: makeCampaign() });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CampaignsError);
|
||||
if (err instanceof CampaignsError) {
|
||||
expect(err.code).toBe(CAMPAIGNS_CODES.WORKFLOW_STEP_FAILED);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context?.stepId).toBe("broken");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("logs step start/end when logger injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logs: string[] = [];
|
||||
const wf = new CampaignWorkflow({
|
||||
logger: {
|
||||
debug: (m) => logs.push(m),
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
});
|
||||
const ctx: WorkflowContext = { campaign: makeCampaign() };
|
||||
await wf.run(
|
||||
[
|
||||
{
|
||||
id: "x",
|
||||
name: "X",
|
||||
description: "x",
|
||||
execute: async (c) => c,
|
||||
},
|
||||
],
|
||||
ctx
|
||||
);
|
||||
expect(logs).toEqual(["workflow.step.start", "workflow.step.end"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
packages/communication/emails/campaigns/src/workflow/workflow.ts
Normal file
127
packages/communication/emails/campaigns/src/workflow/workflow.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import type { Batch } from "../batch-planner/batch-planner.js";
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
|
||||
import type { Campaign } from "../types/campaign.types.js";
|
||||
|
||||
/** Context object threaded through every workflow step. */
|
||||
export interface WorkflowContext {
|
||||
readonly campaign: Campaign;
|
||||
contactIds?: string[];
|
||||
batches?: Batch[];
|
||||
results?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A single named step in a campaign send workflow. */
|
||||
export interface WorkflowStep {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
execute(context: WorkflowContext): Promise<WorkflowContext>;
|
||||
}
|
||||
|
||||
/** Construction options for {@link CampaignWorkflow}. */
|
||||
export interface CampaignWorkflowOptions {
|
||||
/** Optional logger used to trace step execution. */
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and runs the canonical send pipeline for a campaign.
|
||||
*
|
||||
* Step set is fixed (6 steps) but each step is an injectable async function
|
||||
* — call sites can wrap the default pipeline to layer audience resolution,
|
||||
* suppression filtering, batch planning, dispatch, stats roll-up, and
|
||||
* completion.
|
||||
*/
|
||||
export class CampaignWorkflow {
|
||||
private readonly logger?: LoggerLike;
|
||||
|
||||
public constructor(options: CampaignWorkflowOptions = {}) {
|
||||
if (options.logger !== undefined) {
|
||||
this.logger = options.logger;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a 6-step default send pipeline keyed by the campaign id. */
|
||||
public buildSendPipeline(_campaign: Campaign): WorkflowStep[] {
|
||||
return [
|
||||
{
|
||||
id: "resolve-audience",
|
||||
name: "Resolve Audience",
|
||||
description:
|
||||
"Resolve audience groups to individual contact IDs",
|
||||
execute: async (context) => ({
|
||||
...context,
|
||||
contactIds: context.contactIds ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "apply-suppression",
|
||||
name: "Apply Suppression",
|
||||
description: "Filter out suppressed and excluded contacts",
|
||||
execute: async (context) => ({
|
||||
...context,
|
||||
contactIds: context.contactIds ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "plan-batches",
|
||||
name: "Plan Batches",
|
||||
description:
|
||||
"Split contacts into send batches based on throttle config",
|
||||
execute: async (context) => ({
|
||||
...context,
|
||||
batches: context.batches ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "send-batches",
|
||||
name: "Send Batches",
|
||||
description:
|
||||
"Process and send each batch through the configured channel",
|
||||
execute: async (context) => context,
|
||||
},
|
||||
{
|
||||
id: "update-stats",
|
||||
name: "Update Stats",
|
||||
description: "Update campaign statistics with send results",
|
||||
execute: async (context) => context,
|
||||
},
|
||||
{
|
||||
id: "complete",
|
||||
name: "Complete Campaign",
|
||||
description: "Mark the campaign as completed",
|
||||
execute: async (context) => context,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the supplied step list against `initial`. Returns the final
|
||||
* context. Wraps any step error in a {@link CampaignsError} with code
|
||||
* `WORKFLOW_STEP_FAILED` so callers can switch on the canonical
|
||||
* registry instead of `instanceof` ladders.
|
||||
*/
|
||||
public async run(
|
||||
steps: readonly WorkflowStep[],
|
||||
initial: WorkflowContext
|
||||
): Promise<WorkflowContext> {
|
||||
let ctx = initial;
|
||||
for (const step of steps) {
|
||||
try {
|
||||
this.logger?.debug("workflow.step.start", {
|
||||
id: step.id,
|
||||
});
|
||||
ctx = await step.execute(ctx);
|
||||
this.logger?.debug("workflow.step.end", { id: step.id });
|
||||
} catch (cause) {
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.WORKFLOW_STEP_FAILED,
|
||||
`Workflow step '${step.id}' failed`,
|
||||
{ cause, context: { stepId: step.id } }
|
||||
);
|
||||
}
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
12
packages/communication/emails/campaigns/tsconfig.lib.json
Normal file
12
packages/communication/emails/campaigns/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
54
packages/communication/emails/campaigns/vite.config.ts
Normal file
54
packages/communication/emails/campaigns/vite.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir:
|
||||
"../../../node_modules/.vite/packages/communication-emails-campaigns",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"engine/index": "src/engine/index.ts",
|
||||
"state-machine/index": "src/state-machine/index.ts",
|
||||
"batch-planner/index": "src/batch-planner/index.ts",
|
||||
"workflow/index": "src/workflow/index.ts",
|
||||
"store/index": "src/store/index.ts",
|
||||
"types/index": "src/types/index.ts",
|
||||
"error-codes/index": "src/error-codes/index.ts",
|
||||
"telemetry/index": "src/telemetry/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/opentelemetry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/communication/emails/campaigns/vitest.config.ts
Normal file
32
packages/communication/emails/campaigns/vitest.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
hookTimeout: 5000,
|
||||
},
|
||||
});
|
||||
106
packages/communication/emails/delivery-tracking/README.md
Normal file
106
packages/communication/emails/delivery-tracking/README.md
Normal file
@@ -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`
|
||||
|
||||
155
packages/communication/emails/delivery-tracking/package.json
Normal file
155
packages/communication/emails/delivery-tracking/package.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"name": "@suaveplan/delivery-tracking",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./tracker": {
|
||||
"import": {
|
||||
"types": "./dist/core/tracker/tracker.d.ts",
|
||||
"default": "./dist/core/tracker/tracker.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/tracker/tracker.d.ts",
|
||||
"default": "./dist/core/tracker/tracker.js"
|
||||
}
|
||||
},
|
||||
"./aggregator": {
|
||||
"import": {
|
||||
"types": "./dist/core/aggregator/aggregator.d.ts",
|
||||
"default": "./dist/core/aggregator/aggregator.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/aggregator/aggregator.d.ts",
|
||||
"default": "./dist/core/aggregator/aggregator.js"
|
||||
}
|
||||
},
|
||||
"./tracking-link": {
|
||||
"import": {
|
||||
"types": "./dist/core/tracking-link/tracking-link.d.ts",
|
||||
"default": "./dist/core/tracking-link/tracking-link.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/tracking-link/tracking-link.d.ts",
|
||||
"default": "./dist/core/tracking-link/tracking-link.js"
|
||||
}
|
||||
},
|
||||
"./tracking-pixel": {
|
||||
"import": {
|
||||
"types": "./dist/core/tracking-pixel/tracking-pixel.d.ts",
|
||||
"default": "./dist/core/tracking-pixel/tracking-pixel.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/tracking-pixel/tracking-pixel.d.ts",
|
||||
"default": "./dist/core/tracking-pixel/tracking-pixel.js"
|
||||
}
|
||||
},
|
||||
"./webhook-parser": {
|
||||
"import": {
|
||||
"types": "./dist/core/webhook-parser/webhook-parser.d.ts",
|
||||
"default": "./dist/core/webhook-parser/webhook-parser.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/webhook-parser/webhook-parser.d.ts",
|
||||
"default": "./dist/core/webhook-parser/webhook-parser.js"
|
||||
}
|
||||
},
|
||||
"./stores/memory": {
|
||||
"import": {
|
||||
"types": "./dist/stores/memory/memory-store.d.ts",
|
||||
"default": "./dist/stores/memory/memory-store.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/stores/memory/memory-store.d.ts",
|
||||
"default": "./dist/stores/memory/memory-store.js"
|
||||
}
|
||||
},
|
||||
"./stores/surreal": {
|
||||
"import": {
|
||||
"types": "./dist/stores/surreal/surreal-store.d.ts",
|
||||
"default": "./dist/stores/surreal/surreal-store.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/stores/surreal/surreal-store.d.ts",
|
||||
"default": "./dist/stores/surreal/surreal-store.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/delivery-tracking-error.d.ts",
|
||||
"default": "./dist/errors/delivery-tracking-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/delivery-tracking-error.d.ts",
|
||||
"default": "./dist/errors/delivery-tracking-error.js"
|
||||
}
|
||||
},
|
||||
"./otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/surrealdb": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,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,100 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { InMemoryDeliveryEventStore } from "../../stores/memory/memory-store.js";
|
||||
import type { DeliveryEvent } from "../../types/delivery-tracking.types.js";
|
||||
import { DeliveryAggregator } from "./aggregator.js";
|
||||
|
||||
function evt(over: Partial<DeliveryEvent> = {}): DeliveryEvent {
|
||||
return {
|
||||
id: `evt-${Math.random()}`,
|
||||
tenantId: "t1",
|
||||
campaignId: "c1",
|
||||
contactId: "u1",
|
||||
channel: "email",
|
||||
eventType: "sent",
|
||||
messageId: `m-${Math.random()}`,
|
||||
metadata: {},
|
||||
timestamp: new Date("2026-06-01T12:00:00Z"),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DeliveryAggregator", () => {
|
||||
it("returns zero stats for an empty campaign", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const agg = new DeliveryAggregator(new InMemoryDeliveryEventStore());
|
||||
const stats = await agg.getCampaignStats("none");
|
||||
expect(stats.sent).toBe(0);
|
||||
expect(stats.deliveryRate).toBe(0);
|
||||
});
|
||||
|
||||
it("reads contact history and applies filters", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
await store.record(evt({ contactId: "u1", eventType: "sent" }));
|
||||
await store.record(evt({ contactId: "u1", eventType: "delivered" }));
|
||||
await store.record(evt({ contactId: "u2", eventType: "sent" }));
|
||||
const agg = new DeliveryAggregator(store);
|
||||
const all = await agg.getContactStats("u1");
|
||||
const onlySent = await agg.getContactStats("u1", { eventType: "sent" });
|
||||
expect(all).toHaveLength(2);
|
||||
expect(onlySent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty time series when the campaign has no events", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const agg = new DeliveryAggregator(new InMemoryDeliveryEventStore());
|
||||
expect(await agg.getTimeSeriesStats("none", "hour")).toEqual([]);
|
||||
});
|
||||
|
||||
it("buckets events by hour", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
await store.record(
|
||||
evt({
|
||||
eventType: "sent",
|
||||
timestamp: new Date("2026-06-01T10:00:00Z"),
|
||||
})
|
||||
);
|
||||
await store.record(
|
||||
evt({
|
||||
eventType: "sent",
|
||||
timestamp: new Date("2026-06-01T10:30:00Z"),
|
||||
})
|
||||
);
|
||||
await store.record(
|
||||
evt({
|
||||
eventType: "sent",
|
||||
timestamp: new Date("2026-06-01T11:15:00Z"),
|
||||
})
|
||||
);
|
||||
const agg = new DeliveryAggregator(store);
|
||||
const series = await agg.getTimeSeriesStats("c1", "hour");
|
||||
expect(series.length).toBeGreaterThanOrEqual(2);
|
||||
expect(series[0]!.stats.sent).toBe(2);
|
||||
expect(series[1]!.stats.sent).toBe(1);
|
||||
});
|
||||
|
||||
it("buckets events by day and by week", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
await store.record(
|
||||
evt({
|
||||
eventType: "sent",
|
||||
timestamp: new Date("2026-06-01T10:00:00Z"),
|
||||
})
|
||||
);
|
||||
await store.record(
|
||||
evt({
|
||||
eventType: "delivered",
|
||||
timestamp: new Date("2026-06-02T10:00:00Z"),
|
||||
})
|
||||
);
|
||||
const agg = new DeliveryAggregator(store);
|
||||
const daily = await agg.getTimeSeriesStats("c1", "day");
|
||||
expect(daily).toHaveLength(2);
|
||||
expect(daily[0]!.stats.sent).toBe(1);
|
||||
const weekly = await agg.getTimeSeriesStats("c1", "week");
|
||||
expect(weekly.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { DeliveryEventStore } from "../../stores/memory/memory-store.js";
|
||||
import { computeStats } from "../../stores/memory/memory-store.js";
|
||||
import type {
|
||||
DeliveryEvent,
|
||||
DeliveryEventQuery,
|
||||
DeliveryStats,
|
||||
TimeSeriesBucket,
|
||||
} from "../../types/delivery-tracking.types.js";
|
||||
|
||||
/** Supported time-series bucket sizes. */
|
||||
export type TimeInterval = "hour" | "day" | "week";
|
||||
|
||||
const INTERVAL_MS: Record<TimeInterval, number> = {
|
||||
hour: 3_600_000,
|
||||
day: 86_400_000,
|
||||
week: 604_800_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads campaign/contact statistics from a `DeliveryEventStore`.
|
||||
*
|
||||
* The aggregator is stateless — it composes the store's query surface
|
||||
* into derived views (rates, time-series, contact history) without
|
||||
* caching, so callers can layer their own memoization.
|
||||
*/
|
||||
export class DeliveryAggregator {
|
||||
private readonly store: DeliveryEventStore;
|
||||
|
||||
public constructor(store: DeliveryEventStore) {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
/** Campaign-level rollup of `DeliveryStats` (rates + counts). */
|
||||
public async getCampaignStats(campaignId: string): Promise<DeliveryStats> {
|
||||
return this.store.getStatsByCampaign(campaignId);
|
||||
}
|
||||
|
||||
/** Per-contact event history, optionally filtered. */
|
||||
public async getContactStats(
|
||||
contactId: string,
|
||||
query?: DeliveryEventQuery
|
||||
): Promise<DeliveryEvent[]> {
|
||||
return this.store.getByContact(contactId, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket events for a campaign into time slots of size `interval`.
|
||||
* Returns an empty array if the campaign has no events.
|
||||
*/
|
||||
public async getTimeSeriesStats(
|
||||
campaignId: string,
|
||||
interval: TimeInterval
|
||||
): Promise<TimeSeriesBucket[]> {
|
||||
const events = await this.store.getByCampaign(campaignId);
|
||||
if (events.length === 0) return [];
|
||||
|
||||
const intervalMs = INTERVAL_MS[interval];
|
||||
const sorted = [...events].sort(
|
||||
(a, b) => a.timestamp.getTime() - b.timestamp.getTime()
|
||||
);
|
||||
|
||||
const startTime = sorted[0]!.timestamp.getTime();
|
||||
const endTime = sorted[sorted.length - 1]!.timestamp.getTime();
|
||||
const buckets: TimeSeriesBucket[] = [];
|
||||
|
||||
for (
|
||||
let bucketStart = startTime;
|
||||
bucketStart <= endTime;
|
||||
bucketStart += intervalMs
|
||||
) {
|
||||
const bucketEnd = bucketStart + intervalMs;
|
||||
const slice = sorted.filter((e) => {
|
||||
const t = e.timestamp.getTime();
|
||||
return t >= bucketStart && t < bucketEnd;
|
||||
});
|
||||
buckets.push({
|
||||
timestamp: new Date(bucketStart),
|
||||
stats: computeStats(slice),
|
||||
});
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DeliveryAggregator, type TimeInterval } from "./aggregator.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { DeliveryTracker, type DeliveryTrackerOptions } from "./tracker.js";
|
||||
export {
|
||||
type CreateDeliveryEventInputSchema,
|
||||
createDeliveryEventInputSchema,
|
||||
deliveryEventTypeSchema,
|
||||
} from "./tracker.schemas.js";
|
||||
@@ -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,38 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod boundary schema for `CreateDeliveryEventInput`.
|
||||
*
|
||||
* Applied inside `DeliveryTracker.track` so any untrusted caller (webhook,
|
||||
* RPC, HTTP) is validated before the event reaches the store. Failed
|
||||
* validation produces `DeliveryTrackingError(INVALID_INPUT)`.
|
||||
*/
|
||||
export const deliveryEventTypeSchema = z.enum([
|
||||
"sent",
|
||||
"delivered",
|
||||
"opened",
|
||||
"clicked",
|
||||
"bounced",
|
||||
"unsubscribed",
|
||||
"complained",
|
||||
"deferred",
|
||||
"dropped",
|
||||
"read",
|
||||
]);
|
||||
|
||||
export const createDeliveryEventInputSchema = z.object({
|
||||
tenantId: z.string().min(1),
|
||||
campaignId: z.string().min(1).optional(),
|
||||
contactId: z.string().min(1),
|
||||
channel: z.string().min(1),
|
||||
eventType: deliveryEventTypeSchema,
|
||||
messageId: z.string().min(1),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
timestamp: z.date().optional(),
|
||||
ip: z.string().optional(),
|
||||
userAgent: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateDeliveryEventInputSchema = z.infer<
|
||||
typeof createDeliveryEventInputSchema
|
||||
>;
|
||||
@@ -0,0 +1,177 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../../tests/setup/polyfills.js";
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
} from "../../errors/delivery-tracking-error.js";
|
||||
import { resetDeliveryTrackingInstruments } from "../../otel/instruments.js";
|
||||
import { InMemoryDeliveryEventStore } from "../../stores/memory/memory-store.js";
|
||||
import type { CreateDeliveryEventInput } from "../../types/delivery-tracking.types.js";
|
||||
import { DeliveryTracker } from "./tracker.js";
|
||||
|
||||
function input(
|
||||
over: Partial<CreateDeliveryEventInput> = {}
|
||||
): CreateDeliveryEventInput {
|
||||
return {
|
||||
tenantId: "t1",
|
||||
campaignId: "c1",
|
||||
contactId: "u1",
|
||||
channel: "email",
|
||||
eventType: "sent",
|
||||
messageId: "m1",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
interface DataPoint {
|
||||
value?: number;
|
||||
}
|
||||
|
||||
async function totalFor(name: string): Promise<number> {
|
||||
const collected = await flushTestMetrics();
|
||||
let sum = 0;
|
||||
for (const rm of collected) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name !== name) continue;
|
||||
for (const dp of m.dataPoints as DataPoint[]) {
|
||||
if (typeof dp.value === "number") sum += dp.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
describe("DeliveryTracker", () => {
|
||||
beforeEach(async () => {
|
||||
resetDeliveryTrackingInstruments();
|
||||
// Drain any metric data left over from the previous test so DELTA
|
||||
// snapshots in this test only reflect this test's increments.
|
||||
await flushTestMetrics();
|
||||
});
|
||||
|
||||
it("records an event and returns it", { timeout: 5000 }, async () => {
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
|
||||
const r = await tracker.track(input());
|
||||
expect(r).not.toBeNull();
|
||||
expect(r?.eventType).toBe("sent");
|
||||
expect(r?.id).toMatch(/^evt_/);
|
||||
});
|
||||
|
||||
it("uses injected Clock + IdGenerator for deterministic ids", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let n = 0;
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore(), {
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: () => {
|
||||
n += 1;
|
||||
return `id-${n}`;
|
||||
},
|
||||
});
|
||||
const a = await tracker.track(input({ messageId: "ma" }));
|
||||
const b = await tracker.track(input({ messageId: "mb" }));
|
||||
expect(a?.id).toBe("id-1");
|
||||
expect(b?.id).toBe("id-2");
|
||||
expect(a?.timestamp.getTime()).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it("emits a typed bus event matching eventType", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
|
||||
let seen = 0;
|
||||
tracker.bus.on("delivery:sent", () => {
|
||||
seen += 1;
|
||||
});
|
||||
await tracker.track(input());
|
||||
expect(seen).toBe(1);
|
||||
});
|
||||
|
||||
it("dedupes lifecycle events per messageId", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
|
||||
const a = await tracker.track(input({ eventType: "opened" }));
|
||||
const b = await tracker.track(input({ eventType: "opened" }));
|
||||
expect(a).not.toBeNull();
|
||||
expect(b).toBeNull();
|
||||
const deduped = await totalFor("delivery_tracking.deduped.total");
|
||||
expect(deduped).toBe(1);
|
||||
});
|
||||
|
||||
it("does not dedupe non-lifecycle events", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
|
||||
const a = await tracker.track(input({ eventType: "clicked" }));
|
||||
const b = await tracker.track(input({ eventType: "clicked" }));
|
||||
expect(a).not.toBeNull();
|
||||
expect(b).not.toBeNull();
|
||||
});
|
||||
|
||||
it("throws DeliveryTrackingError(INVALID_INPUT) on schema violation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
|
||||
let captured: unknown;
|
||||
try {
|
||||
await tracker.track(input({ tenantId: "" }));
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(DeliveryTrackingError);
|
||||
expect((captured as DeliveryTrackingError).code).toBe(
|
||||
DELIVERY_TRACKING_CODES.INVALID_INPUT
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves metadata and timestamp when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
|
||||
const ts = new Date("2026-03-01T00:00:00Z");
|
||||
const r = await tracker.track(
|
||||
input({ metadata: { k: "v" }, timestamp: ts })
|
||||
);
|
||||
expect(r?.metadata).toEqual({ k: "v" });
|
||||
expect(r?.timestamp).toEqual(ts);
|
||||
});
|
||||
|
||||
it("defaults to empty metadata and 'now' timestamp via injected clock", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore(), {
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
const r = await tracker.track(input());
|
||||
expect(r?.metadata).toEqual({});
|
||||
expect(r?.timestamp.getTime()).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it("trackBatch processes inputs sequentially and reports nulls for dedupes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
|
||||
const results = await tracker.trackBatch([
|
||||
input({ eventType: "opened", messageId: "x" }),
|
||||
input({ eventType: "opened", messageId: "x" }),
|
||||
input({ eventType: "sent", messageId: "y" }),
|
||||
]);
|
||||
expect(results[0]).not.toBeNull();
|
||||
expect(results[1]).toBeNull();
|
||||
expect(results[2]).not.toBeNull();
|
||||
});
|
||||
|
||||
it("emits tracked + deduped counters via real MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tracker = new DeliveryTracker(new InMemoryDeliveryEventStore());
|
||||
await tracker.track(input({ eventType: "delivered" }));
|
||||
await tracker.track(input({ eventType: "delivered" })); // dedupe
|
||||
await tracker.track(input({ eventType: "clicked", messageId: "k" }));
|
||||
const tracked = await totalFor("delivery_tracking.tracked.total");
|
||||
expect(tracked).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
} from "../../errors/delivery-tracking-error.js";
|
||||
import { getDeliveryTrackingInstruments } from "../../otel/instruments.js";
|
||||
import type { DeliveryEventStore } from "../../stores/memory/memory-store.js";
|
||||
import type {
|
||||
CreateDeliveryEventInput,
|
||||
DeliveryEvent,
|
||||
DeliveryEventType,
|
||||
DeliveryTrackerEventMap,
|
||||
} from "../../types/delivery-tracking.types.js";
|
||||
import { createDeliveryEventInputSchema } from "./tracker.schemas.js";
|
||||
|
||||
/** Event types that are deduplicated per `messageId`. */
|
||||
const DEDUPE_EVENT_TYPES: ReadonlySet<DeliveryEventType> = new Set([
|
||||
"opened",
|
||||
"delivered",
|
||||
"bounced",
|
||||
"unsubscribed",
|
||||
"complained",
|
||||
]);
|
||||
|
||||
/** Construction options for `DeliveryTracker`. */
|
||||
export interface DeliveryTrackerOptions {
|
||||
readonly clock?: Clock;
|
||||
readonly idGenerator?: IdGenerator;
|
||||
readonly bus?: EventBus<DeliveryTrackerEventMap>;
|
||||
}
|
||||
|
||||
function defaultClock(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function makeDefaultIdGenerator(clock: Clock): IdGenerator {
|
||||
let counter = 0;
|
||||
return () => {
|
||||
counter += 1;
|
||||
return `evt_${clock()}_${counter}`;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records `DeliveryEvent`s and re-emits them as typed bus events.
|
||||
*
|
||||
* Lifecycle dedupe applies to `opened`, `delivered`, `bounced`,
|
||||
* `unsubscribed`, and `complained` per `messageId`. All other event
|
||||
* types are allowed to repeat. Clock + ID generation are injectable so
|
||||
* tests can pin both deterministically.
|
||||
*/
|
||||
export class DeliveryTracker {
|
||||
public readonly bus: EventBus<DeliveryTrackerEventMap>;
|
||||
private readonly store: DeliveryEventStore;
|
||||
private readonly clock: Clock;
|
||||
private readonly idGenerator: IdGenerator;
|
||||
|
||||
public constructor(
|
||||
store: DeliveryEventStore,
|
||||
options: DeliveryTrackerOptions = {}
|
||||
) {
|
||||
this.store = store;
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
this.idGenerator =
|
||||
options.idGenerator ?? makeDefaultIdGenerator(this.clock);
|
||||
this.bus = options.bus ?? new EventBus<DeliveryTrackerEventMap>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Track one delivery event. Returns the persisted event, or `null` when
|
||||
* the input was rejected by lifecycle dedupe.
|
||||
*/
|
||||
public async track(
|
||||
input: CreateDeliveryEventInput
|
||||
): Promise<DeliveryEvent | null> {
|
||||
const parsed = createDeliveryEventInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new DeliveryTrackingError(
|
||||
DELIVERY_TRACKING_CODES.INVALID_INPUT,
|
||||
"error.delivery_tracking.invalid_input",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { issues: parsed.error.issues },
|
||||
}
|
||||
);
|
||||
}
|
||||
const safe = parsed.data;
|
||||
const instruments = getDeliveryTrackingInstruments();
|
||||
|
||||
if (DEDUPE_EVENT_TYPES.has(safe.eventType)) {
|
||||
const already = await this.store.exists(
|
||||
safe.messageId,
|
||||
safe.eventType
|
||||
);
|
||||
if (already) {
|
||||
instruments.dedupedCounter.add(1, {
|
||||
event_type: safe.eventType,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const event: DeliveryEvent = {
|
||||
id: this.idGenerator(),
|
||||
tenantId: safe.tenantId,
|
||||
campaignId: safe.campaignId,
|
||||
contactId: safe.contactId,
|
||||
channel: safe.channel,
|
||||
eventType: safe.eventType,
|
||||
messageId: safe.messageId,
|
||||
metadata: safe.metadata ?? {},
|
||||
timestamp: safe.timestamp ?? new Date(this.clock()),
|
||||
ip: safe.ip,
|
||||
userAgent: safe.userAgent,
|
||||
};
|
||||
|
||||
await this.store.record(event);
|
||||
instruments.trackedCounter.add(1, {
|
||||
event_type: event.eventType,
|
||||
channel: event.channel,
|
||||
});
|
||||
|
||||
const topic =
|
||||
`delivery:${event.eventType}` as keyof DeliveryTrackerEventMap;
|
||||
this.bus.emit(topic, event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a batch of events sequentially. Returns the resulting events in
|
||||
* input order, with `null` placeholders for entries that were deduped.
|
||||
*/
|
||||
public async trackBatch(
|
||||
inputs: readonly CreateDeliveryEventInput[]
|
||||
): Promise<(DeliveryEvent | null)[]> {
|
||||
const out: (DeliveryEvent | null)[] = [];
|
||||
for (const input of inputs) {
|
||||
out.push(await this.track(input));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TrackingLinkWrapper } from "./tracking-link.js";
|
||||
@@ -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,52 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TrackingLinkWrapper } from "./tracking-link.js";
|
||||
|
||||
const config = { baseUrl: "https://t.example.com", secret: "s3cret" };
|
||||
|
||||
describe("TrackingLinkWrapper", () => {
|
||||
it("wraps a single URL using the default click path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const w = new TrackingLinkWrapper(config);
|
||||
const wrapped = w.wrapUrl(
|
||||
"https://target.example.com/x",
|
||||
"m1",
|
||||
"u1",
|
||||
"c1"
|
||||
);
|
||||
expect(wrapped.startsWith("https://t.example.com/track/click?t=")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("respects a custom clickPath", { timeout: 5000 }, () => {
|
||||
const w = new TrackingLinkWrapper({
|
||||
...config,
|
||||
clickPath: "/redir",
|
||||
});
|
||||
const wrapped = w.wrapUrl("https://x.example.com", "m", "u");
|
||||
expect(wrapped.startsWith("https://t.example.com/redir?t=")).toBe(true);
|
||||
});
|
||||
|
||||
it("parses its click token back to the original URL", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const w = new TrackingLinkWrapper(config);
|
||||
const original = "https://example.com/landing?utm=1";
|
||||
const wrapped = w.wrapUrl(original, "m1", "u1", "c1");
|
||||
const token = wrapped.split("?t=")[1]!;
|
||||
const payload = w.parseClickToken(token);
|
||||
expect(payload.originalUrl).toBe(original);
|
||||
expect(payload.messageId).toBe("m1");
|
||||
expect(payload.contactId).toBe("u1");
|
||||
expect(payload.campaignId).toBe("c1");
|
||||
});
|
||||
|
||||
it("rewrites every <a href> in an HTML body", { timeout: 5000 }, () => {
|
||||
const w = new TrackingLinkWrapper(config);
|
||||
const html = `<p>hi</p><a class="x" href="https://a.example.com">A</a> <a HREF='https://b.example.com'>B</a>`;
|
||||
const out = w.wrapAllLinks(html, "m", "u");
|
||||
expect(out).toContain('href="https://t.example.com/track/click?t=');
|
||||
expect(out.match(/track\/click\?t=/g)?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
ClickTrackingTokenPayload,
|
||||
TrackingConfig,
|
||||
} from "../../types/delivery-tracking.types.js";
|
||||
import { decodeToken, encodeToken } from "../tracking-pixel/token-codec.js";
|
||||
|
||||
/**
|
||||
* Wraps outbound URLs with click-tracking redirects.
|
||||
*
|
||||
* Use `wrapUrl` for a single URL or `wrapAllLinks` to rewrite every
|
||||
* `<a href="…">` in an HTML body. The token codec is shared with
|
||||
* `TrackingPixelGenerator` so both surfaces emit identical OTel metrics.
|
||||
*/
|
||||
export class TrackingLinkWrapper {
|
||||
private readonly config: TrackingConfig;
|
||||
private readonly clickPath: string;
|
||||
|
||||
public constructor(config: TrackingConfig) {
|
||||
this.config = config;
|
||||
this.clickPath = config.clickPath ?? "/track/click";
|
||||
}
|
||||
|
||||
/** Encode a single URL with click tracking. */
|
||||
public wrapUrl(
|
||||
originalUrl: string,
|
||||
messageId: string,
|
||||
contactId: string,
|
||||
campaignId?: string
|
||||
): string {
|
||||
const payload: ClickTrackingTokenPayload = {
|
||||
originalUrl,
|
||||
messageId,
|
||||
contactId,
|
||||
campaignId,
|
||||
};
|
||||
const token = encodeToken(payload, this.config.secret);
|
||||
const base = this.config.baseUrl.replace(/\/$/, "");
|
||||
return `${base}${this.clickPath}?t=${token}`;
|
||||
}
|
||||
|
||||
/** Decode a click token, throwing `DeliveryTrackingError(INVALID_TOKEN)` on failure. */
|
||||
public parseClickToken(token: string): ClickTrackingTokenPayload {
|
||||
return decodeToken<ClickTrackingTokenPayload>(
|
||||
token,
|
||||
this.config.secret
|
||||
);
|
||||
}
|
||||
|
||||
/** Rewrite every `<a href="…">` in `html` with a click-tracking URL. */
|
||||
public wrapAllLinks(
|
||||
html: string,
|
||||
messageId: string,
|
||||
contactId: string,
|
||||
campaignId?: string
|
||||
): string {
|
||||
return html.replace(
|
||||
/<a\s([^>]*?)href=["']([^"']+)["']([^>]*)>/gi,
|
||||
(_match, before: string, url: string, after: string) => {
|
||||
const wrapped = this.wrapUrl(
|
||||
url,
|
||||
messageId,
|
||||
contactId,
|
||||
campaignId
|
||||
);
|
||||
return `<a ${before}href="${wrapped}"${after}>`;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { decodeToken, encodeToken } from "./token-codec.js";
|
||||
export { TrackingPixelGenerator } from "./tracking-pixel.js";
|
||||
@@ -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,34 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
} from "../../errors/delivery-tracking-error.js";
|
||||
import { decodeToken, encodeToken } from "./token-codec.js";
|
||||
|
||||
describe("token-codec", () => {
|
||||
it("round-trips a JSON payload", { timeout: 5000 }, () => {
|
||||
const payload = { messageId: "m", contactId: "u", campaignId: "c" };
|
||||
const token = encodeToken(payload, "secret");
|
||||
expect(decodeToken<typeof payload>(token, "secret")).toEqual(payload);
|
||||
});
|
||||
|
||||
it("throws DeliveryTrackingError(INVALID_TOKEN) on malformed token", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
decodeToken("!!!not-base64!!!", "secret");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(DeliveryTrackingError);
|
||||
expect((captured as DeliveryTrackingError).code).toBe(
|
||||
DELIVERY_TRACKING_CODES.INVALID_TOKEN
|
||||
);
|
||||
});
|
||||
|
||||
it("produces URL-safe characters only", { timeout: 5000 }, () => {
|
||||
const token = encodeToken({ a: 1 }, "s");
|
||||
expect(token).not.toMatch(/[+/=]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
} from "../../errors/delivery-tracking-error.js";
|
||||
import { getDeliveryTrackingInstruments } from "../../otel/instruments.js";
|
||||
|
||||
/**
|
||||
* Shared XOR+base64url token codec used by the pixel and link generators.
|
||||
*
|
||||
* The codec is not a cryptographic primitive — it provides URL-safe
|
||||
* opaque encoding of small JSON payloads. Callers that need integrity
|
||||
* should layer an HMAC outside this module.
|
||||
*/
|
||||
function xorCipher(input: string, key: string): string {
|
||||
let result = "";
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const charCode = input.charCodeAt(i) ^ key.charCodeAt(i % key.length);
|
||||
result += String.fromCharCode(charCode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toBase64Url(str: string): string {
|
||||
const base64 = Buffer.from(str, "binary").toString("base64");
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function fromBase64Url(str: string): string {
|
||||
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (base64.length % 4 !== 0) base64 += "=";
|
||||
return Buffer.from(base64, "base64").toString("binary");
|
||||
}
|
||||
|
||||
/** Encode a JSON-serialisable payload as a URL-safe opaque token. */
|
||||
export function encodeToken(payload: unknown, secret: string): string {
|
||||
getDeliveryTrackingInstruments().tokenIssuedCounter.add(1, {
|
||||
kind: "encode",
|
||||
});
|
||||
return toBase64Url(xorCipher(JSON.stringify(payload), secret));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a token produced by `encodeToken`. Increments the
|
||||
* `delivery_tracking.token_rejected.total` counter and throws
|
||||
* `DeliveryTrackingError(INVALID_TOKEN)` when decoding fails.
|
||||
*/
|
||||
export function decodeToken<T>(token: string, secret: string): T {
|
||||
try {
|
||||
const json = xorCipher(fromBase64Url(token), secret);
|
||||
return JSON.parse(json) as T;
|
||||
} catch (cause) {
|
||||
getDeliveryTrackingInstruments().tokenRejectedCounter.add(1);
|
||||
throw new DeliveryTrackingError(
|
||||
DELIVERY_TRACKING_CODES.INVALID_TOKEN,
|
||||
"error.delivery_tracking.invalid_token",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,50 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TrackingPixelGenerator } from "./tracking-pixel.js";
|
||||
|
||||
const config = {
|
||||
baseUrl: "https://t.example.com",
|
||||
secret: "very-secret",
|
||||
};
|
||||
|
||||
describe("TrackingPixelGenerator", () => {
|
||||
it("generates a URL with the default pixel path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const gen = new TrackingPixelGenerator(config);
|
||||
const url = gen.generatePixelUrl("m1", "u1", "c1");
|
||||
expect(url.startsWith("https://t.example.com/track/open?t=")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("honours a custom pixelPath and trims trailing slashes from baseUrl", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const gen = new TrackingPixelGenerator({
|
||||
baseUrl: "https://t.example.com/",
|
||||
secret: "k",
|
||||
pixelPath: "/p",
|
||||
});
|
||||
const url = gen.generatePixelUrl("m1", "u1");
|
||||
expect(url.startsWith("https://t.example.com/p?t=")).toBe(true);
|
||||
});
|
||||
|
||||
it("parses its own token back into the payload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const gen = new TrackingPixelGenerator(config);
|
||||
const url = gen.generatePixelUrl("m1", "u1", "c1");
|
||||
const token = url.split("?t=")[1]!;
|
||||
const payload = gen.parsePixelToken(token);
|
||||
expect(payload.messageId).toBe("m1");
|
||||
expect(payload.contactId).toBe("u1");
|
||||
expect(payload.campaignId).toBe("c1");
|
||||
});
|
||||
|
||||
it("renders a hidden 1x1 <img> HTML snippet", { timeout: 5000 }, () => {
|
||||
const gen = new TrackingPixelGenerator(config);
|
||||
const html = gen.generatePixelHtml("https://t.example.com/p?t=x");
|
||||
expect(html).toContain('width="1"');
|
||||
expect(html).toContain("display:none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
TrackingConfig,
|
||||
TrackingTokenPayload,
|
||||
} from "../../types/delivery-tracking.types.js";
|
||||
import { decodeToken, encodeToken } from "./token-codec.js";
|
||||
|
||||
/**
|
||||
* Generates and parses open-tracking pixel URLs.
|
||||
*
|
||||
* The pixel URL contains an opaque base64url token (XOR-obfuscated against
|
||||
* `secret`) carrying the (`messageId`, `contactId`, `campaignId`) triple,
|
||||
* so the receiving webhook can attribute the open without an extra lookup.
|
||||
*/
|
||||
export class TrackingPixelGenerator {
|
||||
private readonly config: TrackingConfig;
|
||||
private readonly pixelPath: string;
|
||||
|
||||
public constructor(config: TrackingConfig) {
|
||||
this.config = config;
|
||||
this.pixelPath = config.pixelPath ?? "/track/open";
|
||||
}
|
||||
|
||||
/** Build a fully-qualified pixel URL for an outbound message. */
|
||||
public generatePixelUrl(
|
||||
messageId: string,
|
||||
contactId: string,
|
||||
campaignId?: string
|
||||
): string {
|
||||
const payload: TrackingTokenPayload = {
|
||||
messageId,
|
||||
contactId,
|
||||
campaignId,
|
||||
};
|
||||
const token = encodeToken(payload, this.config.secret);
|
||||
const base = this.config.baseUrl.replace(/\/$/, "");
|
||||
return `${base}${this.pixelPath}?t=${token}`;
|
||||
}
|
||||
|
||||
/** Decode a pixel token previously produced by `generatePixelUrl`. */
|
||||
public parsePixelToken(token: string): TrackingTokenPayload {
|
||||
return decodeToken<TrackingTokenPayload>(token, this.config.secret);
|
||||
}
|
||||
|
||||
/** Wrap a pixel URL into a hidden `<img>` tag suitable for HTML emails. */
|
||||
public generatePixelHtml(url: string): string {
|
||||
return `<img src="${url}" width="1" height="1" alt="" style="display:none;border:0;" />`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type BuiltinWebhookProvider,
|
||||
type ProviderParser,
|
||||
WebhookEventParser,
|
||||
type WebhookPayload,
|
||||
} from "./webhook-parser.js";
|
||||
@@ -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,324 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
} from "../../errors/delivery-tracking-error.js";
|
||||
import { WebhookEventParser } from "./webhook-parser.js";
|
||||
|
||||
describe("WebhookEventParser - sendgrid", () => {
|
||||
it("maps each known sendgrid event to a normalised input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
const events = parser.parse("sendgrid", {
|
||||
body: [
|
||||
{ event: "processed", sg_message_id: "m1", email: "a@x.com" },
|
||||
{ event: "delivered", sg_message_id: "m1", email: "a@x.com" },
|
||||
{ event: "open", sg_message_id: "m2", email: "b@x.com" },
|
||||
{ event: "click", sg_message_id: "m2", email: "b@x.com" },
|
||||
{ event: "bounce", sg_message_id: "m3", email: "c@x.com" },
|
||||
{
|
||||
event: "unsubscribe",
|
||||
sg_message_id: "m4",
|
||||
email: "d@x.com",
|
||||
},
|
||||
{
|
||||
event: "spamreport",
|
||||
sg_message_id: "m5",
|
||||
email: "e@x.com",
|
||||
},
|
||||
{ event: "deferred", sg_message_id: "m6", email: "f@x.com" },
|
||||
{ event: "dropped", sg_message_id: "m7", email: "g@x.com" },
|
||||
{ event: "unknown", sg_message_id: "m8", email: "h@x.com" },
|
||||
],
|
||||
});
|
||||
expect(events).toHaveLength(9);
|
||||
expect(events[0]!.eventType).toBe("sent");
|
||||
expect(events[0]!.contactId).toBe("a@x.com");
|
||||
expect(events[6]!.eventType).toBe("complained");
|
||||
});
|
||||
|
||||
it("falls back to smtp-id when sg_message_id is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
const events = parser.parse("sendgrid", {
|
||||
body: {
|
||||
event: "delivered",
|
||||
"smtp-id": "abc",
|
||||
contact_id: "u9",
|
||||
tenant_id: "tenantA",
|
||||
campaign_id: "campA",
|
||||
ip: "1.1.1.1",
|
||||
useragent: "ua",
|
||||
},
|
||||
});
|
||||
expect(events[0]!.messageId).toBe("abc");
|
||||
expect(events[0]!.contactId).toBe("u9");
|
||||
expect(events[0]!.tenantId).toBe("tenantA");
|
||||
expect(events[0]!.campaignId).toBe("campA");
|
||||
expect(events[0]!.ip).toBe("1.1.1.1");
|
||||
expect(events[0]!.userAgent).toBe("ua");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebhookEventParser - mailgun", () => {
|
||||
it("extracts event from event-data and message headers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
const events = parser.parse("mailgun", {
|
||||
body: {
|
||||
"event-data": {
|
||||
event: "delivered",
|
||||
recipient: "u@x.com",
|
||||
message: { headers: { "message-id": "<m@x>" } },
|
||||
ip: "2.2.2.2",
|
||||
"user-agent": "ua",
|
||||
tenant_id: "tA",
|
||||
campaign_id: "cA",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.messageId).toBe("<m@x>");
|
||||
expect(events[0]!.eventType).toBe("delivered");
|
||||
expect(events[0]!.ip).toBe("2.2.2.2");
|
||||
expect(events[0]!.tenantId).toBe("tA");
|
||||
});
|
||||
|
||||
it("returns empty when event-data is missing or unrecognised", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
expect(
|
||||
parser.parse("mailgun", {
|
||||
body: { "event-data": { event: "what" } },
|
||||
})
|
||||
).toEqual([]);
|
||||
// body is an array
|
||||
const e = parser.parse("mailgun", {
|
||||
body: [
|
||||
{
|
||||
"event-data": {
|
||||
event: "opened",
|
||||
recipient: "u@x.com",
|
||||
id: "id-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(e[0]!.messageId).toBe("id-1");
|
||||
// empty array body
|
||||
expect(parser.parse("mailgun", { body: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebhookEventParser - ses", () => {
|
||||
it("parses the SNS Message envelope", { timeout: 5000 }, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
const message = JSON.stringify({
|
||||
notificationType: "Bounce",
|
||||
mail: {
|
||||
messageId: "m-ses",
|
||||
destination: ["u@x.com"],
|
||||
tenant_id: "t1",
|
||||
campaign_id: "c1",
|
||||
},
|
||||
});
|
||||
const events = parser.parse("ses", { body: { Message: message } });
|
||||
expect(events[0]!.eventType).toBe("bounced");
|
||||
expect(events[0]!.messageId).toBe("m-ses");
|
||||
expect(events[0]!.contactId).toBe("u@x.com");
|
||||
});
|
||||
|
||||
it("parses a raw SES record without an SNS envelope", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
const events = parser.parse("ses", {
|
||||
body: {
|
||||
eventType: "Delivery",
|
||||
mail: { messageId: "m2", destination: ["b@x.com"] },
|
||||
},
|
||||
});
|
||||
expect(events[0]!.eventType).toBe("delivered");
|
||||
});
|
||||
|
||||
it("returns empty when notificationType is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
expect(parser.parse("ses", { body: { mail: {} } })).toEqual([]);
|
||||
expect(parser.parse("ses", { body: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebhookEventParser - generic + extensibility", () => {
|
||||
it("parses a generic shape", { timeout: 5000 }, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
const events = parser.parse("generic", {
|
||||
body: {
|
||||
eventType: "opened",
|
||||
messageId: "m",
|
||||
contactId: "u",
|
||||
tenantId: "t",
|
||||
campaignId: "c",
|
||||
channel: "sms",
|
||||
metadata: { extra: 1 },
|
||||
},
|
||||
});
|
||||
expect(events[0]!.channel).toBe("sms");
|
||||
expect(events[0]!.metadata).toEqual({ extra: 1 });
|
||||
});
|
||||
|
||||
it("returns empty for a generic payload missing eventType", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
expect(parser.parse("generic", { body: { messageId: "m" } })).toEqual(
|
||||
[]
|
||||
);
|
||||
expect(parser.parse("generic", { body: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("supports registering a custom provider parser", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser({
|
||||
postmark: {
|
||||
parse: () => [
|
||||
{
|
||||
tenantId: "t1",
|
||||
contactId: "u1",
|
||||
channel: "email",
|
||||
eventType: "sent",
|
||||
messageId: "post-m1",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(parser.parse("postmark", { body: {} })).toHaveLength(1);
|
||||
parser.registerProvider("brevo", {
|
||||
parse: () => [
|
||||
{
|
||||
tenantId: "t1",
|
||||
contactId: "u1",
|
||||
channel: "email",
|
||||
eventType: "sent",
|
||||
messageId: "brevo-m1",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parser.parse("brevo", { body: {} })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("sendgrid falls back through every || branch when fields are blank", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
// Every optional field missing → defaults / fallbacks fire.
|
||||
const events = parser.parse("sendgrid", {
|
||||
body: {
|
||||
event: "open",
|
||||
// no tenant_id, no campaign_id, no contact_id (falls to email)
|
||||
email: "fallback@x.com",
|
||||
// no sg_message_id → falls to smtp-id (also missing → "")
|
||||
// no ip, no useragent → undefined branches
|
||||
},
|
||||
});
|
||||
expect(events[0]!.tenantId).toBe("default");
|
||||
expect(events[0]!.campaignId).toBeUndefined();
|
||||
expect(events[0]!.contactId).toBe("fallback@x.com");
|
||||
expect(events[0]!.messageId).toBe("");
|
||||
expect(events[0]!.ip).toBeUndefined();
|
||||
expect(events[0]!.userAgent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mailgun parses a payload without the event-data wrapper", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
const events = parser.parse("mailgun", {
|
||||
body: { event: "delivered", recipient: "u@x.com", id: "m-flat" },
|
||||
});
|
||||
expect(events[0]!.eventType).toBe("delivered");
|
||||
expect(events[0]!.messageId).toBe("m-flat");
|
||||
});
|
||||
|
||||
it("mailgun falls back through every || branch when fields are blank", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
const events = parser.parse("mailgun", {
|
||||
body: {
|
||||
"event-data": {
|
||||
event: "delivered",
|
||||
recipient: "u@x.com",
|
||||
// no message.headers; id present, no tenant/campaign/ip/ua
|
||||
id: "evt-id",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(events[0]!.tenantId).toBe("default");
|
||||
expect(events[0]!.campaignId).toBeUndefined();
|
||||
expect(events[0]!.messageId).toBe("evt-id");
|
||||
expect(events[0]!.ip).toBeUndefined();
|
||||
expect(events[0]!.userAgent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ses falls back when mail.destination is missing or non-array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
const events = parser.parse("ses", {
|
||||
body: {
|
||||
eventType: "Send",
|
||||
mail: { messageId: "m", destination: "not-array" },
|
||||
},
|
||||
});
|
||||
expect(events[0]!.contactId).toBe("");
|
||||
expect(events[0]!.tenantId).toBe("default");
|
||||
expect(events[0]!.campaignId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("generic falls back to defaults when optional fields are blank", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
const events = parser.parse("generic", {
|
||||
body: { eventType: "sent", messageId: "m" },
|
||||
});
|
||||
expect(events[0]!.tenantId).toBe("default");
|
||||
expect(events[0]!.campaignId).toBeUndefined();
|
||||
expect(events[0]!.channel).toBe("unknown");
|
||||
expect(events[0]!.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("asObject defaults to empty when fed null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
// metadata: null in generic body forces asObject's null branch
|
||||
const events = parser.parse("generic", {
|
||||
body: { eventType: "sent", messageId: "m", metadata: null },
|
||||
});
|
||||
expect(events[0]!.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("raises UNSUPPORTED_PROVIDER for an unknown provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parser = new WebhookEventParser();
|
||||
let captured: unknown;
|
||||
try {
|
||||
parser.parse("mystery", { body: {} });
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(DeliveryTrackingError);
|
||||
expect((captured as DeliveryTrackingError).code).toBe(
|
||||
DELIVERY_TRACKING_CODES.UNSUPPORTED_PROVIDER
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
} from "../../errors/delivery-tracking-error.js";
|
||||
import { getDeliveryTrackingInstruments } from "../../otel/instruments.js";
|
||||
import type {
|
||||
CreateDeliveryEventInput,
|
||||
DeliveryEventType,
|
||||
} from "../../types/delivery-tracking.types.js";
|
||||
|
||||
/** Raw webhook envelope from a provider. */
|
||||
export interface WebhookPayload {
|
||||
readonly body: Record<string, unknown> | Record<string, unknown>[];
|
||||
readonly headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Pluggable parser the registry calls to convert a payload into events. */
|
||||
export interface ProviderParser {
|
||||
parse(payload: WebhookPayload): CreateDeliveryEventInput[];
|
||||
}
|
||||
|
||||
type EventMapping = Record<string, DeliveryEventType>;
|
||||
|
||||
const SENDGRID_EVENTS: EventMapping = {
|
||||
processed: "sent",
|
||||
delivered: "delivered",
|
||||
open: "opened",
|
||||
click: "clicked",
|
||||
bounce: "bounced",
|
||||
unsubscribe: "unsubscribed",
|
||||
spamreport: "complained",
|
||||
deferred: "deferred",
|
||||
dropped: "dropped",
|
||||
};
|
||||
|
||||
const MAILGUN_EVENTS: EventMapping = {
|
||||
accepted: "sent",
|
||||
delivered: "delivered",
|
||||
opened: "opened",
|
||||
clicked: "clicked",
|
||||
permanent_fail: "bounced",
|
||||
temporary_fail: "deferred",
|
||||
unsubscribed: "unsubscribed",
|
||||
complained: "complained",
|
||||
};
|
||||
|
||||
const SES_EVENTS: EventMapping = {
|
||||
Send: "sent",
|
||||
Delivery: "delivered",
|
||||
Open: "opened",
|
||||
Click: "clicked",
|
||||
Bounce: "bounced",
|
||||
Complaint: "complained",
|
||||
Reject: "dropped",
|
||||
};
|
||||
|
||||
function extractString(obj: Record<string, unknown>, key: string): string {
|
||||
const value = obj[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function parseSendgrid(payload: WebhookPayload): CreateDeliveryEventInput[] {
|
||||
const events = Array.isArray(payload.body) ? payload.body : [payload.body];
|
||||
const out: CreateDeliveryEventInput[] = [];
|
||||
for (const evt of events) {
|
||||
const eventName = extractString(evt, "event");
|
||||
const eventType = SENDGRID_EVENTS[eventName];
|
||||
if (eventType === undefined) continue;
|
||||
out.push({
|
||||
tenantId: extractString(evt, "tenant_id") || "default",
|
||||
campaignId: extractString(evt, "campaign_id") || undefined,
|
||||
contactId:
|
||||
extractString(evt, "contact_id") || extractString(evt, "email"),
|
||||
channel: "email",
|
||||
eventType,
|
||||
messageId:
|
||||
extractString(evt, "sg_message_id") ||
|
||||
extractString(evt, "smtp-id"),
|
||||
metadata: { provider: "sendgrid", raw: evt },
|
||||
ip: extractString(evt, "ip") || undefined,
|
||||
userAgent: extractString(evt, "useragent") || undefined,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseMailgun(payload: WebhookPayload): CreateDeliveryEventInput[] {
|
||||
const root = Array.isArray(payload.body) ? payload.body[0] : payload.body;
|
||||
if (root === undefined) return [];
|
||||
const eventData = asObject(root["event-data"] ?? root);
|
||||
const eventName = extractString(eventData, "event");
|
||||
const eventType = MAILGUN_EVENTS[eventName];
|
||||
if (eventType === undefined) return [];
|
||||
const message = asObject(eventData["message"]);
|
||||
const headers = asObject(message["headers"]);
|
||||
return [
|
||||
{
|
||||
tenantId: extractString(eventData, "tenant_id") || "default",
|
||||
campaignId: extractString(eventData, "campaign_id") || undefined,
|
||||
contactId: extractString(eventData, "recipient"),
|
||||
channel: "email",
|
||||
eventType,
|
||||
messageId:
|
||||
extractString(headers, "message-id") ||
|
||||
extractString(eventData, "id"),
|
||||
metadata: { provider: "mailgun", raw: eventData },
|
||||
ip: extractString(eventData, "ip") || undefined,
|
||||
userAgent: extractString(eventData, "user-agent") || undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function parseSes(payload: WebhookPayload): CreateDeliveryEventInput[] {
|
||||
const root = Array.isArray(payload.body) ? payload.body[0] : payload.body;
|
||||
if (root === undefined) return [];
|
||||
const messageEnvelope = root["Message"];
|
||||
const message =
|
||||
typeof messageEnvelope === "string"
|
||||
? (JSON.parse(messageEnvelope) as Record<string, unknown>)
|
||||
: root;
|
||||
const notificationType =
|
||||
extractString(message, "notificationType") ||
|
||||
extractString(message, "eventType");
|
||||
const eventType = SES_EVENTS[notificationType];
|
||||
if (eventType === undefined) return [];
|
||||
const mail = asObject(message["mail"]);
|
||||
const destination = Array.isArray(mail["destination"])
|
||||
? (mail["destination"] as string[])
|
||||
: [];
|
||||
return [
|
||||
{
|
||||
tenantId: extractString(mail, "tenant_id") || "default",
|
||||
campaignId: extractString(mail, "campaign_id") || undefined,
|
||||
contactId: destination[0] ?? "",
|
||||
channel: "email",
|
||||
eventType,
|
||||
messageId: extractString(mail, "messageId"),
|
||||
metadata: { provider: "ses", raw: message },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function parseGeneric(payload: WebhookPayload): CreateDeliveryEventInput[] {
|
||||
const root = Array.isArray(payload.body) ? payload.body[0] : payload.body;
|
||||
if (root === undefined) return [];
|
||||
const rawEventType = extractString(root, "eventType");
|
||||
if (rawEventType === "") return [];
|
||||
return [
|
||||
{
|
||||
tenantId: extractString(root, "tenantId") || "default",
|
||||
campaignId: extractString(root, "campaignId") || undefined,
|
||||
contactId: extractString(root, "contactId"),
|
||||
channel: extractString(root, "channel") || "unknown",
|
||||
eventType: rawEventType as DeliveryEventType,
|
||||
messageId: extractString(root, "messageId"),
|
||||
metadata: asObject(root["metadata"]),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Built-in webhook provider names. Extensible via `registerProvider`. */
|
||||
export type BuiltinWebhookProvider = "sendgrid" | "mailgun" | "ses" | "generic";
|
||||
|
||||
/**
|
||||
* Pluggable parser for provider webhooks.
|
||||
*
|
||||
* Built-in providers cover SendGrid, Mailgun, SES, and a generic shape;
|
||||
* custom providers can be registered at construction time or via
|
||||
* `registerProvider`. Unknown providers raise
|
||||
* `DeliveryTrackingError(UNSUPPORTED_PROVIDER)`.
|
||||
*/
|
||||
export class WebhookEventParser {
|
||||
private readonly parsers: Map<
|
||||
string,
|
||||
(p: WebhookPayload) => CreateDeliveryEventInput[]
|
||||
>;
|
||||
|
||||
public constructor(custom: Readonly<Record<string, ProviderParser>> = {}) {
|
||||
this.parsers = new Map();
|
||||
this.parsers.set("sendgrid", parseSendgrid);
|
||||
this.parsers.set("mailgun", parseMailgun);
|
||||
this.parsers.set("ses", parseSes);
|
||||
this.parsers.set("generic", parseGeneric);
|
||||
for (const [name, parser] of Object.entries(custom)) {
|
||||
this.parsers.set(name, (payload) => parser.parse(payload));
|
||||
}
|
||||
}
|
||||
|
||||
/** Register an additional provider parser at runtime. */
|
||||
public registerProvider(name: string, parser: ProviderParser): void {
|
||||
this.parsers.set(name, (payload) => parser.parse(payload));
|
||||
}
|
||||
|
||||
/** Parse `payload` using the parser registered under `provider`. */
|
||||
public parse(
|
||||
provider: string,
|
||||
payload: WebhookPayload
|
||||
): CreateDeliveryEventInput[] {
|
||||
const parser = this.parsers.get(provider);
|
||||
if (parser === undefined) {
|
||||
throw new DeliveryTrackingError(
|
||||
DELIVERY_TRACKING_CODES.UNSUPPORTED_PROVIDER,
|
||||
"error.delivery_tracking.unsupported_provider",
|
||||
{ context: { provider } }
|
||||
);
|
||||
}
|
||||
const events = parser(payload);
|
||||
getDeliveryTrackingInstruments().webhookParsedCounter.add(
|
||||
events.length,
|
||||
{ provider }
|
||||
);
|
||||
return events;
|
||||
}
|
||||
}
|
||||
@@ -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,52 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
} from "./delivery-tracking-error.js";
|
||||
|
||||
describe("DELIVERY_TRACKING_CODES", () => {
|
||||
it("registers the full set of stable codes", { timeout: 5000 }, () => {
|
||||
expect(DELIVERY_TRACKING_CODES.INVALID_INPUT).toBe(
|
||||
"DELIVERY_TRACKING.INVALID_INPUT"
|
||||
);
|
||||
expect(DELIVERY_TRACKING_CODES.INVALID_TOKEN).toBe(
|
||||
"DELIVERY_TRACKING.INVALID_TOKEN"
|
||||
);
|
||||
expect(DELIVERY_TRACKING_CODES.UNSUPPORTED_PROVIDER).toBe(
|
||||
"DELIVERY_TRACKING.UNSUPPORTED_PROVIDER"
|
||||
);
|
||||
expect(DELIVERY_TRACKING_CODES.STORE_FAILURE).toBe(
|
||||
"DELIVERY_TRACKING.STORE_FAILURE"
|
||||
);
|
||||
expect(DELIVERY_TRACKING_CODES.QUERY_FAILURE).toBe(
|
||||
"DELIVERY_TRACKING.QUERY_FAILURE"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeliveryTrackingError", () => {
|
||||
it("constructs with code + message key and exposes the discriminant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new DeliveryTrackingError(
|
||||
DELIVERY_TRACKING_CODES.INVALID_INPUT,
|
||||
"error.delivery_tracking.invalid_input"
|
||||
);
|
||||
expect(err).toBeInstanceOf(DeliveryTrackingError);
|
||||
expect(err.name).toBe("DeliveryTrackingError");
|
||||
expect(err.code).toBe("DELIVERY_TRACKING.INVALID_INPUT");
|
||||
expect(err.message).toContain("error.delivery_tracking.invalid_input");
|
||||
});
|
||||
|
||||
it("preserves cause chain when wrapping a lower-level error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("driver-level failure");
|
||||
const err = new DeliveryTrackingError(
|
||||
DELIVERY_TRACKING_CODES.STORE_FAILURE,
|
||||
"error.delivery_tracking.store_failure",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/delivery-tracking`.
|
||||
*
|
||||
* Every code is thrown by at least one production code path. Removing
|
||||
* an entry without removing its call site is forbidden by §2c of
|
||||
* `openspec/AGENTS.md`.
|
||||
*/
|
||||
export const DELIVERY_TRACKING_CODES = defineCodes("DELIVERY_TRACKING", [
|
||||
"INVALID_INPUT",
|
||||
"INVALID_TOKEN",
|
||||
"UNSUPPORTED_PROVIDER",
|
||||
"STORE_FAILURE",
|
||||
"QUERY_FAILURE",
|
||||
] as const);
|
||||
|
||||
export type DeliveryTrackingCode =
|
||||
(typeof DELIVERY_TRACKING_CODES)[keyof typeof DELIVERY_TRACKING_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for every failure raised by this package.
|
||||
*
|
||||
* Extends `SuaveplanError` with a discriminant `code` drawn from
|
||||
* `DELIVERY_TRACKING_CODES` so callers can branch on `instanceof` +
|
||||
* code without inspecting the `.message` string (which is i18n-keyed).
|
||||
*/
|
||||
export class DeliveryTrackingError extends SuaveplanError<DeliveryTrackingCode> {
|
||||
public constructor(
|
||||
code: DeliveryTrackingCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "DeliveryTrackingError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
type DeliveryTrackingCode,
|
||||
DeliveryTrackingError,
|
||||
} from "./delivery-tracking-error.js";
|
||||
53
packages/communication/emails/delivery-tracking/src/index.ts
Normal file
53
packages/communication/emails/delivery-tracking/src/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export {
|
||||
DeliveryAggregator,
|
||||
type TimeInterval,
|
||||
} from "./core/aggregator/aggregator.js";
|
||||
export {
|
||||
DeliveryTracker,
|
||||
type DeliveryTrackerOptions,
|
||||
} from "./core/tracker/tracker.js";
|
||||
export {
|
||||
type CreateDeliveryEventInputSchema,
|
||||
createDeliveryEventInputSchema,
|
||||
deliveryEventTypeSchema,
|
||||
} from "./core/tracker/tracker.schemas.js";
|
||||
export { TrackingLinkWrapper } from "./core/tracking-link/tracking-link.js";
|
||||
export {
|
||||
decodeToken,
|
||||
encodeToken,
|
||||
} from "./core/tracking-pixel/token-codec.js";
|
||||
export { TrackingPixelGenerator } from "./core/tracking-pixel/tracking-pixel.js";
|
||||
export {
|
||||
type BuiltinWebhookProvider,
|
||||
type ProviderParser,
|
||||
WebhookEventParser,
|
||||
type WebhookPayload,
|
||||
} from "./core/webhook-parser/webhook-parser.js";
|
||||
export {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
type DeliveryTrackingCode,
|
||||
DeliveryTrackingError,
|
||||
} from "./errors/delivery-tracking-error.js";
|
||||
export {
|
||||
type DeliveryTrackingInstruments,
|
||||
getDeliveryTrackingInstruments,
|
||||
resetDeliveryTrackingInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
export {
|
||||
computeStats,
|
||||
type DeliveryEventStore,
|
||||
InMemoryDeliveryEventStore,
|
||||
} from "./stores/memory/memory-store.js";
|
||||
export { SurrealDeliveryEventStore } from "./stores/surreal/surreal-store.js";
|
||||
export type {
|
||||
ClickTrackingTokenPayload,
|
||||
CreateDeliveryEventInput,
|
||||
DeliveryEvent,
|
||||
DeliveryEventQuery,
|
||||
DeliveryEventType,
|
||||
DeliveryStats,
|
||||
DeliveryTrackerEventMap,
|
||||
TimeSeriesBucket,
|
||||
TrackingConfig,
|
||||
TrackingTokenPayload,
|
||||
} from "./types/delivery-tracking.types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type DeliveryTrackingInstruments,
|
||||
getDeliveryTrackingInstruments,
|
||||
resetDeliveryTrackingInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -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,34 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getDeliveryTrackingInstruments,
|
||||
resetDeliveryTrackingInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
describe("delivery-tracking instruments", () => {
|
||||
beforeEach(() => {
|
||||
resetDeliveryTrackingInstruments();
|
||||
});
|
||||
|
||||
it("constructs the instrument set lazily and caches it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getDeliveryTrackingInstruments();
|
||||
const b = getDeliveryTrackingInstruments();
|
||||
expect(a).toBe(b);
|
||||
expect(a.meter).toBeDefined();
|
||||
expect(a.trackedCounter).toBeDefined();
|
||||
expect(a.dedupedCounter).toBeDefined();
|
||||
expect(a.webhookParsedCounter).toBeDefined();
|
||||
expect(a.tokenIssuedCounter).toBeDefined();
|
||||
expect(a.tokenRejectedCounter).toBeDefined();
|
||||
});
|
||||
|
||||
it("reset clears the cache so the next call rebinds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getDeliveryTrackingInstruments();
|
||||
resetDeliveryTrackingInstruments();
|
||||
const b = getDeliveryTrackingInstruments();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Counter, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Lazily-constructed, shared OTel instruments for delivery tracking.
|
||||
*
|
||||
* Names are stable and asserted by the package's spec-conformance test
|
||||
* via a real `InMemoryMetricExporter`/`MeterProvider` (no mocks).
|
||||
*/
|
||||
export interface DeliveryTrackingInstruments {
|
||||
readonly meter: Meter;
|
||||
readonly trackedCounter: Counter;
|
||||
readonly dedupedCounter: Counter;
|
||||
readonly webhookParsedCounter: Counter;
|
||||
readonly tokenIssuedCounter: Counter;
|
||||
readonly tokenRejectedCounter: Counter;
|
||||
}
|
||||
|
||||
let cached: DeliveryTrackingInstruments | undefined;
|
||||
|
||||
/** Returns the shared instrument set, constructing it on first call. */
|
||||
export function getDeliveryTrackingInstruments(): DeliveryTrackingInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/delivery-tracking");
|
||||
cached = {
|
||||
meter,
|
||||
trackedCounter: meter.createCounter("delivery_tracking.tracked.total", {
|
||||
description:
|
||||
"Number of delivery events recorded by DeliveryTracker.",
|
||||
}),
|
||||
dedupedCounter: meter.createCounter("delivery_tracking.deduped.total", {
|
||||
description:
|
||||
"Number of inbound events suppressed by lifecycle dedupe.",
|
||||
}),
|
||||
webhookParsedCounter: meter.createCounter(
|
||||
"delivery_tracking.webhook_parsed.total",
|
||||
{
|
||||
description:
|
||||
"Number of webhook payloads parsed (attributed by provider).",
|
||||
}
|
||||
),
|
||||
tokenIssuedCounter: meter.createCounter(
|
||||
"delivery_tracking.token_issued.total",
|
||||
{
|
||||
description:
|
||||
"Number of pixel/link tokens issued (attributed by kind).",
|
||||
}
|
||||
),
|
||||
tokenRejectedCounter: meter.createCounter(
|
||||
"delivery_tracking.token_rejected.total",
|
||||
{
|
||||
description:
|
||||
"Number of pixel/link tokens that failed to parse.",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Drops the cached instruments so the next call rebinds them. Test-only. */
|
||||
export function resetDeliveryTrackingInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
computeStats,
|
||||
type DeliveryEventStore,
|
||||
InMemoryDeliveryEventStore,
|
||||
} from "./memory-store.js";
|
||||
@@ -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,127 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
DeliveryEvent,
|
||||
DeliveryEventType,
|
||||
} from "../../types/delivery-tracking.types.js";
|
||||
import { computeStats, InMemoryDeliveryEventStore } from "./memory-store.js";
|
||||
|
||||
function evt(
|
||||
over: Partial<DeliveryEvent> & { eventType: DeliveryEventType }
|
||||
): DeliveryEvent {
|
||||
return {
|
||||
id: over.id ?? `evt-${Math.random()}`,
|
||||
tenantId: over.tenantId ?? "t1",
|
||||
campaignId: over.campaignId ?? "c1",
|
||||
contactId: over.contactId ?? "u1",
|
||||
channel: over.channel ?? "email",
|
||||
eventType: over.eventType,
|
||||
messageId: over.messageId ?? "m1",
|
||||
metadata: over.metadata ?? {},
|
||||
timestamp: over.timestamp ?? new Date("2026-01-01T00:00:00Z"),
|
||||
ip: over.ip,
|
||||
userAgent: over.userAgent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeStats", () => {
|
||||
it("returns zero stats for an empty event list", { timeout: 5000 }, () => {
|
||||
const stats = computeStats([]);
|
||||
expect(stats.sent).toBe(0);
|
||||
expect(stats.deliveryRate).toBe(0);
|
||||
expect(stats.openRate).toBe(0);
|
||||
});
|
||||
|
||||
it("computes correct counts and rates over a mixed event list", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const stats = computeStats([
|
||||
evt({ eventType: "sent" }),
|
||||
evt({ eventType: "sent" }),
|
||||
evt({ eventType: "delivered" }),
|
||||
evt({ eventType: "opened" }),
|
||||
evt({ eventType: "clicked" }),
|
||||
evt({ eventType: "bounced" }),
|
||||
evt({ eventType: "unsubscribed" }),
|
||||
evt({ eventType: "complained" }),
|
||||
]);
|
||||
expect(stats.sent).toBe(2);
|
||||
expect(stats.delivered).toBe(1);
|
||||
expect(stats.deliveryRate).toBe(50);
|
||||
expect(stats.bounceRate).toBe(50);
|
||||
expect(stats.openRate).toBe(100);
|
||||
expect(stats.clickRate).toBe(100);
|
||||
expect(stats.unsubscribeRate).toBe(100);
|
||||
expect(stats.complainRate).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryDeliveryEventStore", () => {
|
||||
it("records and fetches by id", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
const e = evt({ id: "x1", eventType: "sent" });
|
||||
await store.record(e);
|
||||
expect(await store.getById("x1")).toEqual(e);
|
||||
expect(await store.getById("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns events for a campaign and supports filters + paging", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
await store.record(evt({ eventType: "sent", channel: "email" }));
|
||||
await store.record(evt({ eventType: "opened", channel: "email" }));
|
||||
await store.record(evt({ eventType: "sent", channel: "sms" }));
|
||||
await store.record(
|
||||
evt({
|
||||
eventType: "sent",
|
||||
campaignId: "other",
|
||||
})
|
||||
);
|
||||
|
||||
expect(await store.getByCampaign("c1")).toHaveLength(3);
|
||||
expect(
|
||||
await store.getByCampaign("c1", { eventType: "sent" })
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
await store.getByCampaign("c1", { channel: "sms" })
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
await store.getByCampaign("c1", {
|
||||
from: new Date("2025-01-01"),
|
||||
to: new Date("2027-01-01"),
|
||||
})
|
||||
).toHaveLength(3);
|
||||
expect(
|
||||
await store.getByCampaign("c1", { limit: 1, offset: 1 })
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("filters by contact and message id", { timeout: 5000 }, async () => {
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
await store.record(evt({ contactId: "u2", eventType: "sent" }));
|
||||
await store.record(evt({ contactId: "u2", eventType: "opened" }));
|
||||
await store.record(evt({ contactId: "u3", eventType: "sent" }));
|
||||
expect(await store.getByContact("u2")).toHaveLength(2);
|
||||
expect(
|
||||
await store.getByContact("u2", { eventType: "opened" })
|
||||
).toHaveLength(1);
|
||||
await store.record(
|
||||
evt({ messageId: "m-target", eventType: "delivered" })
|
||||
);
|
||||
expect(await store.getByMessageId("m-target")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("counts by type and reports existence + stats", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new InMemoryDeliveryEventStore();
|
||||
await store.record(evt({ eventType: "sent", messageId: "ma" }));
|
||||
await store.record(evt({ eventType: "opened", messageId: "ma" }));
|
||||
expect(await store.countByType("c1", "sent")).toBe(1);
|
||||
expect(await store.exists("ma", "opened")).toBe(true);
|
||||
expect(await store.exists("ma", "bounced")).toBe(false);
|
||||
const stats = await store.getStatsByCampaign("c1");
|
||||
expect(stats.sent).toBe(1);
|
||||
expect(stats.opened).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
DeliveryEvent,
|
||||
DeliveryEventQuery,
|
||||
DeliveryEventType,
|
||||
DeliveryStats,
|
||||
} from "../../types/delivery-tracking.types.js";
|
||||
|
||||
/**
|
||||
* Storage contract every delivery-event sink must satisfy.
|
||||
*
|
||||
* Implementations are responsible for durability, transactionality, and
|
||||
* read-consistency guarantees — `DeliveryTracker` and `DeliveryAggregator`
|
||||
* only depend on this interface, never on a concrete store.
|
||||
*/
|
||||
export interface DeliveryEventStore {
|
||||
record(event: DeliveryEvent): Promise<void>;
|
||||
getById(id: string): Promise<DeliveryEvent | undefined>;
|
||||
getByCampaign(
|
||||
campaignId: string,
|
||||
query?: DeliveryEventQuery
|
||||
): Promise<DeliveryEvent[]>;
|
||||
getByContact(
|
||||
contactId: string,
|
||||
query?: DeliveryEventQuery
|
||||
): Promise<DeliveryEvent[]>;
|
||||
getByMessageId(messageId: string): Promise<DeliveryEvent[]>;
|
||||
countByType(
|
||||
campaignId: string,
|
||||
eventType: DeliveryEventType
|
||||
): Promise<number>;
|
||||
getStatsByCampaign(campaignId: string): Promise<DeliveryStats>;
|
||||
exists(messageId: string, eventType: DeliveryEventType): Promise<boolean>;
|
||||
}
|
||||
|
||||
function applyQuery(
|
||||
events: DeliveryEvent[],
|
||||
query?: DeliveryEventQuery
|
||||
): DeliveryEvent[] {
|
||||
let filtered = events;
|
||||
if (query?.eventType !== undefined) {
|
||||
filtered = filtered.filter((e) => e.eventType === query.eventType);
|
||||
}
|
||||
if (query?.channel !== undefined) {
|
||||
filtered = filtered.filter((e) => e.channel === query.channel);
|
||||
}
|
||||
if (query?.from !== undefined) {
|
||||
const from = query.from;
|
||||
filtered = filtered.filter((e) => e.timestamp >= from);
|
||||
}
|
||||
if (query?.to !== undefined) {
|
||||
const to = query.to;
|
||||
filtered = filtered.filter((e) => e.timestamp <= to);
|
||||
}
|
||||
const offset = query?.offset ?? 0;
|
||||
const limit = query?.limit ?? filtered.length;
|
||||
return filtered.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute campaign-level stats from a list of events.
|
||||
*
|
||||
* Pure function — used by both the in-memory store and the SurrealDB
|
||||
* store after the SurQL query returns its row set.
|
||||
*/
|
||||
export function computeStats(events: DeliveryEvent[]): DeliveryStats {
|
||||
const count = (type: DeliveryEventType): number =>
|
||||
events.filter((e) => e.eventType === type).length;
|
||||
|
||||
const sent = count("sent");
|
||||
const delivered = count("delivered");
|
||||
const opened = count("opened");
|
||||
const clicked = count("clicked");
|
||||
const bounced = count("bounced");
|
||||
const unsubscribed = count("unsubscribed");
|
||||
const complained = count("complained");
|
||||
|
||||
const rate = (n: number, d: number): number =>
|
||||
d === 0 ? 0 : Number(((n / d) * 100).toFixed(2));
|
||||
|
||||
return {
|
||||
sent,
|
||||
delivered,
|
||||
deliveryRate: rate(delivered, sent),
|
||||
opened,
|
||||
openRate: rate(opened, delivered),
|
||||
clicked,
|
||||
clickRate: rate(clicked, delivered),
|
||||
bounced,
|
||||
bounceRate: rate(bounced, sent),
|
||||
unsubscribed,
|
||||
unsubscribeRate: rate(unsubscribed, delivered),
|
||||
complained,
|
||||
complainRate: rate(complained, delivered),
|
||||
};
|
||||
}
|
||||
|
||||
/** Deterministic in-memory store — ideal for tests and development. */
|
||||
export class InMemoryDeliveryEventStore implements DeliveryEventStore {
|
||||
private readonly events: DeliveryEvent[] = [];
|
||||
|
||||
public async record(event: DeliveryEvent): Promise<void> {
|
||||
this.events.push(event);
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<DeliveryEvent | undefined> {
|
||||
return this.events.find((e) => e.id === id);
|
||||
}
|
||||
|
||||
public async getByCampaign(
|
||||
campaignId: string,
|
||||
query?: DeliveryEventQuery
|
||||
): Promise<DeliveryEvent[]> {
|
||||
return applyQuery(
|
||||
this.events.filter((e) => e.campaignId === campaignId),
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
public async getByContact(
|
||||
contactId: string,
|
||||
query?: DeliveryEventQuery
|
||||
): Promise<DeliveryEvent[]> {
|
||||
return applyQuery(
|
||||
this.events.filter((e) => e.contactId === contactId),
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
public async getByMessageId(messageId: string): Promise<DeliveryEvent[]> {
|
||||
return this.events.filter((e) => e.messageId === messageId);
|
||||
}
|
||||
|
||||
public async countByType(
|
||||
campaignId: string,
|
||||
eventType: DeliveryEventType
|
||||
): Promise<number> {
|
||||
return this.events.filter(
|
||||
(e) => e.campaignId === campaignId && e.eventType === eventType
|
||||
).length;
|
||||
}
|
||||
|
||||
public async getStatsByCampaign(
|
||||
campaignId: string
|
||||
): Promise<DeliveryStats> {
|
||||
return computeStats(
|
||||
this.events.filter((e) => e.campaignId === campaignId)
|
||||
);
|
||||
}
|
||||
|
||||
public async exists(
|
||||
messageId: string,
|
||||
eventType: DeliveryEventType
|
||||
): Promise<boolean> {
|
||||
return this.events.some(
|
||||
(e) => e.messageId === messageId && e.eventType === eventType
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SurrealDeliveryEventStore } from "./surreal-store.js";
|
||||
@@ -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,305 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
} from "../../errors/delivery-tracking-error.js";
|
||||
import type { DeliveryEvent } from "../../types/delivery-tracking.types.js";
|
||||
import { SurrealDeliveryEventStore } from "./surreal-store.js";
|
||||
|
||||
type QueryFn = (sql: string, params?: readonly unknown[]) => Promise<unknown>;
|
||||
|
||||
function stub(queryFn: QueryFn): import("@suaveplan/surrealdb").SurrealAdapter {
|
||||
return {
|
||||
query: queryFn,
|
||||
} as unknown as import("@suaveplan/surrealdb").SurrealAdapter;
|
||||
}
|
||||
|
||||
function event(over: Partial<DeliveryEvent> = {}): DeliveryEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
tenantId: "t1",
|
||||
campaignId: "c1",
|
||||
contactId: "u1",
|
||||
channel: "email",
|
||||
eventType: "sent",
|
||||
messageId: "m1",
|
||||
metadata: {},
|
||||
timestamp: new Date("2026-01-01T00:00:00Z"),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function row(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: "evt-1",
|
||||
tenant_id: "t1",
|
||||
campaign_id: "c1",
|
||||
contact_id: "u1",
|
||||
channel: "email",
|
||||
event_type: "sent",
|
||||
message_id: "m1",
|
||||
metadata: {},
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
ip: null,
|
||||
user_agent: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SurrealDeliveryEventStore.record", () => {
|
||||
it("emits a positional INSERT with mapped fields", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { sql: string; params: readonly unknown[] }[] = [];
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async (sql, params) => {
|
||||
calls.push({ sql, params: params ?? [] });
|
||||
return [];
|
||||
})
|
||||
);
|
||||
await store.record(event({ ip: "1.2.3.4", userAgent: "ua/1" }));
|
||||
expect(calls[0]!.sql).toContain("INSERT INTO delivery_events");
|
||||
// positional params: id, tenantId, campaignId, contactId, channel,
|
||||
// eventType, messageId, metadata, timestamp, ip, userAgent
|
||||
expect(calls[0]!.params[1]).toBe("t1");
|
||||
expect(calls[0]!.params[9]).toBe("1.2.3.4");
|
||||
expect(calls[0]!.params[10]).toBe("ua/1");
|
||||
});
|
||||
|
||||
it("maps undefined optional fields to null in the positional params", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { params: readonly unknown[] }[] = [];
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async (_sql, params) => {
|
||||
calls.push({ params: params ?? [] });
|
||||
return [];
|
||||
})
|
||||
);
|
||||
await store.record(event({ campaignId: undefined, ip: undefined }));
|
||||
expect(calls[0]!.params[2]).toBeNull();
|
||||
expect(calls[0]!.params[9]).toBeNull();
|
||||
expect(calls[0]!.params[10]).toBeNull();
|
||||
});
|
||||
|
||||
it("wraps adapter errors in DeliveryTrackingError(STORE_FAILURE)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async () => {
|
||||
throw new Error("driver-down");
|
||||
})
|
||||
);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.record(event());
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(DeliveryTrackingError);
|
||||
expect((captured as DeliveryTrackingError).code).toBe(
|
||||
DELIVERY_TRACKING_CODES.STORE_FAILURE
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SurrealDeliveryEventStore queries", () => {
|
||||
it("getById returns undefined for an empty result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new SurrealDeliveryEventStore(stub(async () => []));
|
||||
expect(await store.getById("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getById maps a row and includes the WHERE clause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async (sql) => {
|
||||
calls.push(sql);
|
||||
return [row()];
|
||||
})
|
||||
);
|
||||
const result = await store.getById("evt-1");
|
||||
expect(result?.id).toBe("evt-1");
|
||||
expect(result?.timestamp).toBeInstanceOf(Date);
|
||||
expect(calls[0]).toContain("WHERE id = $p0");
|
||||
});
|
||||
|
||||
it("preserves a Date timestamp passed through unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ts = new Date("2026-06-01T12:00:00.000Z");
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async () => [row({ timestamp: ts })])
|
||||
);
|
||||
const r = await store.getById("evt-1");
|
||||
expect(r?.timestamp).toEqual(ts);
|
||||
});
|
||||
|
||||
it("returns ip and userAgent when present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async () => [row({ ip: "10.0.0.1", user_agent: "curl/8" })])
|
||||
);
|
||||
const r = await store.getById("evt-1");
|
||||
expect(r?.ip).toBe("10.0.0.1");
|
||||
expect(r?.userAgent).toBe("curl/8");
|
||||
});
|
||||
|
||||
it("getByCampaign threads filters + paging into the SQL", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async (sql) => {
|
||||
calls.push(sql);
|
||||
return [];
|
||||
})
|
||||
);
|
||||
await store.getByCampaign("c1", {
|
||||
eventType: "opened",
|
||||
channel: "email",
|
||||
from: new Date("2026-01-01"),
|
||||
to: new Date("2026-02-01"),
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
});
|
||||
expect(calls[0]).toContain("event_type = $p1");
|
||||
expect(calls[0]).toContain("channel = $p2");
|
||||
expect(calls[0]).toContain("timestamp >= $p3");
|
||||
expect(calls[0]).toContain("timestamp <= $p4");
|
||||
expect(calls[0]).toContain("LIMIT 10");
|
||||
expect(calls[0]).toContain("START 20");
|
||||
});
|
||||
|
||||
it("getByContact passes contactId and returns mapped rows", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { sql: string; params: readonly unknown[] }[] = [];
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async (sql, params) => {
|
||||
calls.push({ sql, params: params ?? [] });
|
||||
return [row()];
|
||||
})
|
||||
);
|
||||
const r = await store.getByContact("u1");
|
||||
expect(r).toHaveLength(1);
|
||||
expect(calls[0]!.sql).toContain("contact_id = $p0");
|
||||
expect(calls[0]!.params[0]).toBe("u1");
|
||||
});
|
||||
|
||||
it("getByMessageId scopes by message_id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: { sql: string; params: readonly unknown[] }[] = [];
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async (sql, params) => {
|
||||
calls.push({ sql, params: params ?? [] });
|
||||
return [row()];
|
||||
})
|
||||
);
|
||||
await store.getByMessageId("m1");
|
||||
expect(calls[0]!.sql).toContain("message_id = $p0");
|
||||
expect(calls[0]!.params[0]).toBe("m1");
|
||||
});
|
||||
|
||||
it("countByType returns the GROUP ALL count or zero", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = new SurrealDeliveryEventStore(
|
||||
stub(async () => [{ count: 7 }])
|
||||
);
|
||||
expect(await a.countByType("c1", "opened")).toBe(7);
|
||||
const b = new SurrealDeliveryEventStore(stub(async () => []));
|
||||
expect(await b.countByType("c1", "opened")).toBe(0);
|
||||
});
|
||||
|
||||
it("exists derives true / false from the GROUP ALL count", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = new SurrealDeliveryEventStore(
|
||||
stub(async () => [{ count: 1 }])
|
||||
);
|
||||
expect(await a.exists("m1", "sent")).toBe(true);
|
||||
const b = new SurrealDeliveryEventStore(
|
||||
stub(async () => [{ count: 0 }])
|
||||
);
|
||||
expect(await b.exists("m1", "sent")).toBe(false);
|
||||
const c = new SurrealDeliveryEventStore(stub(async () => []));
|
||||
expect(await c.exists("m1", "sent")).toBe(false);
|
||||
});
|
||||
|
||||
it("getStatsByCampaign aggregates rows via computeStats", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const rows = [
|
||||
row({ event_type: "sent" }),
|
||||
row({ event_type: "delivered" }),
|
||||
row({ event_type: "opened" }),
|
||||
];
|
||||
const store = new SurrealDeliveryEventStore(stub(async () => rows));
|
||||
const stats = await store.getStatsByCampaign("c1");
|
||||
expect(stats.sent).toBe(1);
|
||||
expect(stats.delivered).toBe(1);
|
||||
expect(stats.opened).toBe(1);
|
||||
expect(stats.deliveryRate).toBe(100);
|
||||
});
|
||||
|
||||
it("wraps adapter errors thrown during select in QUERY_FAILURE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async () => {
|
||||
throw new Error("network");
|
||||
})
|
||||
);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.getByCampaign("c1");
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(DeliveryTrackingError);
|
||||
expect((captured as DeliveryTrackingError).code).toBe(
|
||||
DELIVERY_TRACKING_CODES.QUERY_FAILURE
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a custom table name when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async (sql) => {
|
||||
calls.push(sql);
|
||||
return [];
|
||||
}),
|
||||
"custom_events"
|
||||
);
|
||||
await store.getByCampaign("c1");
|
||||
expect(calls[0]).toContain("FROM custom_events");
|
||||
});
|
||||
|
||||
it("handles undefined metadata in row mapping", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async () => [row({ metadata: undefined })])
|
||||
);
|
||||
const r = await store.getById("evt-1");
|
||||
expect(r?.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("maps null campaign_id to undefined in row mapping", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = new SurrealDeliveryEventStore(
|
||||
stub(async () => [row({ campaign_id: null })])
|
||||
);
|
||||
const r = await store.getById("evt-1");
|
||||
expect(r?.campaignId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
DELIVERY_TRACKING_CODES,
|
||||
DeliveryTrackingError,
|
||||
} from "../../errors/delivery-tracking-error.js";
|
||||
import type {
|
||||
DeliveryEvent,
|
||||
DeliveryEventQuery,
|
||||
DeliveryEventType,
|
||||
DeliveryStats,
|
||||
} from "../../types/delivery-tracking.types.js";
|
||||
import {
|
||||
computeStats,
|
||||
type DeliveryEventStore,
|
||||
} from "../memory/memory-store.js";
|
||||
|
||||
/** Row shape returned by SurrealDB for the `delivery_events` table. */
|
||||
interface DeliveryEventRow {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
campaign_id?: string | null;
|
||||
contact_id: string;
|
||||
channel: string;
|
||||
event_type: string;
|
||||
message_id: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
timestamp: Date | string;
|
||||
ip?: string | null;
|
||||
user_agent?: string | null;
|
||||
}
|
||||
|
||||
function rowToEvent(row: DeliveryEventRow): DeliveryEvent {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
campaignId: row.campaign_id ?? undefined,
|
||||
contactId: row.contact_id,
|
||||
channel: row.channel,
|
||||
eventType: row.event_type as DeliveryEventType,
|
||||
messageId: row.message_id,
|
||||
metadata: row.metadata ?? {},
|
||||
timestamp:
|
||||
row.timestamp instanceof Date
|
||||
? row.timestamp
|
||||
: new Date(row.timestamp),
|
||||
ip: row.ip ?? undefined,
|
||||
userAgent: row.user_agent ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
interface BuiltQuery {
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
}
|
||||
|
||||
function buildFilterQuery(
|
||||
table: string,
|
||||
baseClause: string,
|
||||
baseParams: unknown[],
|
||||
query?: DeliveryEventQuery
|
||||
): BuiltQuery {
|
||||
const params: unknown[] = [...baseParams];
|
||||
const conditions: string[] = [baseClause];
|
||||
|
||||
const pushClause = (template: string, value: unknown): void => {
|
||||
params.push(value);
|
||||
conditions.push(template.replace("$p", `$p${params.length - 1}`));
|
||||
};
|
||||
|
||||
if (query?.eventType !== undefined) {
|
||||
pushClause("event_type = $p", query.eventType);
|
||||
}
|
||||
if (query?.channel !== undefined) {
|
||||
pushClause("channel = $p", query.channel);
|
||||
}
|
||||
if (query?.from !== undefined) {
|
||||
pushClause("timestamp >= $p", query.from);
|
||||
}
|
||||
if (query?.to !== undefined) {
|
||||
pushClause("timestamp <= $p", query.to);
|
||||
}
|
||||
|
||||
let sql = `SELECT * FROM ${table} WHERE ${conditions.join(" AND ")}`;
|
||||
if (query?.limit !== undefined) sql += ` LIMIT ${query.limit}`;
|
||||
if (query?.offset !== undefined && query.offset > 0) {
|
||||
sql += ` START ${query.offset}`;
|
||||
}
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* SurrealDB-backed implementation of `DeliveryEventStore`.
|
||||
*
|
||||
* Uses positional SurQL via `SurrealAdapter.query(sql, params)` per the
|
||||
* genesis pattern established by `api-plugin-events` — no typed-CRUD
|
||||
* wrappers — keeping the I/O path predictable and the SQL auditable.
|
||||
* Throws `DeliveryTrackingError(STORE_FAILURE | QUERY_FAILURE)` so callers
|
||||
* can branch on a stable code instead of inspecting driver internals.
|
||||
*/
|
||||
export class SurrealDeliveryEventStore implements DeliveryEventStore {
|
||||
private readonly db: SurrealAdapter;
|
||||
private readonly table: string;
|
||||
|
||||
public constructor(db: SurrealAdapter, table = "delivery_events") {
|
||||
this.db = db;
|
||||
this.table = table;
|
||||
}
|
||||
|
||||
public async record(event: DeliveryEvent): Promise<void> {
|
||||
const params: unknown[] = [
|
||||
event.id,
|
||||
event.tenantId,
|
||||
event.campaignId ?? null,
|
||||
event.contactId,
|
||||
event.channel,
|
||||
event.eventType,
|
||||
event.messageId,
|
||||
event.metadata,
|
||||
event.timestamp,
|
||||
event.ip ?? null,
|
||||
event.userAgent ?? null,
|
||||
];
|
||||
const sql = `INSERT INTO ${this.table} {
|
||||
id: $p0,
|
||||
tenant_id: $p1,
|
||||
campaign_id: $p2,
|
||||
contact_id: $p3,
|
||||
channel: $p4,
|
||||
event_type: $p5,
|
||||
message_id: $p6,
|
||||
metadata: $p7,
|
||||
timestamp: $p8,
|
||||
ip: $p9,
|
||||
user_agent: $p10
|
||||
}`;
|
||||
try {
|
||||
await this.db.query<DeliveryEventRow>(sql, params);
|
||||
} catch (cause) {
|
||||
throw new DeliveryTrackingError(
|
||||
DELIVERY_TRACKING_CODES.STORE_FAILURE,
|
||||
"error.delivery_tracking.store_failure",
|
||||
{ cause, context: { op: "record", id: event.id } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<DeliveryEvent | undefined> {
|
||||
const rows = await this.runQuery<DeliveryEventRow>(
|
||||
`SELECT * FROM ${this.table} WHERE id = $p0 LIMIT 1`,
|
||||
[id],
|
||||
"getById"
|
||||
);
|
||||
return rows[0] !== undefined ? rowToEvent(rows[0]) : undefined;
|
||||
}
|
||||
|
||||
public async getByCampaign(
|
||||
campaignId: string,
|
||||
query?: DeliveryEventQuery
|
||||
): Promise<DeliveryEvent[]> {
|
||||
const built = buildFilterQuery(
|
||||
this.table,
|
||||
"campaign_id = $p0",
|
||||
[campaignId],
|
||||
query
|
||||
);
|
||||
const rows = await this.runQuery<DeliveryEventRow>(
|
||||
built.sql,
|
||||
built.params,
|
||||
"getByCampaign"
|
||||
);
|
||||
return rows.map(rowToEvent);
|
||||
}
|
||||
|
||||
public async getByContact(
|
||||
contactId: string,
|
||||
query?: DeliveryEventQuery
|
||||
): Promise<DeliveryEvent[]> {
|
||||
const built = buildFilterQuery(
|
||||
this.table,
|
||||
"contact_id = $p0",
|
||||
[contactId],
|
||||
query
|
||||
);
|
||||
const rows = await this.runQuery<DeliveryEventRow>(
|
||||
built.sql,
|
||||
built.params,
|
||||
"getByContact"
|
||||
);
|
||||
return rows.map(rowToEvent);
|
||||
}
|
||||
|
||||
public async getByMessageId(messageId: string): Promise<DeliveryEvent[]> {
|
||||
const rows = await this.runQuery<DeliveryEventRow>(
|
||||
`SELECT * FROM ${this.table} WHERE message_id = $p0`,
|
||||
[messageId],
|
||||
"getByMessageId"
|
||||
);
|
||||
return rows.map(rowToEvent);
|
||||
}
|
||||
|
||||
public async countByType(
|
||||
campaignId: string,
|
||||
eventType: DeliveryEventType
|
||||
): Promise<number> {
|
||||
const rows = await this.runQuery<{ count: number }>(
|
||||
`SELECT count() AS count FROM ${this.table}
|
||||
WHERE campaign_id = $p0 AND event_type = $p1
|
||||
GROUP ALL`,
|
||||
[campaignId, eventType],
|
||||
"countByType"
|
||||
);
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
public async getStatsByCampaign(
|
||||
campaignId: string
|
||||
): Promise<DeliveryStats> {
|
||||
const events = await this.getByCampaign(campaignId);
|
||||
return computeStats(events);
|
||||
}
|
||||
|
||||
public async exists(
|
||||
messageId: string,
|
||||
eventType: DeliveryEventType
|
||||
): Promise<boolean> {
|
||||
const rows = await this.runQuery<{ count: number }>(
|
||||
`SELECT count() AS count FROM ${this.table}
|
||||
WHERE message_id = $p0 AND event_type = $p1
|
||||
GROUP ALL`,
|
||||
[messageId, eventType],
|
||||
"exists"
|
||||
);
|
||||
return (rows[0]?.count ?? 0) > 0;
|
||||
}
|
||||
|
||||
private async runQuery<T>(
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
op: string
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
return await this.db.query<T>(sql, params);
|
||||
} catch (cause) {
|
||||
throw new DeliveryTrackingError(
|
||||
DELIVERY_TRACKING_CODES.QUERY_FAILURE,
|
||||
"error.delivery_tracking.query_failure",
|
||||
{ cause, context: { op } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Core types for `@suaveplan/delivery-tracking`.
|
||||
*
|
||||
* Pure data shapes — zero runtime footprint. Runtime validation lives in
|
||||
* `*.schemas.ts` sibling files where Zod boundaries are enforced.
|
||||
*/
|
||||
|
||||
/** Lifecycle event types for an outbound message. */
|
||||
export type DeliveryEventType =
|
||||
| "sent"
|
||||
| "delivered"
|
||||
| "opened"
|
||||
| "clicked"
|
||||
| "bounced"
|
||||
| "unsubscribed"
|
||||
| "complained"
|
||||
| "deferred"
|
||||
| "dropped"
|
||||
| "read";
|
||||
|
||||
/** A persisted delivery event. */
|
||||
export interface DeliveryEvent {
|
||||
readonly id: string;
|
||||
readonly tenantId: string;
|
||||
readonly campaignId?: string | undefined;
|
||||
readonly contactId: string;
|
||||
readonly channel: string;
|
||||
readonly eventType: DeliveryEventType;
|
||||
readonly messageId: string;
|
||||
readonly metadata: Record<string, unknown>;
|
||||
readonly timestamp: Date;
|
||||
readonly ip?: string | undefined;
|
||||
readonly userAgent?: string | undefined;
|
||||
}
|
||||
|
||||
/** Aggregated counters + rates for a campaign. */
|
||||
export interface DeliveryStats {
|
||||
readonly sent: number;
|
||||
readonly delivered: number;
|
||||
readonly deliveryRate: number;
|
||||
readonly opened: number;
|
||||
readonly openRate: number;
|
||||
readonly clicked: number;
|
||||
readonly clickRate: number;
|
||||
readonly bounced: number;
|
||||
readonly bounceRate: number;
|
||||
readonly unsubscribed: number;
|
||||
readonly unsubscribeRate: number;
|
||||
readonly complained: number;
|
||||
readonly complainRate: number;
|
||||
}
|
||||
|
||||
/** Input shape accepted by `DeliveryTracker.track`. */
|
||||
export interface CreateDeliveryEventInput {
|
||||
readonly tenantId: string;
|
||||
readonly campaignId?: string | undefined;
|
||||
readonly contactId: string;
|
||||
readonly channel: string;
|
||||
readonly eventType: DeliveryEventType;
|
||||
readonly messageId: string;
|
||||
readonly metadata?: Record<string, unknown> | undefined;
|
||||
readonly timestamp?: Date | undefined;
|
||||
readonly ip?: string | undefined;
|
||||
readonly userAgent?: string | undefined;
|
||||
}
|
||||
|
||||
/** Query parameters for filtering persisted events. */
|
||||
export interface DeliveryEventQuery {
|
||||
readonly eventType?: DeliveryEventType | undefined;
|
||||
readonly channel?: string | undefined;
|
||||
readonly from?: Date | undefined;
|
||||
readonly to?: Date | undefined;
|
||||
readonly limit?: number | undefined;
|
||||
readonly offset?: number | undefined;
|
||||
}
|
||||
|
||||
/** Configuration for pixel + link token generation. */
|
||||
export interface TrackingConfig {
|
||||
readonly baseUrl: string;
|
||||
readonly secret: string;
|
||||
readonly pixelPath?: string | undefined;
|
||||
readonly clickPath?: string | undefined;
|
||||
}
|
||||
|
||||
/** Decoded pixel-token payload. */
|
||||
export interface TrackingTokenPayload {
|
||||
readonly messageId: string;
|
||||
readonly contactId: string;
|
||||
readonly campaignId?: string | undefined;
|
||||
}
|
||||
|
||||
/** Decoded click-token payload (includes the wrapped URL). */
|
||||
export interface ClickTrackingTokenPayload extends TrackingTokenPayload {
|
||||
readonly originalUrl: string;
|
||||
}
|
||||
|
||||
/** One bucket of a time-series aggregation. */
|
||||
export interface TimeSeriesBucket {
|
||||
readonly timestamp: Date;
|
||||
readonly stats: DeliveryStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of typed events emitted by `DeliveryTracker` over the genesis `EventBus`.
|
||||
* Includes an explicit `[key: string]: DeliveryEvent` so it satisfies the
|
||||
* `EventMap = Record<string, unknown>` constraint imposed by `@suaveplan/events`.
|
||||
*/
|
||||
export interface DeliveryTrackerEventMap {
|
||||
"delivery:sent": DeliveryEvent;
|
||||
"delivery:delivered": DeliveryEvent;
|
||||
"delivery:opened": DeliveryEvent;
|
||||
"delivery:clicked": DeliveryEvent;
|
||||
"delivery:bounced": DeliveryEvent;
|
||||
"delivery:unsubscribed": DeliveryEvent;
|
||||
"delivery:complained": DeliveryEvent;
|
||||
"delivery:deferred": DeliveryEvent;
|
||||
"delivery:dropped": DeliveryEvent;
|
||||
"delivery:read": DeliveryEvent;
|
||||
[key: string]: DeliveryEvent;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
ClickTrackingTokenPayload,
|
||||
CreateDeliveryEventInput,
|
||||
DeliveryEvent,
|
||||
DeliveryEventQuery,
|
||||
DeliveryEventType,
|
||||
DeliveryStats,
|
||||
DeliveryTrackerEventMap,
|
||||
TimeSeriesBucket,
|
||||
TrackingConfig,
|
||||
TrackingTokenPayload,
|
||||
} from "./delivery-tracking.types.js";
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Test setup for `@suaveplan/delivery-tracking`.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry `MeterProvider` BEFORE any
|
||||
* source module imports — `metrics.getMeter()` therefore binds to this
|
||||
* provider, so `flushTestMetrics()` returns the exact counter snapshots
|
||||
* the production code emitted.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.DELTA
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics(): Promise<
|
||||
ReturnType<typeof testInMemoryMetricExporter.getMetrics>
|
||||
> {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/delivery-tracking",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/surrealdb",
|
||||
"@suaveplan/types",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
74
packages/communication/emails/dmarc/README.md
Normal file
74
packages/communication/emails/dmarc/README.md
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
112
packages/communication/emails/dmarc/package.json
Normal file
112
packages/communication/emails/dmarc/package.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"name": "@suaveplan/dmarc",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "DMARC report parser — parses aggregate (RUA) and forensic (RUF) DMARC XML reports into typed structures",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/dmarc-error.d.ts",
|
||||
"default": "./dist/errors/dmarc-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/dmarc-error.d.ts",
|
||||
"default": "./dist/errors/dmarc-error.js"
|
||||
}
|
||||
},
|
||||
"./schemas/aggregate": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/aggregate-report.schema.d.ts",
|
||||
"default": "./dist/schemas/aggregate-report.schema.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/schemas/aggregate-report.schema.d.ts",
|
||||
"default": "./dist/schemas/aggregate-report.schema.js"
|
||||
}
|
||||
},
|
||||
"./schemas/forensic": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/forensic-report.schema.d.ts",
|
||||
"default": "./dist/schemas/forensic-report.schema.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/schemas/forensic-report.schema.d.ts",
|
||||
"default": "./dist/schemas/forensic-report.schema.js"
|
||||
}
|
||||
},
|
||||
"./parsers/aggregate": {
|
||||
"import": {
|
||||
"types": "./dist/parsers/aggregate.d.ts",
|
||||
"default": "./dist/parsers/aggregate.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parsers/aggregate.d.ts",
|
||||
"default": "./dist/parsers/aggregate.js"
|
||||
}
|
||||
},
|
||||
"./parsers/forensic": {
|
||||
"import": {
|
||||
"types": "./dist/parsers/forensic.d.ts",
|
||||
"default": "./dist/parsers/forensic.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parsers/forensic.d.ts",
|
||||
"default": "./dist/parsers/forensic.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "catalog:parsing",
|
||||
"fflate": "catalog:parsing",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
76
packages/communication/emails/dmarc/src/decoders/gzip.md
Normal file
76
packages/communication/emails/dmarc/src/decoders/gzip.md
Normal file
@@ -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,73 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { gzipSync, strToU8 } from "fflate";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
import { decodeInput, decompressGzip, isGzip } from "./gzip.js";
|
||||
|
||||
const PLAIN_XML = "<feedback><version>1.0</version></feedback>";
|
||||
|
||||
describe("isGzip", () => {
|
||||
it("returns true for gzip magic bytes", { timeout: 5000 }, () => {
|
||||
const compressed = gzipSync(strToU8(PLAIN_XML));
|
||||
expect(isGzip(compressed)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for plain text bytes", { timeout: 5000 }, () => {
|
||||
const bytes = strToU8(PLAIN_XML);
|
||||
expect(isGzip(bytes)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty Uint8Array", { timeout: 5000 }, () => {
|
||||
expect(isGzip(new Uint8Array(0))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for single byte", { timeout: 5000 }, () => {
|
||||
expect(isGzip(new Uint8Array([0x1f]))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when first byte matches but second does not", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isGzip(new Uint8Array([0x1f, 0x00]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decompressGzip", () => {
|
||||
it("decompresses a valid gzip buffer to a string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const compressed = gzipSync(strToU8(PLAIN_XML));
|
||||
const result = decompressGzip(compressed);
|
||||
expect(result).toBe(PLAIN_XML);
|
||||
});
|
||||
|
||||
it("throws DmarcError with UNSUPPORTED_ENCODING on invalid bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const invalid = new Uint8Array([0x1f, 0x8b, 0x00, 0x01]);
|
||||
expect(() => decompressGzip(invalid)).toThrowError(DmarcError);
|
||||
try {
|
||||
decompressGzip(invalid);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmarcError);
|
||||
expect((err as DmarcError).code).toBe(
|
||||
DMARC_CODES.UNSUPPORTED_ENCODING
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeInput", () => {
|
||||
it("returns string input unchanged", { timeout: 5000 }, () => {
|
||||
expect(decodeInput(PLAIN_XML)).toBe(PLAIN_XML);
|
||||
});
|
||||
|
||||
it("decompresses gzip Uint8Array", { timeout: 5000 }, () => {
|
||||
const compressed = gzipSync(strToU8(PLAIN_XML));
|
||||
expect(decodeInput(compressed)).toBe(PLAIN_XML);
|
||||
});
|
||||
|
||||
it("UTF-8 decodes plain Uint8Array", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode(PLAIN_XML);
|
||||
expect(decodeInput(bytes)).toBe(PLAIN_XML);
|
||||
});
|
||||
});
|
||||
66
packages/communication/emails/dmarc/src/decoders/gzip.ts
Normal file
66
packages/communication/emails/dmarc/src/decoders/gzip.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
|
||||
/**
|
||||
* Gzip magic bytes (first two bytes of a gzip stream: 0x1f 0x8b).
|
||||
*/
|
||||
const GZIP_MAGIC_0 = 0x1f;
|
||||
const GZIP_MAGIC_1 = 0x8b;
|
||||
|
||||
/**
|
||||
* Returns `true` if the given bytes begin with the gzip magic header.
|
||||
*
|
||||
* @param bytes - Raw input bytes.
|
||||
*/
|
||||
export function isGzip(bytes: Uint8Array): boolean {
|
||||
return (
|
||||
bytes.length >= 2 &&
|
||||
bytes[0] === GZIP_MAGIC_0 &&
|
||||
bytes[1] === GZIP_MAGIC_1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompresses a gzip-compressed `Uint8Array` to a UTF-8 string.
|
||||
*
|
||||
* Uses the synchronous `fflate.gunzipSync` path so it works in all
|
||||
* JavaScript runtimes without requiring streams or Node.js builtins.
|
||||
*
|
||||
* @param bytes - Raw gzip-compressed bytes.
|
||||
* @returns Decompressed UTF-8 string.
|
||||
* @throws {DmarcError} with code `DMARC.UNSUPPORTED_ENCODING` when decompression fails.
|
||||
*/
|
||||
export function decompressGzip(bytes: Uint8Array): string {
|
||||
try {
|
||||
const decompressed = gunzipSync(bytes);
|
||||
return strFromU8(decompressed);
|
||||
} catch (cause) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.UNSUPPORTED_ENCODING,
|
||||
"Failed to decompress gzip input",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a `Uint8Array` or string input to a UTF-8 string.
|
||||
*
|
||||
* - If `input` is already a `string`, it is returned as-is.
|
||||
* - If `input` is a `Uint8Array` starting with gzip magic bytes, it is
|
||||
* decompressed via {@link decompressGzip}.
|
||||
* - Otherwise the bytes are decoded as UTF-8 using the standard `TextDecoder`.
|
||||
*
|
||||
* @param input - Raw report data: either a string or binary bytes.
|
||||
* @returns UTF-8 string ready for XML parsing.
|
||||
* @throws {DmarcError} with code `DMARC.UNSUPPORTED_ENCODING` on decompression failure.
|
||||
*/
|
||||
export function decodeInput(input: string | Uint8Array): string {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (isGzip(input)) {
|
||||
return decompressGzip(input);
|
||||
}
|
||||
return new TextDecoder().decode(input);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { decodeInput, decompressGzip, isGzip } from "./gzip";
|
||||
export { parseXml } from "./xml";
|
||||
69
packages/communication/emails/dmarc/src/decoders/xml.md
Normal file
69
packages/communication/emails/dmarc/src/decoders/xml.md
Normal file
@@ -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.
|
||||
58
packages/communication/emails/dmarc/src/decoders/xml.test.ts
Normal file
58
packages/communication/emails/dmarc/src/decoders/xml.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
import { parseXml } from "./xml.js";
|
||||
|
||||
describe("parseXml", () => {
|
||||
it("parses valid XML into an object", { timeout: 5000 }, () => {
|
||||
const xml = "<root><child>value</child></root>";
|
||||
const result = parseXml(xml);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe("object");
|
||||
});
|
||||
|
||||
it("forces <record> to be an array for a single element", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = "<feedback><record><row/></record></feedback>";
|
||||
const result = parseXml(xml) as Record<string, Record<string, unknown>>;
|
||||
expect(Array.isArray(result["feedback"]["record"])).toBe(true);
|
||||
});
|
||||
|
||||
it("forces <dkim> to be an array for a single element", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml =
|
||||
"<auth_results><dkim><domain>x.com</domain><result>pass</result></dkim></auth_results>";
|
||||
const result = parseXml(xml) as Record<string, Record<string, unknown>>;
|
||||
expect(Array.isArray(result["auth_results"]["dkim"])).toBe(true);
|
||||
});
|
||||
|
||||
it("forces <spf> to be an array for a single element", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml =
|
||||
"<auth_results><spf><domain>x.com</domain><result>pass</result></spf></auth_results>";
|
||||
const result = parseXml(xml) as Record<string, Record<string, unknown>>;
|
||||
expect(Array.isArray(result["auth_results"]["spf"])).toBe(true);
|
||||
});
|
||||
|
||||
it("parses numeric values as numbers", { timeout: 5000 }, () => {
|
||||
const xml = "<row><count>5</count></row>";
|
||||
const result = parseXml(xml) as Record<string, Record<string, number>>;
|
||||
expect(typeof result["row"]["count"]).toBe("number");
|
||||
expect(result["row"]["count"]).toBe(5);
|
||||
});
|
||||
|
||||
it("throws DmarcError with PARSE_ERROR on malformed XML (unclosed attribute)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// fast-xml-parser throws on unclosed attribute strings
|
||||
expect(() => parseXml('<root attr="unclosed')).toThrowError(DmarcError);
|
||||
try {
|
||||
parseXml('<root attr="unclosed');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmarcError);
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.PARSE_ERROR);
|
||||
}
|
||||
});
|
||||
});
|
||||
60
packages/communication/emails/dmarc/src/decoders/xml.ts
Normal file
60
packages/communication/emails/dmarc/src/decoders/xml.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
|
||||
/**
|
||||
* Shared `fast-xml-parser` configuration for DMARC report parsing.
|
||||
*
|
||||
* Key choices:
|
||||
* - `ignoreAttributes: false` — preserve XML attributes (needed for version fields)
|
||||
* - `parseTagValue: true` — coerce numeric text nodes to JS numbers
|
||||
* - `trimValues: true` — strip leading/trailing whitespace from text content
|
||||
* - `isArray` — force `<record>` and `<dkim>`/`<spf>` to always be arrays so
|
||||
* single-record reports and single auth-result reports parse consistently
|
||||
*/
|
||||
const PARSER_OPTIONS = {
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "@_",
|
||||
parseTagValue: true,
|
||||
parseAttributeValue: true,
|
||||
trimValues: true,
|
||||
isArray: (name: string): boolean => {
|
||||
return (
|
||||
name === "record" ||
|
||||
name === "dkim" ||
|
||||
name === "spf" ||
|
||||
name === "error"
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Parses a DMARC XML string into a raw JavaScript object.
|
||||
*
|
||||
* The raw object mirrors the XML structure with camelCase conversion handled by
|
||||
* callers (see the aggregate/forensic mappers). This function is intentionally
|
||||
* low-level — it only concerns itself with well-formedness; schema validation
|
||||
* is deferred to the Zod schemas.
|
||||
*
|
||||
* @param xml - Well-formed UTF-8 XML string.
|
||||
* @returns Raw parsed object (untyped — callers apply the Zod schema).
|
||||
* @throws {DmarcError} with code `DMARC.PARSE_ERROR` on XML parse failure.
|
||||
*/
|
||||
export function parseXml(xml: string): Record<string, unknown> {
|
||||
try {
|
||||
const parser = new XMLParser(PARSER_OPTIONS);
|
||||
const result = parser.parse(xml) as Record<string, unknown>;
|
||||
return result;
|
||||
} catch (cause) {
|
||||
const msg =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: /* istanbul ignore next */ String(cause);
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.PARSE_ERROR,
|
||||
`Failed to parse XML: ${msg}`,
|
||||
{
|
||||
cause,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user