Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,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`

View 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"
]
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
export { DeliveryAggregator, type TimeInterval } from "./aggregator.js";

View File

@@ -0,0 +1,6 @@
export { DeliveryTracker, type DeliveryTrackerOptions } from "./tracker.js";
export {
type CreateDeliveryEventInputSchema,
createDeliveryEventInputSchema,
deliveryEventTypeSchema,
} from "./tracker.schemas.js";

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
>;

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
export { TrackingLinkWrapper } from "./tracking-link.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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}>`;
}
);
}
}

View File

@@ -0,0 +1,2 @@
export { decodeToken, encodeToken } from "./token-codec.js";
export { TrackingPixelGenerator } from "./tracking-pixel.js";

View File

@@ -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.

View File

@@ -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(/[+/=]/);
});
});

View File

@@ -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 }
);
}
}

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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;" />`;
}
}

View File

@@ -0,0 +1,6 @@
export {
type BuiltinWebhookProvider,
type ProviderParser,
WebhookEventParser,
type WebhookPayload,
} from "./webhook-parser.js";

View File

@@ -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.

View File

@@ -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
);
});
});

View File

@@ -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;
}
}

View File

@@ -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`.

View File

@@ -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);
});
});

View File

@@ -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";
}
}

View File

@@ -0,0 +1,5 @@
export {
DELIVERY_TRACKING_CODES,
type DeliveryTrackingCode,
DeliveryTrackingError,
} from "./delivery-tracking-error.js";

View 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";

View File

@@ -0,0 +1,5 @@
export {
type DeliveryTrackingInstruments,
getDeliveryTrackingInstruments,
resetDeliveryTrackingInstruments,
} from "./instruments.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
export {
computeStats,
type DeliveryEventStore,
InMemoryDeliveryEventStore,
} from "./memory-store.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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
);
}
}

View File

@@ -0,0 +1 @@
export { SurrealDeliveryEventStore } from "./surreal-store.js";

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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 } }
);
}
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,12 @@
export type {
ClickTrackingTokenPayload,
CreateDeliveryEventInput,
DeliveryEvent,
DeliveryEventQuery,
DeliveryEventType,
DeliveryStats,
DeliveryTrackerEventMap,
TimeSeriesBucket,
TrackingConfig,
TrackingTokenPayload,
} from "./delivery-tracking.types.js";

View File

@@ -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;
}

View File

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

View File

@@ -0,0 +1,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:/,
],
},
},
});

View File

@@ -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,
},
});