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,89 @@
# @suaveplan/emailer - Universal email service with plugin transports (Dummy / SMTP via injectable SmtpClientLike / Gmail REST via FetchLike), nine lifecycle hooks, retry, queue dispatch, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable Clock/Rng/Logger/FetchLike/TimersLike, stable defineCodes('EMAILER', [...]) error registry.
Universal email service with pluggable transports. Ships
`EmailService`, three transports (Dummy / SMTP via injectable
`SmtpClientLike` / Gmail REST via injectable `FetchLike`), nine lifecycle
hooks, deterministic retry, structural queue dispatch, Zod-validated option
boundaries, OpenTelemetry-instrumented hot paths, and a stable
`defineCodes("EMAILER", [...])` error registry.
<!-- begin:auto-docs -->
## Documentation tree
- [email-service](./src/core/email-service/email-service.md) — `EmailService<TTransport>` is the public entry point for `@suaveplan/emailer`. It orchestrates message validation, life…
- [base-email-transport](./src/core/email-service/base-email-transport.md) — `BaseEmailTransport` is the abstract helper that every concrete transport (`DummyTransport`, `SmtpTransport`, `GmailRes…
- [email-service](./src/core/email-service/email-service.md) — `EmailService<TTransport>` is the public entry point for `@suaveplan/emailer`. It orchestrates message validation, life…
- [retry](./src/core/retry/retry.md) — Deterministic retry helper used by `EmailService.send`. The helper takes a `RetryPolicy`, an injectable `TimersLike`, a…
- [retry](./src/core/retry/retry.md) — Deterministic retry helper used by `EmailService.send`. The helper takes a `RetryPolicy`, an injectable `TimersLike`, a…
- [validate](./src/core/validate/validate.md) — Shared address-formatting and message-validation helpers used by every concrete transport and by `EmailService` itself.…
- [validate](./src/core/validate/validate.md) — Shared address-formatting and message-validation helpers used by every concrete transport and by `EmailService` itself.…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/emailer` ships a single stable error-code registry that every failure path in the package routes through. T…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/emailer` ships a single stable error-code registry that every failure path in the package routes through. T…
- [fake-fetch](./src/fakes/fake-fetch.md) — Deterministic test double for `FetchLike`, used by `GmailRestTransport` tests. Queues scripted responses and captures e…
- [fake-smtp-client](./src/fakes/fake-smtp-client.md) — Deterministic test double for `SmtpClientLike`. Tests queue scripted responses, invoke the wrapped `SmtpTransport`, and…
- [otel](./src/otel/metrics.md) — Single source of truth for every OpenTelemetry instrument emitted by `@suaveplan/emailer`. Every production path that t…
- [metrics](./src/otel/metrics.md) — Single source of truth for every OpenTelemetry instrument emitted by `@suaveplan/emailer`. Every production path that t…
- [schemas](./src/schemas/schemas.md) — Centralised Zod schemas for every public option bundle and wire payload in `@suaveplan/emailer`. The schemas exist to e…
- [schemas](./src/schemas/schemas.md) — Centralised Zod schemas for every public option bundle and wire payload in `@suaveplan/emailer`. The schemas exist to e…
- [dummy](./src/transports/dummy/dummy-transport.md) — `DummyTransport` is a deterministic in-memory transport designed for tests, fixtures, and local development. It records…
- [dummy-transport](./src/transports/dummy/dummy-transport.md) — `DummyTransport` is a deterministic in-memory transport designed for tests, fixtures, and local development. It records…
- [gmail-rest](./src/transports/gmail-rest/gmail-rest-transport.md) — `GmailRestTransport` sends mail through Gmail's REST API directly, over an injectable `FetchLike`. The transport builds…
- [gmail-rest-transport](./src/transports/gmail-rest/gmail-rest-transport.md) — `GmailRestTransport` sends mail through Gmail's REST API directly, over an injectable `FetchLike`. The transport builds…
- [smtp](./src/transports/smtp/smtp-transport.md) — `SmtpTransport` is a structural SMTP transport: it accepts a caller-supplied `SmtpClientLike` adapter (the contract is…
- [smtp-transport](./src/transports/smtp/smtp-transport.md) — `SmtpTransport` is a structural SMTP transport: it accepts a caller-supplied `SmtpClientLike` adapter (the contract is…
<!-- end:auto-docs -->
## Why universal
The upstream `@suaveplan/emailer` was server-only because it imported
`nodemailer` and `googleapis` directly. The rewrite drops both hard
dependencies in favour of injectable contracts:
- **SMTP**: callers supply a thin `SmtpClientLike` adapter so nodemailer
(or any other library) lives in the consumer's app shell, not in this
package.
- **Gmail**: send is implemented directly against the Gmail REST API via
`FetchLike`, so token acquisition is the caller's concern and the
transport runs unchanged in Bun, Node, and the browser.
## Modules
- [`src/core/email-service/email-service.md`](src/core/email-service/email-service.md) — `EmailService<TTransport>` orchestrator
- [`src/core/email-service/base-email-transport.md`](src/core/email-service/base-email-transport.md) — abstract helper
- [`src/core/retry/retry.md`](src/core/retry/retry.md) — deterministic retry helper
- [`src/core/validate/validate.md`](src/core/validate/validate.md) — address-formatting helpers
- [`src/transports/dummy/dummy-transport.md`](src/transports/dummy/dummy-transport.md) — in-memory test transport
- [`src/transports/smtp/smtp-transport.md`](src/transports/smtp/smtp-transport.md) — SMTP via adapter
- [`src/transports/smtp/smtp-client-like.types.md`](src/transports/smtp/smtp-client-like.types.md) — adapter contract
- [`src/transports/gmail-rest/gmail-rest-transport.md`](src/transports/gmail-rest/gmail-rest-transport.md) — Gmail REST transport
- [`src/error-codes.md`](src/error-codes.md) — error registry
- [`src/schemas/schemas.md`](src/schemas/schemas.md) — Zod boundary schemas
- [`src/types/emailer.types.md`](src/types/emailer.types.md) — public types
- [`src/otel/metrics.md`](src/otel/metrics.md) — OTel instruments
- [`src/fakes/fake-smtp-client.md`](src/fakes/fake-smtp-client.md) — deterministic SMTP fake
- [`src/fakes/fake-fetch.md`](src/fakes/fake-fetch.md) — deterministic fetch fake
## Quick start
```ts
import { EmailService } from "@suaveplan/emailer";
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
const transport = createDummyTransport();
const service = new EmailService({
transport,
transportOptions: {},
defaultFrom: "no-reply@example.com",
});
await service.initialize();
const result = await service.send({
to: "user@example.com",
subject: "hello",
html: "<p>hi</p>",
});
```
## License
MIT

View File

@@ -0,0 +1,131 @@
{
"name": "@suaveplan/emailer",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal email service with plugin transports (Dummy / SMTP via injectable SmtpClientLike / Gmail REST via FetchLike), nine lifecycle hooks, retry, queue dispatch, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable Clock/Rng/Logger/FetchLike/TimersLike, stable defineCodes('EMAILER', [...]) error registry.",
"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"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./schemas/schemas": {
"import": {
"types": "./dist/schemas/schemas.d.ts",
"default": "./dist/schemas/schemas.js"
}
},
"./types/emailer": {
"import": {
"types": "./dist/types/emailer.types.d.ts",
"default": "./dist/types/emailer.types.js"
}
},
"./core/email-service": {
"import": {
"types": "./dist/core/email-service/email-service.d.ts",
"default": "./dist/core/email-service/email-service.js"
}
},
"./core/base-email-transport": {
"import": {
"types": "./dist/core/email-service/base-email-transport.d.ts",
"default": "./dist/core/email-service/base-email-transport.js"
}
},
"./core/retry": {
"import": {
"types": "./dist/core/retry/retry.d.ts",
"default": "./dist/core/retry/retry.js"
}
},
"./core/validate": {
"import": {
"types": "./dist/core/validate/validate.d.ts",
"default": "./dist/core/validate/validate.js"
}
},
"./transports/dummy": {
"import": {
"types": "./dist/transports/dummy/dummy-transport.d.ts",
"default": "./dist/transports/dummy/dummy-transport.js"
}
},
"./transports/smtp": {
"import": {
"types": "./dist/transports/smtp/smtp-transport.d.ts",
"default": "./dist/transports/smtp/smtp-transport.js"
}
},
"./transports/gmail-rest": {
"import": {
"types": "./dist/transports/gmail-rest/gmail-rest-transport.d.ts",
"default": "./dist/transports/gmail-rest/gmail-rest-transport.js"
}
},
"./fakes/fake-smtp-client": {
"import": {
"types": "./dist/fakes/fake-smtp-client.d.ts",
"default": "./dist/fakes/fake-smtp-client.js"
}
},
"./otel/metrics": {
"import": {
"types": "./dist/otel/metrics.d.ts",
"default": "./dist/otel/metrics.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/queue": "workspace:^",
"@suaveplan/timing": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,92 @@
# `base-email-transport.ts`
## Purpose
`BaseEmailTransport` is the abstract helper that every concrete transport
(`DummyTransport`, `SmtpTransport`, `GmailRestTransport`) extends. It
centralises address formatting, message validation, initialisation
bookkeeping, and result-construction so concrete transports stay focused
on their wire format — not on cross-cutting concerns that would drift
between implementations otherwise.
## Features
- `initialize(config)` records the config and flips an `initialized` flag;
subclasses can override but typically just call `super.initialize`.
- `assertInitialized()` raises `EmailerError(EMAILER.TRANSPORT_NOT_INITIALIZED)`
if called before `initialize`, so every transport's `send` has a single
guarded entry point.
- `formatAddress`, `formatSingleAddress`, `extractEmail`, `toEmailArray` —
thin proxies to `core/validate` so subclasses use the canonical
formatters via `this.*`.
- `createSuccessResult(messageId, response, metadata)` and
`createErrorResult(error, metadata)` — produce `EmailSendResult` with the
shape every transport returns. Conditional property assignment honours
`exactOptionalPropertyTypes` from `tsconfig.base.json`.
- `validateMessage(message)` — proxies to `core/validate.validateMessage`.
## Usage: Basic
```ts
import { BaseEmailTransport } from "@suaveplan/emailer/core/base-email-transport";
class NullTransport extends BaseEmailTransport<{}> {
public readonly name = "null";
public override async send(message) {
this.assertInitialized();
this.validateMessage(message);
return this.createSuccessResult("null-1");
}
}
```
## Usage: Advanced
```ts
import { BaseEmailTransport } from "@suaveplan/emailer/core/base-email-transport";
import type { EmailMessage, EmailSendResult } from "@suaveplan/emailer/types/emailer";
interface MyConfig {
readonly target: URL;
}
export class MyTransport extends BaseEmailTransport<MyConfig> {
public readonly name = "my-transport";
public override async send(message: EmailMessage): Promise<EmailSendResult> {
this.assertInitialized();
try {
this.validateMessage(message);
const recipients = this.toEmailArray(message.to);
const from = this.formatSingleAddress(message.from ?? "noreply@x.y");
// ... actual wire send ...
return this.createSuccessResult("ok", { recipients, from });
} catch (error) {
return this.createErrorResult(error as Error);
}
}
}
```
## API Reference
- `abstract class BaseEmailTransport<TConfig>`
- `abstract readonly name: string`
- `initialize(config: TConfig): Promise<void>`
- `abstract send(message: EmailMessage): Promise<EmailSendResult>`
- `verify(): Promise<boolean>` — defaults to the `initialized` flag.
- `close(): Promise<void>` — flips `initialized` to `false`.
- `assertInitialized(): void` (protected)
- `formatSingleAddress`, `formatAddress`, `extractEmail`, `toEmailArray`,
`validateMessage`, `createSuccessResult`, `createErrorResult` (protected)
## Implementation Notes
- The class is universal — every helper avoids Node-only APIs so the
class compiles cleanly under the package's `"lib": ["ES2022", "DOM"]`
target.
- `createSuccessResult` and `createErrorResult` use the `-readonly` mapped
type trick to build up the result object piece-by-piece without
violating `exactOptionalPropertyTypes`.
- `assertInitialized` is the only throw site in the base class; every
other helper returns or proxies.

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
EmailMessage,
EmailSendResult,
} from "../../types/emailer.types.js";
import { BaseEmailTransport } from "./base-email-transport.js";
class MinimalTransport extends BaseEmailTransport<undefined> {
public readonly name = "minimal";
public override async send(_: EmailMessage): Promise<EmailSendResult> {
this.assertInitialized();
return this.createSuccessResult("min-1", undefined, { ok: true });
}
/** Expose protected helpers for assertion. */
public exposeExtract(addr: string): string {
return this.extractEmail(addr);
}
public exposeArray(bag: undefined): string[] {
return this.toEmailArray(bag);
}
public exposeError(err: Error): EmailSendResult {
return this.createErrorResult(err, { src: "test" });
}
public exposeSuccess(): EmailSendResult {
return this.createSuccessResult("ok");
}
}
describe("BaseEmailTransport defaults", () => {
it("verify returns false before initialize", {
timeout: 5000,
}, async () => {
const t = new MinimalTransport();
expect(await t.verify()).toBe(false);
});
it("verify returns true after initialize", { timeout: 5000 }, async () => {
const t = new MinimalTransport();
await t.initialize(undefined);
expect(await t.verify()).toBe(true);
});
it("close flips initialized to false", { timeout: 5000 }, async () => {
const t = new MinimalTransport();
await t.initialize(undefined);
await t.close();
expect(await t.verify()).toBe(false);
});
it("createSuccessResult with metadata sets metadata", {
timeout: 5000,
}, async () => {
const t = new MinimalTransport();
await t.initialize(undefined);
const r = await t.send({ to: "a@b.c", subject: "x", text: "y" });
expect(r.metadata).toEqual({ ok: true });
});
it("createErrorResult preserves metadata", { timeout: 5000 }, () => {
const t = new MinimalTransport();
const r = t.exposeError(new Error("e"));
expect(r.metadata).toEqual({ src: "test" });
expect(r.success).toBe(false);
});
it("createSuccessResult without response is success-only", {
timeout: 5000,
}, () => {
const t = new MinimalTransport();
const r = t.exposeSuccess();
expect(r.success).toBe(true);
expect(r.response).toBeUndefined();
expect(r.metadata).toBeUndefined();
});
it("extractEmail proxy", { timeout: 5000 }, () => {
const t = new MinimalTransport();
expect(t.exposeExtract("plain@x.y")).toBe("plain@x.y");
});
it("toEmailArray with undefined", { timeout: 5000 }, () => {
const t = new MinimalTransport();
expect(t.exposeArray(undefined)).toEqual([]);
});
});

View File

@@ -0,0 +1,108 @@
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
import type {
AddressBag,
EmailAddress,
EmailMessage,
EmailSendResult,
EmailTransport,
} from "../../types/emailer.types.js";
import {
extractEmail,
formatAddress,
toEmailArray,
validateMessage,
} from "../validate/validate.js";
/**
* Abstract helper that every concrete transport (`DummyTransport`,
* `SmtpTransport`, `GmailRestTransport`) extends. Provides the
* address-formatting / validation helpers and a small set of result
* constructors so concrete transports keep their body free of
* cross-cutting concerns.
*/
export abstract class BaseEmailTransport<TConfig = unknown>
implements EmailTransport<TConfig>
{
public abstract readonly name: string;
protected config: TConfig | undefined;
protected initialized = false;
public async initialize(config: TConfig): Promise<void> {
this.config = config;
this.initialized = true;
await Promise.resolve();
}
public abstract send(message: EmailMessage): Promise<EmailSendResult>;
/** Default verify — returns whether `initialize` was called. */
public async verify(): Promise<boolean> {
return this.initialized;
}
/** Default close — flips the `initialized` flag. */
public async close(): Promise<void> {
this.initialized = false;
}
/** Guard used by every transport at the head of `send`. */
protected assertInitialized(): void {
if (!this.initialized) {
throw new EmailerError(
EMAILER_CODES.TRANSPORT_NOT_INITIALIZED,
"emailer.errors.transportNotInitialized",
{ context: { transport: this.name } }
);
}
}
protected formatAddress(
address: AddressBag | undefined
): string | string[] | undefined {
return formatAddress(address);
}
protected extractEmail(address: string | EmailAddress): string {
return extractEmail(address);
}
protected toEmailArray(address: AddressBag | undefined): string[] {
return toEmailArray(address);
}
protected validateMessage(message: EmailMessage): void {
validateMessage(message);
}
protected createSuccessResult(
messageId: string,
response?: unknown,
metadata?: Readonly<Record<string, unknown>>
): EmailSendResult {
const out: {
-readonly [K in keyof EmailSendResult]: EmailSendResult[K];
} = {
success: true,
messageId,
timestamp: new Date(),
};
if (response !== undefined) out.response = response;
if (metadata !== undefined) out.metadata = metadata;
return out;
}
protected createErrorResult(
error: Error,
metadata?: Readonly<Record<string, unknown>>
): EmailSendResult {
const out: {
-readonly [K in keyof EmailSendResult]: EmailSendResult[K];
} = {
success: false,
error,
timestamp: new Date(),
};
if (metadata !== undefined) out.metadata = metadata;
return out;
}
}

View File

@@ -0,0 +1,104 @@
# `email-service.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EmailService<TTransport>` is the public entry point for `@suaveplan/emailer`.
It orchestrates message validation, lifecycle hooks, retry with backoff,
queue dispatch, OpenTelemetry emission, structured logging, and running
statistics against any `EmailTransport` implementation. The service is
generic over the transport so type-level capability (e.g. that a particular
transport's config bag is `GmailRestTransportOptions`) propagates through
`EmailService.initialize` without runtime widening.
## Features
- Validates `EmailerOptions` via Zod `safeParse` in the constructor; throws
`EmailerError(EMAILER.INVALID_CONFIG)` carrying the `ZodError` on bad
input.
- Nine lifecycle hook arrays (`beforeValidate`, `afterValidate`,
`beforeSend`, `afterSend`, `onError`, `afterError`, `beforeRetry`,
`beforeQueue`, `afterQueue`) with `addHook` / `removeHook` / `clearHooks`.
- Retry with linear or exponential backoff via injectable `TimersLike` and
`AbortSignal`.
- Optional queue dispatch via structural `QueueLike` (`{ add(fn) }`).
- OTel emission on every send / error / retry / queue path.
- Stats: `sent`, `failed`, `queued`, `retries`, `startTime`, `lastSendTime`.
- `initialize` invokes `transport.initialize(transportOptions)` then
`transport.verify()` if present; verification failure raises
`EMAILER.VERIFY_FAILED`.
- `close` calls the transport's `close` and emits `service:closed`.
## Usage: Basic
```ts
import { EmailService } from "@suaveplan/emailer/core/email-service";
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
const service = new EmailService({
transport: createDummyTransport(),
transportOptions: {},
defaultFrom: "no-reply@example.com",
});
await service.initialize();
const result = await service.send({
to: "user@example.com",
subject: "hello",
text: "hi",
});
```
## Usage: Advanced
```ts
import { EmailService } from "@suaveplan/emailer/core/email-service";
import { GmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
const fake = new FakeFetch();
fake.push({ status: 200, json: { id: "abc123" } });
const ac = new AbortController();
const service = new EmailService({
transport: new GmailRestTransport(),
transportOptions: { accessToken: "ya29.test", fetch: fake.fetch },
retry: { maxAttempts: 3, delay: 50, backoff: "exponential" },
signal: ac.signal,
hooks: {
beforeSend: [async (ctx) => logger.info({ to: ctx.message.to })],
afterSend: [async (ctx) => audit.record(ctx.result.messageId)],
},
});
await service.initialize();
const result = await service.send({ to: "x@y.z", subject: "hi", text: "hi" });
console.info(service.getStats().sent); // 1
await service.close();
```
## API Reference
- `class EmailService<TTransport extends EmailTransport>`
- `constructor(options: EmailerOptions<TTransport>)`
- `initialize(): Promise<void>`
- `send(message: EmailMessage): Promise<EmailSendResult>`
- `queue(message: EmailMessage): Promise<void>`
- `addHook<K>(event, hook)` / `removeHook<K>(event, hook)` / `clearHooks<K>(event)`
- `getStats(): Readonly<EmailerStats>` / `resetStats(): void`
- `close(): Promise<void>`
## Implementation Notes
- `send` returns `EmailSendResult` (never throws) — failures surface as
`{ success: false, error }` so callers can decide whether to log, retry,
or escalate without a `try`/`catch`.
- `queue` throws `EMAILER.QUEUE_NOT_CONFIGURED` if no `QueueLike` was
supplied; that is the only public method that uses an exception for
control flow.
- The Zod validation in the constructor only checks the serialisable
subset of options. Live objects (transport, logger, bus, signal) pass
through unchanged because Zod cannot meaningfully validate them.
- Stats are mutable internally but the `getStats()` snapshot is a copy so
callers cannot observe a partially-mutated counter.

View File

@@ -0,0 +1,451 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock, LoggerLike, TimersLike } from "@suaveplan/types";
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
import {
createDummyTransport,
type DummyTransport,
} from "../../transports/dummy/dummy-transport.js";
import type {
EmailerHooks,
EmailHookContext,
EmailMessage,
EmailResultHookContext,
EventBusLike,
QueueLike,
} from "../../types/emailer.types.js";
import { EmailService } from "./email-service.js";
function recorder(): EventBusLike & { events: Array<[string, unknown]> } {
const events: Array<[string, unknown]> = [];
return {
events,
emit: (e, p) => {
events.push([e, p]);
},
};
}
const syncTimers: TimersLike = {
setTimer: (cb) => {
cb();
return 0;
},
clearTimer: () => {
/* noop */
},
};
function fixedClock(): Clock {
let now = 1000;
return () => {
now += 1;
return now;
};
}
function makeService(
overrides: Partial<{
hooks: EmailerHooks;
transport: DummyTransport;
queue?: QueueLike;
retry?: {
maxAttempts: number;
delay: number;
backoff?: "linear" | "exponential";
};
events?: EventBusLike;
}> = {}
): { service: EmailService<DummyTransport>; transport: DummyTransport } {
const transport = overrides.transport ?? createDummyTransport();
const service = new EmailService<DummyTransport>({
transport,
transportOptions: {},
defaultFrom: { email: "noreply@example.com", name: "NoReply" },
clock: fixedClock(),
timers: syncTimers,
...(overrides.hooks !== undefined ? { hooks: overrides.hooks } : {}),
...(overrides.queue !== undefined ? { queue: overrides.queue } : {}),
...(overrides.retry !== undefined ? { retry: overrides.retry } : {}),
...(overrides.events !== undefined ? { events: overrides.events } : {}),
});
return { service, transport };
}
const validMessage: EmailMessage = {
to: "user@example.com",
subject: "hello",
text: "hi",
};
describe("EmailService", () => {
it("sends successfully via configured transport", {
timeout: 5000,
}, async () => {
const { service, transport } = makeService();
await service.initialize();
const r = await service.send({ ...validMessage });
expect(r.success).toBe(true);
expect(transport.getSendCount()).toBe(1);
expect(service.getStats().sent).toBe(1);
expect(service.getStats().lastSendTime).toBeDefined();
});
it("records failure for invalid message", { timeout: 5000 }, async () => {
const events = recorder();
const { service } = makeService({ events });
await service.initialize();
const r = await service.send({
to: "",
subject: "hi",
text: "hi",
});
expect(r.success).toBe(false);
expect(service.getStats().failed).toBe(1);
expect(events.events.some(([e]) => e === "email:failed")).toBe(true);
});
it("runs lifecycle hooks in declared order", {
timeout: 5000,
}, async () => {
const order: string[] = [];
const hooks: EmailerHooks = {
beforeValidate: [
async () => {
order.push("beforeValidate");
},
],
afterValidate: [
async () => {
order.push("afterValidate");
},
],
beforeSend: [
async () => {
order.push("beforeSend");
},
],
afterSend: [
async () => {
order.push("afterSend");
},
],
};
const { service } = makeService({ hooks });
await service.initialize();
await service.send({ ...validMessage });
expect(order).toEqual([
"beforeValidate",
"afterValidate",
"beforeSend",
"afterSend",
]);
});
it("addHook / removeHook / clearHooks mutate the hook list", {
timeout: 5000,
}, async () => {
const { service } = makeService();
await service.initialize();
let calls = 0;
const hook = async (_ctx: EmailHookContext): Promise<void> => {
calls += 1;
};
service.addHook("beforeSend", hook);
await service.send({ ...validMessage });
expect(calls).toBe(1);
service.removeHook("beforeSend", hook);
await service.send({ ...validMessage });
expect(calls).toBe(1);
service.addHook("beforeSend", hook);
service.clearHooks("beforeSend");
await service.send({ ...validMessage });
expect(calls).toBe(1);
});
it("queue() enqueues a closure that sends", { timeout: 5000 }, async () => {
let captured: (() => Promise<void>) | undefined;
const queue: QueueLike = {
add: async (fn) => {
captured = fn;
},
};
const { service, transport } = makeService({ queue });
await service.initialize();
await service.queue({ ...validMessage });
expect(service.getStats().queued).toBe(1);
expect(captured).toBeDefined();
await captured?.();
expect(transport.getSendCount()).toBe(1);
});
it("queue() throws QUEUE_NOT_CONFIGURED when no queue is wired", {
timeout: 5000,
}, async () => {
const { service } = makeService();
await service.initialize();
await expect(service.queue({ ...validMessage })).rejects.toMatchObject({
code: EMAILER_CODES.QUEUE_NOT_CONFIGURED,
});
});
it("retries failed sends with stats increment", {
timeout: 5000,
}, async () => {
const transport = createDummyTransport();
await transport.initialize({ shouldFail: true });
const { service } = makeService({
transport,
retry: { maxAttempts: 3, delay: 1, backoff: "linear" },
});
// skip initialize since we manually set transport
const r = await service.send({ ...validMessage });
expect(r.success).toBe(false);
expect(service.getStats().retries).toBeGreaterThanOrEqual(2);
});
it("constructor validates options via Zod", { timeout: 5000 }, () => {
try {
new EmailService({
transport: createDummyTransport(),
transportOptions: {},
retry: { maxAttempts: -1, delay: 0 },
});
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(EmailerError);
expect((e as EmailerError).code).toBe(EMAILER_CODES.INVALID_CONFIG);
}
});
it("initialize raises VERIFY_FAILED when transport.verify fails", {
timeout: 5000,
}, async () => {
const transport = createDummyTransport();
const service = new EmailService<DummyTransport>({
transport,
transportOptions: { verifySuccess: false },
clock: fixedClock(),
timers: syncTimers,
});
await expect(service.initialize()).rejects.toMatchObject({
code: EMAILER_CODES.VERIFY_FAILED,
});
});
it("applies defaultFrom when message.from missing", {
timeout: 5000,
}, async () => {
const { service } = makeService();
await service.initialize();
const r = await service.send({ ...validMessage });
expect(r.success).toBe(true);
});
it("resetStats clears counters", { timeout: 5000 }, async () => {
const { service } = makeService();
await service.initialize();
await service.send({ ...validMessage });
service.resetStats();
const stats = service.getStats();
expect(stats.sent).toBe(0);
expect(stats.failed).toBe(0);
expect(stats.lastSendTime).toBeUndefined();
});
it("close calls transport.close and emits service:closed", {
timeout: 5000,
}, async () => {
const events = recorder();
const { service } = makeService({ events });
await service.initialize();
await service.close();
expect(events.events.some(([e]) => e === "service:closed")).toBe(true);
});
it("afterSend receives the result context", { timeout: 5000 }, async () => {
let captured: EmailResultHookContext | undefined;
const hooks: EmailerHooks = {
afterSend: [
async (ctx) => {
captured = ctx;
},
],
};
const { service } = makeService({ hooks });
await service.initialize();
await service.send({ ...validMessage });
expect(captured?.result.success).toBe(true);
});
it("noop logger / event bus when not supplied", {
timeout: 5000,
}, async () => {
const transport = createDummyTransport();
const service = new EmailService<DummyTransport>({
transport,
transportOptions: {},
clock: fixedClock(),
timers: syncTimers,
});
await service.initialize();
const r = await service.send({ ...validMessage });
expect(r.success).toBe(true);
await service.close();
});
it("uses supplied logger when available", { timeout: 5000 }, async () => {
const logged: string[] = [];
const logger: LoggerLike = {
debug: () => undefined,
info: (msg) => {
logged.push(msg);
},
warn: () => undefined,
error: () => undefined,
};
const transport = createDummyTransport();
const service = new EmailService<DummyTransport>({
transport,
transportOptions: {},
clock: fixedClock(),
timers: syncTimers,
logger,
});
await service.initialize();
await service.send({ ...validMessage });
expect(logged.some((s) => s.startsWith("emailer."))).toBe(true);
});
it("beforeQueue / afterQueue fire around queue dispatch", {
timeout: 5000,
}, async () => {
const order: string[] = [];
const queue: QueueLike = {
add: async (fn) => {
order.push("add");
await fn();
},
};
const hooks: EmailerHooks = {
beforeQueue: [
async () => {
order.push("beforeQueue");
},
],
afterQueue: [
async () => {
order.push("afterQueue");
},
],
};
const { service } = makeService({ queue, hooks });
await service.initialize();
await service.queue({ ...validMessage });
expect(order).toEqual(["beforeQueue", "add", "afterQueue"]);
});
it("send returns failed result when transport throws non-Emailer error", {
timeout: 5000,
}, async () => {
const transport = createDummyTransport();
await transport.initialize({});
// monkey-patch a single send to throw a plain error
const original = transport.send.bind(transport);
let calls = 0;
transport.send = async (m) => {
calls += 1;
if (calls === 1) throw new Error("boom");
return original(m);
};
const service = new EmailService<DummyTransport>({
transport,
transportOptions: undefined,
clock: fixedClock(),
timers: syncTimers,
});
const r = await service.send({ ...validMessage });
expect(r.success).toBe(false);
expect(r.error?.message).toBe("boom");
});
it("initialize skips transport.initialize when transportOptions undefined", {
timeout: 5000,
}, async () => {
const transport = createDummyTransport();
await transport.initialize({}); // pre-initialise manually
const service = new EmailService<DummyTransport>({
transport,
clock: fixedClock(),
timers: syncTimers,
});
await service.initialize();
const r = await service.send({ ...validMessage });
expect(r.success).toBe(true);
});
it("close skips transport.close when transport has no close method", {
timeout: 5000,
}, async () => {
const transport = createDummyTransport();
await transport.initialize({});
// strip close
(transport as unknown as { close?: () => Promise<void> }).close =
undefined;
const service = new EmailService<DummyTransport>({
transport,
clock: fixedClock(),
timers: syncTimers,
});
await expect(service.close()).resolves.toBeUndefined();
});
it("initialize skips verify when transport.verify undefined", {
timeout: 5000,
}, async () => {
const transport = createDummyTransport();
await transport.initialize({});
(transport as unknown as { verify?: () => Promise<boolean> }).verify =
undefined;
const service = new EmailService<DummyTransport>({
transport,
clock: fixedClock(),
timers: syncTimers,
});
await expect(service.initialize()).resolves.toBeUndefined();
});
it("removeHook is a no-op when hook is not registered", {
timeout: 5000,
}, async () => {
const { service } = makeService();
await service.initialize();
service.removeHook("beforeSend", async () => undefined);
const r = await service.send({ ...validMessage });
expect(r.success).toBe(true);
});
it("beforeRetry fires between attempts", { timeout: 5000 }, async () => {
const transport = createDummyTransport();
await transport.initialize({ shouldFail: true });
const retryEvents: number[] = [];
const hooks: EmailerHooks = {
beforeRetry: [
async (ctx) => {
if (ctx.attempt !== undefined) {
retryEvents.push(ctx.attempt);
}
},
],
};
const service = new EmailService<DummyTransport>({
transport,
transportOptions: undefined,
retry: { maxAttempts: 3, delay: 0 },
hooks,
clock: fixedClock(),
timers: syncTimers,
});
await service.send({ ...validMessage });
expect(retryEvents.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,326 @@
import type { Clock, LoggerLike, TimersLike } from "@suaveplan/types";
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
import {
recordQueue,
recordRetry,
recordSend,
recordSendError,
} from "../../otel/metrics.js";
import { EmailerOptionsShapeSchema } from "../../schemas/schemas.js";
import type {
EmailErrorHookContext,
EmailerHooks,
EmailerOptions,
EmailerStats,
EmailHookContext,
EmailMessage,
EmailResultHookContext,
EmailSendResult,
EmailTransport,
EventBusLike,
HookFunction,
MutableEmailerHooks,
QueueLike,
RetryPolicy,
} from "../../types/emailer.types.js";
import { defaultTimers, runWithRetry } from "../retry/retry.js";
import { validateMessage as validateEmail } from "../validate/validate.js";
const noop = (): void => {
/* shared no-op */
};
const noopLogger: LoggerLike = {
debug: noop,
info: noop,
warn: noop,
error: noop,
};
const noopBus: EventBusLike = { emit: noop };
const defaultClock: Clock = () => Date.now();
function emptyHooks(): MutableEmailerHooks {
return {
beforeValidate: [],
afterValidate: [],
beforeSend: [],
afterSend: [],
onError: [],
afterError: [],
beforeRetry: [],
beforeQueue: [],
afterQueue: [],
};
}
function mergeHooks(input: EmailerHooks | undefined): MutableEmailerHooks {
const out = emptyHooks();
if (input === undefined) return out;
for (const key of Object.keys(out) as Array<keyof MutableEmailerHooks>) {
const incoming = input[key];
if (incoming !== undefined) {
(out[key] as Array<HookFunction<EmailHookContext>>).push(
...(incoming as ReadonlyArray<HookFunction<EmailHookContext>>)
);
}
}
return out;
}
/**
* Universal email service. Orchestrates validation, lifecycle hooks,
* retry, queue dispatch, OTel emission, and stats accumulation against a
* pluggable `EmailTransport`.
*/
export class EmailService<TTransport extends EmailTransport = EmailTransport> {
readonly #transport: TTransport;
readonly #defaultFrom: EmailerOptions<TTransport>["defaultFrom"];
readonly #logger: LoggerLike;
readonly #events: EventBusLike;
readonly #queue: QueueLike | undefined;
readonly #retry: RetryPolicy | undefined;
readonly #hooks: MutableEmailerHooks;
readonly #clock: Clock;
readonly #timers: TimersLike;
readonly #signal: AbortSignal | undefined;
readonly #transportOptions: unknown;
readonly #stats: EmailerStats;
public constructor(options: EmailerOptions<TTransport>) {
const parsed = EmailerOptionsShapeSchema.safeParse({
defaultFrom: options.defaultFrom,
retry: options.retry,
});
if (!parsed.success) {
throw new EmailerError(
EMAILER_CODES.INVALID_CONFIG,
"emailer.errors.invalidConfig",
{ cause: parsed.error }
);
}
this.#transport = options.transport;
this.#defaultFrom = options.defaultFrom;
this.#logger = options.logger ?? noopLogger;
this.#events = options.events ?? noopBus;
this.#queue = options.queue;
this.#retry = options.retry;
this.#hooks = mergeHooks(options.hooks);
this.#clock = options.clock ?? defaultClock;
this.#timers = options.timers ?? defaultTimers;
this.#signal = options.signal;
this.#transportOptions = options.transportOptions;
this.#stats = {
sent: 0,
failed: 0,
queued: 0,
retries: 0,
startTime: new Date(this.#clock()),
};
}
/** Initialise the underlying transport. */
public async initialize(): Promise<void> {
this.#logger.info("emailer.initialize", {
transport: this.#transport.name,
});
if (this.#transportOptions !== undefined) {
await this.#transport.initialize(
this.#transportOptions as Parameters<
TTransport["initialize"]
>[0]
);
}
if (this.#transport.verify !== undefined) {
const ok = await this.#transport.verify();
if (!ok) {
throw new EmailerError(
EMAILER_CODES.VERIFY_FAILED,
"emailer.errors.verifyFailed",
{ context: { transport: this.#transport.name } }
);
}
}
this.#events.emit("service:initialized", {
transport: this.#transport.name,
});
}
/** Send `message`. */
public async send(message: EmailMessage): Promise<EmailSendResult> {
const context: EmailHookContext = {
message,
metadata: {},
timestamp: new Date(this.#clock()),
};
const startedAt = this.#clock();
try {
if (message.from === undefined && this.#defaultFrom !== undefined) {
message.from = this.#defaultFrom;
}
await this.#runHooks(this.#hooks.beforeValidate, context);
validateEmail(message);
await this.#runHooks(this.#hooks.afterValidate, context);
await this.#runHooks(this.#hooks.beforeSend, context);
const result = await this.#sendWithRetry(message, context);
const resultContext: EmailResultHookContext = {
...context,
result,
};
await this.#runHooks(this.#hooks.afterSend, resultContext);
this.#stats.sent += 1;
this.#stats.lastSendTime = new Date(this.#clock());
this.#events.emit("email:sent", { message, result });
this.#logger.info("emailer.sent", { messageId: result.messageId });
recordSend(this.#transport.name, "ok", this.#clock() - startedAt);
return result;
} catch (error) {
const err = error as Error;
const errorContext: EmailErrorHookContext = {
...context,
error: err,
};
await this.#runHooks(this.#hooks.onError, errorContext);
await this.#runHooks(this.#hooks.afterError, errorContext);
this.#stats.failed += 1;
this.#events.emit("email:failed", { message, error: err });
this.#logger.error("emailer.failed", { error: err.message });
const code =
err instanceof EmailerError
? err.code
: EMAILER_CODES.TRANSPORT_FAILED;
recordSendError(this.#transport.name, code);
recordSend(
this.#transport.name,
"error",
this.#clock() - startedAt
);
return {
success: false,
error: err,
timestamp: new Date(this.#clock()),
};
}
}
async #sendWithRetry(
message: EmailMessage,
context: EmailHookContext
): Promise<EmailSendResult> {
const policy = this.#retry ?? { maxAttempts: 1, delay: 0 };
const transport = this.#transport;
const transportName = transport.name;
const hooks = this.#hooks;
const runHooks = this.#runHooks.bind(this);
const stats = this.#stats;
return runWithRetry<EmailSendResult>(
policy,
this.#timers,
this.#signal,
{
attempt: (attempt: number) =>
attempt === 1
? transport.send(message)
: transport.send(message),
isSuccess: (result) =>
result.success && result.error === undefined,
extractError: (result) => result.error,
onBeforeRetry: async (error, nextAttempt) => {
const errorContext: EmailErrorHookContext = {
...context,
error,
attempt: nextAttempt,
};
await runHooks(hooks.beforeRetry, errorContext);
},
onRetryIncrement: () => {
stats.retries += 1;
recordRetry(transportName);
},
}
);
}
/** Queue `message` for asynchronous send. */
public async queue(message: EmailMessage): Promise<void> {
if (this.#queue === undefined) {
throw new EmailerError(
EMAILER_CODES.QUEUE_NOT_CONFIGURED,
"emailer.errors.queueNotConfigured"
);
}
const context: EmailHookContext = {
message,
metadata: {},
timestamp: new Date(this.#clock()),
};
await this.#runHooks(this.#hooks.beforeQueue, context);
const sendFn = (): Promise<void> =>
this.send(message).then(() => undefined);
await this.#queue.add(sendFn);
await this.#runHooks(this.#hooks.afterQueue, context);
this.#stats.queued += 1;
recordQueue(this.#transport.name);
this.#events.emit("email:queued", { message });
this.#logger.info("emailer.queued", {});
}
async #runHooks<TContext>(
hooks: ReadonlyArray<HookFunction<TContext>>,
context: TContext
): Promise<void> {
for (const hook of hooks) {
await hook(context);
}
}
/** Append a hook. */
public addHook<K extends keyof EmailerHooks>(
event: K,
hook: NonNullable<EmailerHooks[K]>[number]
): void {
(this.#hooks[event] as Array<typeof hook>).push(hook);
}
/** Remove a hook by identity. */
public removeHook<K extends keyof EmailerHooks>(
event: K,
hook: NonNullable<EmailerHooks[K]>[number]
): void {
const list = this.#hooks[event] as Array<typeof hook>;
const index = list.indexOf(hook);
if (index >= 0) list.splice(index, 1);
}
/** Clear every hook for `event`. */
public clearHooks<K extends keyof EmailerHooks>(event: K): void {
(this.#hooks[event] as Array<unknown>).length = 0;
}
/** Read-only stats snapshot. */
public getStats(): Readonly<EmailerStats> {
return { ...this.#stats };
}
/** Reset stats to a fresh snapshot. */
public resetStats(): void {
this.#stats.sent = 0;
this.#stats.failed = 0;
this.#stats.queued = 0;
this.#stats.retries = 0;
this.#stats.startTime = new Date(this.#clock());
delete this.#stats.lastSendTime;
}
/** Close the underlying transport. */
public async close(): Promise<void> {
this.#logger.info("emailer.close", { transport: this.#transport.name });
if (this.#transport.close !== undefined) {
await this.#transport.close();
}
this.#events.emit("service:closed", {
transport: this.#transport.name,
});
}
}

View File

@@ -0,0 +1,2 @@
export { BaseEmailTransport } from "./base-email-transport.js";
export { EmailService } from "./email-service.js";

View File

@@ -0,0 +1,18 @@
export {
BaseEmailTransport,
EmailService,
} from "./email-service/index.js";
export {
computeBackoff,
defaultTimers,
delay,
type RetryHooks,
runWithRetry,
} from "./retry/retry.js";
export {
extractEmail,
formatAddress,
formatSingleAddress,
toEmailArray,
validateMessage,
} from "./validate/validate.js";

View File

@@ -0,0 +1,7 @@
export {
computeBackoff,
defaultTimers,
delay,
type RetryHooks,
runWithRetry,
} from "./retry.js";

View File

@@ -0,0 +1,96 @@
# `retry.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Deterministic retry helper used by `EmailService.send`. The helper takes a
`RetryPolicy`, an injectable `TimersLike`, an optional `AbortSignal`, and a
`RetryHooks` bag, then drives a bounded attempt loop with linear or
exponential backoff. Injecting the timers means tests can drive the loop
through synthetic delays without scheduling real `setTimeout` callbacks,
which is essential for deterministic CI runs on WSL2.
## Features
- `computeBackoff(policy, attempt)` — returns the delay between `attempt`
and `attempt + 1` (linear: constant `delay`; exponential: `delay * 2^(n-1)`).
- `delay(timers, delayMs, signal)` — schedules a single delay and rejects
early if `signal` is aborted.
- `runWithRetry<TResult>(policy, timers, signal, hooks)` — top-level loop:
invokes `hooks.attempt(n)`, checks `hooks.isSuccess`, fires
`hooks.onError` / `hooks.onBeforeRetry`, increments retry stats via
`hooks.onRetryIncrement`, and sleeps `computeBackoff` between failed
attempts.
- `defaultTimers` — host-default `setTimeout` / `clearTimeout` pair used
when no `TimersLike` is supplied.
## Usage: Basic
```ts
import { runWithRetry, defaultTimers } from "@suaveplan/emailer/core/retry";
const result = await runWithRetry(
{ maxAttempts: 3, delay: 100, backoff: "exponential" },
defaultTimers,
undefined,
{
attempt: () => transport.send(message),
isSuccess: (r) => r.success,
extractError: (r) => r.error,
},
);
```
## Usage: Advanced
```ts
import { runWithRetry } from "@suaveplan/emailer/core/retry";
import type { TimersLike } from "@suaveplan/types";
const fakeTimers: TimersLike = {
setTimer: (cb) => {
cb();
return 0;
},
clearTimer: () => undefined,
};
const ac = new AbortController();
const result = await runWithRetry(
{ maxAttempts: 5, delay: 50, backoff: "linear" },
fakeTimers,
ac.signal,
{
attempt: (n) => transport.send({ ...message, headers: { "X-Attempt": String(n) } }),
isSuccess: (r) => r.success,
extractError: (r) => r.error,
onError: async (err, n) => logger.warn("send-failed", { err, n }),
onBeforeRetry: async (err, next) => logger.info("retrying", { next }),
onRetryIncrement: () => stats.retries++,
},
);
```
## API Reference
- `computeBackoff(policy: RetryPolicy, attempt: number): number`
- `delay(timers: TimersLike, delayMs: number, signal: AbortSignal | undefined): Promise<void>`
- `runWithRetry<TResult>(policy, timers, signal, hooks): Promise<TResult>`
- `defaultTimers: TimersLike`
- `RetryHooks<TResult>` — interface
## Implementation Notes
- The helper does not throw on a failed-but-defined result; it returns it
unchanged so the caller can decide whether to surface the result or
raise. This is what enables `EmailService` to record a stat increment
and emit `email:failed` while still returning a result to the caller.
- `AbortSignal` is honoured at three points: before every attempt, before
every sleep, and as a sleep listener. Aborting between attempts raises
`EmailerError(EMAILER.ABORTED)` rather than the last transport error.
- The default `RetryPolicy` (`{ maxAttempts: 1, delay: 0 }`) effectively
disables retry. `EmailService` substitutes this when no `retry` option
is passed; the helper runs once and returns the result unchanged.

View File

@@ -0,0 +1,266 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
import { computeBackoff, defaultTimers, delay, runWithRetry } from "./retry.js";
interface FakeTimers extends TimersLike {
readonly delays: number[];
}
function fakeTimers(): FakeTimers {
const delays: number[] = [];
let counter = 1;
return {
delays,
setTimer(cb, delayMs) {
delays.push(delayMs);
cb();
return counter++;
},
clearTimer() {
/* noop */
},
};
}
describe("computeBackoff", () => {
it("returns constant for linear", { timeout: 5000 }, () => {
expect(computeBackoff({ maxAttempts: 3, delay: 100 }, 1)).toBe(100);
expect(
computeBackoff({ maxAttempts: 3, delay: 100, backoff: "linear" }, 3)
).toBe(100);
});
it("doubles for exponential", { timeout: 5000 }, () => {
const p = {
maxAttempts: 4,
delay: 50,
backoff: "exponential" as const,
};
expect(computeBackoff(p, 1)).toBe(50);
expect(computeBackoff(p, 2)).toBe(100);
expect(computeBackoff(p, 3)).toBe(200);
});
});
describe("delay", () => {
it("rejects immediately when already aborted", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
ac.abort();
await expect(delay(defaultTimers, 5, ac.signal)).rejects.toBeInstanceOf(
EmailerError
);
});
it("rejects when aborted during sleep", { timeout: 5000 }, async () => {
const ac = new AbortController();
const p = delay(defaultTimers, 50, ac.signal);
ac.abort();
await expect(p).rejects.toBeInstanceOf(EmailerError);
});
it("resolves after the timer fires", { timeout: 5000 }, async () => {
await expect(
delay(fakeTimers(), 10, undefined)
).resolves.toBeUndefined();
});
it("removes abort listener when timer fires first", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
await expect(
delay(fakeTimers(), 5, ac.signal)
).resolves.toBeUndefined();
});
});
describe("runWithRetry", () => {
it("returns first success without sleeping", {
timeout: 5000,
}, async () => {
const timers = fakeTimers();
const result = await runWithRetry<{ success: true }>(
{ maxAttempts: 3, delay: 10 },
timers,
undefined,
{
attempt: async () => ({ success: true as const }),
isSuccess: (r) => r.success,
extractError: () => undefined,
}
);
expect(result.success).toBe(true);
expect(timers.delays).toEqual([]);
});
it("applies exponential backoff schedule", { timeout: 5000 }, async () => {
const timers = fakeTimers();
let calls = 0;
const result = await runWithRetry<{ success: boolean; error?: Error }>(
{ maxAttempts: 3, delay: 100, backoff: "exponential" },
timers,
undefined,
{
attempt: async () => {
calls += 1;
if (calls < 3) {
return { success: false, error: new Error("nope") };
}
return { success: true };
},
isSuccess: (r) => r.success,
extractError: (r) => r.error,
onRetryIncrement: () => {
/* counted */
},
}
);
expect(result.success).toBe(true);
expect(timers.delays).toEqual([100, 200]);
});
it("aborts pending retry on AbortSignal", { timeout: 5000 }, async () => {
const ac = new AbortController();
let calls = 0;
const promise = runWithRetry<{ success: boolean; error?: Error }>(
{ maxAttempts: 5, delay: 50 },
defaultTimers,
ac.signal,
{
attempt: async () => {
calls += 1;
if (calls === 1) {
queueMicrotask(() => ac.abort());
}
return { success: false, error: new Error("nope") };
},
isSuccess: (r) => r.success,
extractError: (r) => r.error,
}
);
await expect(promise).rejects.toBeInstanceOf(EmailerError);
});
it("with maxAttempts === 1 throws underlying error when no result", {
timeout: 5000,
}, async () => {
const timers = fakeTimers();
try {
await runWithRetry<{ success: boolean }>(
{ maxAttempts: 1, delay: 0 },
timers,
undefined,
{
attempt: async () => {
throw new Error("plain boom");
},
isSuccess: (r) => r.success,
extractError: () => undefined,
}
);
expect.unreachable();
} catch (e) {
expect((e as Error).message).toBe("plain boom");
}
});
it("throws RETRY_EXHAUSTED when extractor returns undefined on every failure", {
timeout: 5000,
}, async () => {
const timers = fakeTimers();
try {
await runWithRetry<{ success: boolean }>(
{ maxAttempts: 2, delay: 0 },
timers,
undefined,
{
attempt: async () => ({ success: false }),
isSuccess: (r) => r.success,
extractError: () => undefined,
}
);
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(EmailerError);
expect((e as EmailerError).code).toBe(
EMAILER_CODES.RETRY_EXHAUSTED
);
}
});
it("fires onError and onBeforeRetry between attempts", {
timeout: 5000,
}, async () => {
const timers = fakeTimers();
const events: string[] = [];
let calls = 0;
await runWithRetry<{ success: boolean; error?: Error }>(
{ maxAttempts: 3, delay: 0 },
timers,
undefined,
{
attempt: async () => {
calls += 1;
if (calls < 2) {
return { success: false, error: new Error("nope") };
}
return { success: true };
},
isSuccess: (r) => r.success,
extractError: (r) => r.error,
onError: async () => {
events.push("onError");
},
onBeforeRetry: async () => {
events.push("onBeforeRetry");
},
onRetryIncrement: () => {
events.push("inc");
},
}
);
expect(events).toEqual(["onError", "onBeforeRetry", "inc"]);
});
it("returns the last failed result when isSuccess is never true and extractor exists", {
timeout: 5000,
}, async () => {
const timers = fakeTimers();
const result = await runWithRetry<{ success: boolean; error?: Error }>(
{ maxAttempts: 2, delay: 0 },
timers,
undefined,
{
attempt: async () => ({
success: false,
error: new Error("nope"),
}),
isSuccess: (r) => r.success,
extractError: (r) => r.error,
}
);
expect(result.success).toBe(false);
});
it("throws ABORTED when signal is already set at loop entry", {
timeout: 5000,
}, async () => {
const ac = new AbortController();
ac.abort();
await expect(
runWithRetry<{ success: boolean }>(
{ maxAttempts: 1, delay: 0 },
defaultTimers,
ac.signal,
{
attempt: async () => ({ success: false }),
isSuccess: (r) => r.success,
extractError: () => undefined,
}
)
).rejects.toBeInstanceOf(EmailerError);
});
});

View File

@@ -0,0 +1,149 @@
import type { TimersLike } from "@suaveplan/types";
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
import type { RetryPolicy } from "../../types/emailer.types.js";
/** Default timer pair — `setTimeout` / `clearTimeout` from the host. */
export const defaultTimers: TimersLike = {
setTimer: (callback, delayMs) =>
setTimeout(callback, delayMs) as unknown as number,
clearTimer: (handle) => {
clearTimeout(handle);
},
};
/** Compute backoff delay for `attempt` (1-indexed). */
export function computeBackoff(policy: RetryPolicy, attempt: number): number {
if (policy.backoff === "exponential") {
return policy.delay * 2 ** (attempt - 1);
}
return policy.delay;
}
/** Schedule a delay via the injectable timer, with AbortSignal short-circuit. */
export function delay(
timers: TimersLike,
delayMs: number,
signal: AbortSignal | undefined
): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(
new EmailerError(
EMAILER_CODES.ABORTED,
"emailer.errors.aborted"
)
);
return;
}
const handle = timers.setTimer(() => {
if (signal !== undefined) {
signal.removeEventListener("abort", onAbort);
}
resolve();
}, delayMs);
function onAbort(): void {
timers.clearTimer(handle);
reject(
new EmailerError(
EMAILER_CODES.ABORTED,
"emailer.errors.aborted"
)
);
}
if (signal !== undefined) {
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
export interface RetryHooks<TResult> {
readonly attempt: (attempt: number) => Promise<TResult>;
readonly onError?: (error: Error, attempt: number) => Promise<void>;
readonly onBeforeRetry?: (
error: Error,
nextAttempt: number
) => Promise<void>;
readonly onRetryIncrement?: () => void;
readonly isSuccess: (result: TResult) => boolean;
readonly extractError: (result: TResult) => Error | undefined;
}
/**
* Run `hooks.attempt` up to `policy.maxAttempts` times. Between attempts
* the helper schedules a backoff delay via the injected timers (so tests
* are deterministic) and honours `signal` for cancellation.
*/
export async function runWithRetry<TResult>(
policy: RetryPolicy,
timers: TimersLike,
signal: AbortSignal | undefined,
hooks: RetryHooks<TResult>
): Promise<TResult> {
let lastError: Error | undefined;
let lastResult: TResult | undefined;
for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
if (signal?.aborted) {
throw new EmailerError(
EMAILER_CODES.ABORTED,
"emailer.errors.aborted"
);
}
try {
const result = await hooks.attempt(attempt);
if (hooks.isSuccess(result)) {
return result;
}
const reportedError = hooks.extractError(result);
if (reportedError !== undefined) {
lastError = reportedError;
lastResult = result;
} else {
lastError = new EmailerError(
EMAILER_CODES.TRANSPORT_FAILED,
"emailer.errors.transportFailed"
);
// Do not set lastResult: caller cannot meaningfully consume
// a non-success with no extractable error.
}
} catch (error) {
lastError = error as Error;
}
const isLast = attempt >= policy.maxAttempts;
if (!isLast && lastError !== undefined) {
if (hooks.onError !== undefined) {
await hooks.onError(lastError, attempt);
}
const nextAttempt = attempt + 1;
if (hooks.onBeforeRetry !== undefined) {
await hooks.onBeforeRetry(lastError, nextAttempt);
}
if (hooks.onRetryIncrement !== undefined) {
hooks.onRetryIncrement();
}
await delay(timers, computeBackoff(policy, attempt), signal);
}
}
if (lastResult !== undefined && !hooks.isSuccess(lastResult)) {
return lastResult;
}
// All attempts exhausted with no usable result. When the policy
// permits multiple attempts, surface the typed `RETRY_EXHAUSTED` so
// callers can distinguish "transient failure rolled over" from the
// first-attempt error. With `maxAttempts === 1`, re-throw the
// underlying error so the caller sees the cause without an extra wrap.
// lastError is guaranteed at this point: maxAttempts >= 1 (Zod-enforced),
// so the loop ran ≥1 iteration. Either lastResult is set (handled above)
// or every iteration assigned lastError.
const cause = lastError as Error;
if (policy.maxAttempts > 1) {
throw new EmailerError(
EMAILER_CODES.RETRY_EXHAUSTED,
"emailer.errors.retryExhausted",
{ cause }
);
}
throw cause;
}

View File

@@ -0,0 +1,7 @@
export {
extractEmail,
formatAddress,
formatSingleAddress,
toEmailArray,
validateMessage,
} from "./validate.js";

View File

@@ -0,0 +1,81 @@
# `validate.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Shared address-formatting and message-validation helpers used by every
concrete transport and by `EmailService` itself. Keeping these helpers in
one module means the wire-format contract for `to` / `from` / `cc` / `bcc`
/ `replyTo` is identical across `DummyTransport`, `SmtpTransport`, and
`GmailRestTransport`, and that the "to / subject / body" required-field
check is enforced at a single throw site rather than five subtly-different
copies.
## Features
- `formatSingleAddress` — `{name, email}` → `"name" <email>` per RFC 5322.
- `formatAddress` — address-bag (string | object | array) → string |
string[].
- `extractEmail` — strip the display name and unwrap `<addr>`.
- `toEmailArray` — address-bag → string[] (used by accept/reject lists).
- `validateMessage` — throws `EmailerError(EMAILER.INVALID_MESSAGE)` if the
message lacks a recipient, subject, or both bodies.
## Usage: Basic
```ts
import {
formatSingleAddress,
validateMessage,
} from "@suaveplan/emailer/core/validate";
formatSingleAddress({ email: "a@b.c", name: "Alice" });
// → "\"Alice\" <a@b.c>"
validateMessage({ to: "x@y.z", subject: "hello", text: "hi" });
// → no throw
```
## Usage: Advanced
```ts
import {
extractEmail,
formatAddress,
toEmailArray,
} from "@suaveplan/emailer/core/validate";
const bag = ["alice@example.com", { email: "bob@example.com", name: "Bob" }];
const wire = formatAddress(bag);
// → ["alice@example.com", "\"Bob\" <bob@example.com>"]
const emails = toEmailArray(wire);
// → ["alice@example.com", "bob@example.com"]
const raw = extractEmail("\"Charlie\" <c@e.com>");
// → "c@e.com"
```
## API Reference
- `formatSingleAddress(address: string | EmailAddress): string`
- `formatAddress(address: AddressBag | undefined): string | string[] | undefined`
- `extractEmail(address: string | EmailAddress): string`
- `toEmailArray(address: AddressBag | undefined): string[]`
- `validateMessage(message: EmailMessage): void`
## Implementation Notes
- `validateMessage` raises `EmailerError(EMAILER.INVALID_MESSAGE)` with the
failing field path (`to` / `subject` / `body`) carried as `context.field`
so downstream loggers know which field to call out.
- `extractEmail` accepts bare email strings (returns unchanged), addresses
in `Name <addr>` form (extracted), and `EmailAddress` objects (returns
`.email`). It deliberately does not validate the extracted string; that
is the boundary's job, not the formatter's.
- `formatAddress` returns `undefined` (not an empty string) when the input
is undefined so callers can spread the result into wire payloads without
emitting an empty header.

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EmailerError } from "../../error-codes/error-codes.js";
import {
extractEmail,
formatAddress,
formatSingleAddress,
toEmailArray,
validateMessage,
} from "./validate.js";
describe("validate helpers", () => {
it("formatSingleAddress quotes display name", { timeout: 5000 }, () => {
expect(formatSingleAddress({ email: "a@b.c", name: "Alice" })).toBe(
'"Alice" <a@b.c>'
);
expect(formatSingleAddress({ email: "a@b.c" })).toBe("a@b.c");
expect(formatSingleAddress("plain@x.y")).toBe("plain@x.y");
expect(formatSingleAddress({ email: "a@b.c", name: "" })).toBe("a@b.c");
});
it("formatAddress handles arrays and undefined", { timeout: 5000 }, () => {
expect(formatAddress(undefined)).toBeUndefined();
expect(formatAddress("a@b.c")).toBe("a@b.c");
expect(formatAddress({ email: "a@b.c" })).toBe("a@b.c");
const arr = formatAddress(["x@y.z", { email: "a@b.c", name: "A" }]);
expect(arr).toEqual(["x@y.z", '"A" <a@b.c>']);
});
it("extractEmail unwraps angle-bracket form", { timeout: 5000 }, () => {
expect(extractEmail('"Alice" <a@b.c>')).toBe("a@b.c");
expect(extractEmail("plain@x.y")).toBe("plain@x.y");
expect(extractEmail({ email: "obj@x.y" })).toBe("obj@x.y");
expect(extractEmail("no-brackets here")).toBe("no-brackets here");
});
it("toEmailArray normalises bags", { timeout: 5000 }, () => {
expect(toEmailArray(undefined)).toEqual([]);
expect(toEmailArray("a@b.c")).toEqual(["a@b.c"]);
expect(toEmailArray(["a@b.c", { email: "x@y.z" }])).toEqual([
"a@b.c",
"x@y.z",
]);
});
it("validateMessage accepts a complete message", { timeout: 5000 }, () => {
expect(() =>
validateMessage({ to: "a@b.c", subject: "hi", text: "hi" })
).not.toThrow();
expect(() =>
validateMessage({ to: "a@b.c", subject: "hi", html: "<p>hi</p>" })
).not.toThrow();
});
it("validateMessage rejects missing to", { timeout: 5000 }, () => {
try {
validateMessage({ to: "", subject: "hi", text: "hi" });
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(EmailerError);
}
try {
validateMessage({ to: [], subject: "hi", text: "hi" });
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(EmailerError);
}
});
it("validateMessage rejects missing subject", { timeout: 5000 }, () => {
try {
validateMessage({ to: "a@b.c", subject: "", text: "hi" });
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(EmailerError);
}
});
it("validateMessage rejects missing body", { timeout: 5000 }, () => {
try {
validateMessage({ to: "a@b.c", subject: "hi" });
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(EmailerError);
}
});
});

View File

@@ -0,0 +1,90 @@
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
import type {
AddressBag,
EmailAddress,
EmailMessage,
} from "../../types/emailer.types.js";
/** Format a single address into RFC 5322 form. */
export function formatSingleAddress(address: string | EmailAddress): string {
if (typeof address === "string") return address;
if (address.name !== undefined && address.name.length > 0) {
return `"${address.name}" <${address.email}>`;
}
return address.email;
}
/**
* Format an address-bag into the shape expected by SMTP / Gmail wire
* envelopes. `undefined` passes through. Arrays return an array; bare
* values return a single string.
*/
export function formatAddress(
address: AddressBag | undefined
): string | string[] | undefined {
if (address === undefined) return undefined;
if (Array.isArray(address)) {
return address.map(formatSingleAddress);
}
return formatSingleAddress(address as string | EmailAddress);
}
/** Extract the email portion from a string-or-object address. */
export function extractEmail(address: string | EmailAddress): string {
if (typeof address === "string") {
const match = address.match(/<([^>]+)>/);
if (match !== null && match[1] !== undefined) {
return match[1];
}
return address;
}
return address.email;
}
/** Extract every email address from an address-bag as a string array. */
export function toEmailArray(address: AddressBag | undefined): string[] {
if (address === undefined) return [];
if (Array.isArray(address)) {
return address.map((a) => extractEmail(a as string | EmailAddress));
}
return [extractEmail(address as string | EmailAddress)];
}
/**
* Validate that `message` carries a recipient, subject, and at least one
* body. Throws `EmailerError(EMAILER.INVALID_MESSAGE)` on failure.
*/
export function validateMessage(message: EmailMessage): void {
if (
message.to === undefined ||
message.to === null ||
(Array.isArray(message.to) && message.to.length === 0) ||
(typeof message.to === "string" && message.to.length === 0)
) {
throw new EmailerError(
EMAILER_CODES.INVALID_MESSAGE,
"emailer.errors.invalidMessage",
{ context: { field: "to" } }
);
}
if (
message.subject === undefined ||
message.subject === null ||
message.subject.length === 0
) {
throw new EmailerError(
EMAILER_CODES.INVALID_MESSAGE,
"emailer.errors.invalidMessage",
{ context: { field: "subject" } }
);
}
const hasHtml = message.html !== undefined && message.html.length > 0;
const hasText = message.text !== undefined && message.text.length > 0;
if (!hasHtml && !hasText) {
throw new EmailerError(
EMAILER_CODES.INVALID_MESSAGE,
"emailer.errors.invalidMessage",
{ context: { field: "body" } }
);
}
}

View File

@@ -0,0 +1,96 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import {
afterEach,
beforeEach,
describe,
expect,
expectTypeOf,
it,
} from "@suaveplan/testing/runner";
import { EmailService } from "./core/email-service/email-service.js";
import {
EMAILER_CODES,
type EmailerErrorCode,
} from "./error-codes/error-codes.js";
import { refreshEmailerMeters } from "./otel/metrics.js";
import { createDummyTransport } from "./transports/dummy/dummy-transport.js";
let exporter: InMemoryMetricExporter;
let provider: MeterProvider;
let reader: PeriodicExportingMetricReader;
beforeEach(() => {
exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 100_000,
});
provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
refreshEmailerMeters();
});
afterEach(async () => {
await reader.shutdown();
metrics.disable();
});
describe("emailer spec conformance", () => {
it("defineCodes literal-type union is preserved", { timeout: 5000 }, () => {
expectTypeOf<EmailerErrorCode>().toEqualTypeOf<
| "EMAILER.INVALID_CONFIG"
| "EMAILER.INVALID_MESSAGE"
| "EMAILER.TRANSPORT_NOT_INITIALIZED"
| "EMAILER.TRANSPORT_FAILED"
| "EMAILER.VERIFY_FAILED"
| "EMAILER.QUEUE_NOT_CONFIGURED"
| "EMAILER.RETRY_EXHAUSTED"
| "EMAILER.ABORTED"
| "EMAILER.AUTH_FAILED"
| "EMAILER.RATE_LIMITED"
| "EMAILER.TIMEOUT"
| "EMAILER.MIME_BUILD_FAILED"
>();
expect(Object.keys(EMAILER_CODES).length).toBe(12);
});
it("production paths emit declared OTel instruments", {
timeout: 5000,
}, async () => {
const transport = createDummyTransport();
const service = new EmailService({
transport,
transportOptions: {},
});
await service.initialize();
await service.send({ to: "a@b.c", subject: "hi", text: "hi" });
await reader.forceFlush();
const all = exporter
.getMetrics()
.flatMap((b) => b.scopeMetrics.flatMap((s) => s.metrics));
const names = all.map((m) => m.descriptor.name);
expect(names).toContain("emailer.send.total");
expect(names).toContain("emailer.send.latency_ms");
});
it("EmailService threads transport generic", { timeout: 5000 }, () => {
const transport = createDummyTransport();
const service = new EmailService<typeof transport>({
transport,
transportOptions: {},
});
// Compile-time assertion: service.send returns a Promise of EmailSendResult.
expectTypeOf(service.send).toBeCallableWith({
to: "a@b.c",
subject: "hi",
text: "hi",
});
expect(service).toBeInstanceOf(EmailService);
});
});

View File

@@ -0,0 +1,88 @@
# `error-codes.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/emailer` ships a single stable error-code registry that every
failure path in the package routes through. The registry is built via
`defineCodes("EMAILER", [...])` from `@suaveplan/error`, which freezes the
codes into a `{ KEY: "EMAILER.KEY" }` map at compile time and preserves the
literal-typed union all the way through to downstream consumers.
This guarantees three properties that hand-rolled `string` constants cannot:
1. Every code carries the `EMAILER.` prefix so it sorts and filters cleanly
alongside other `@suaveplan/*` registries in logs, metrics, and
problem+json responses.
2. The `EmailerErrorCode` union is a true literal-type union (never widened
to `string`), so exhaustive-`switch` checks and discriminated-union
handlers downstream catch missing branches at compile time.
3. `EmailerError extends SuaveplanError<EmailerErrorCode>` preserves the
typed code on every thrown instance, so the OTel error-span adapter
tags the right `error.code` attribute without inspecting the message.
## Features
- Twelve registered codes covering every failure mode: configuration,
validation, transport lifecycle, retry exhaustion, abort, auth, rate
limiting, timeout, MIME build.
- Every code is reachable from at least one production throw site —
pinned by `src/error-codes.test.ts::"every registered code is reachable"`.
- `EmailerError` constructor accepts `(code, messageKey, options)` so the
i18n hook in `@suaveplan/error` can resolve the human-readable string at
format time without ever lighting up `console.error` paths in this
package.
## Usage: Basic
```ts
import { EMAILER_CODES, EmailerError } from "@suaveplan/emailer/error-codes";
throw new EmailerError(
EMAILER_CODES.INVALID_MESSAGE,
"emailer.errors.invalidMessage",
{ context: { field: "to" } },
);
```
## Usage: Advanced
```ts
import { EMAILER_CODES, type EmailerErrorCode } from "@suaveplan/emailer/error-codes";
function describe(code: EmailerErrorCode): string {
switch (code) {
case EMAILER_CODES.RATE_LIMITED:
return "back off and retry with jitter";
case EMAILER_CODES.AUTH_FAILED:
return "refresh the access token";
case EMAILER_CODES.TRANSPORT_FAILED:
return "fall over to the next transport in the chain";
// ... every other code, exhaustively
default:
const _exhaustive: never = code;
return _exhaustive;
}
}
```
## API Reference
- `EMAILER_CODES` — frozen record of every code.
- `EmailerErrorCode` — literal-type union of every fully-qualified code.
- `EmailerError` — `class EmailerError extends SuaveplanError<EmailerErrorCode>`.
## Implementation Notes
- The registry is the contract; never delete a code to satisfy a gate. If
the conformance auditor reports a code as dead, wire it into the
production path the proposal designed it for.
- Throw `EmailerError`, never `new Error(...)`. The base class carries the
OTel attributes, redaction, and i18n hook that downstream consumers rely
on.
- The package `src/index.ts` imports `./error-codes.js` for side effects so
subpath consumers see the registered namespace even when they do not
touch the main entry point.

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EMAILER_CODES, EmailerError } from "./error-codes.js";
describe("EMAILER_CODES", () => {
it("ships twelve fully-qualified codes", { timeout: 5000 }, () => {
const values = Object.values(EMAILER_CODES);
expect(values.length).toBe(12);
for (const v of values) expect(v.startsWith("EMAILER.")).toBe(true);
});
it("every registered code is reachable from a production throw site", {
timeout: 5000,
}, async () => {
// Each of these production paths is exercised in unit tests below.
// Here we assert the registry shape only — the actual throws are
// covered by feature tests, and the spec-conformance test pins it.
const e = new EmailerError(EMAILER_CODES.INVALID_CONFIG, "x");
expect(e.code).toBe("EMAILER.INVALID_CONFIG");
expect(e.name).toBe("EmailerError");
});
});

View File

@@ -0,0 +1,44 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/emailer`. Every error raised by
* the package surfaces one of these twelve codes so downstream consumers —
* loggers, problem+json responders, retry wrappers, OTel exporters — can map
* failure to retry policy, HTTP status, and i18n keys without re-implementing
* the classifier.
*
* The registry uses `defineCodes` from `@suaveplan/error`, producing a frozen
* `{ REASON: "EMAILER.REASON" }` record at compile time so the literal-typed
* union is preserved through the public surface.
*/
export const EMAILER_CODES = defineCodes("EMAILER", [
"INVALID_CONFIG",
"INVALID_MESSAGE",
"TRANSPORT_NOT_INITIALIZED",
"TRANSPORT_FAILED",
"VERIFY_FAILED",
"QUEUE_NOT_CONFIGURED",
"RETRY_EXHAUSTED",
"ABORTED",
"AUTH_FAILED",
"RATE_LIMITED",
"TIMEOUT",
"MIME_BUILD_FAILED",
] as const);
/** Union of every fully-qualified code emitted by this package. */
export type EmailerErrorCode =
(typeof EMAILER_CODES)[keyof typeof EMAILER_CODES];
/** Typed error class for every `@suaveplan/emailer` failure. */
export class EmailerError extends SuaveplanError<EmailerErrorCode> {
public constructor(
code: EmailerErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "EmailerError";
}
}

View File

@@ -0,0 +1,5 @@
export type { EmailerErrorCode } from "./error-codes.js";
export {
EMAILER_CODES,
EmailerError,
} from "./error-codes.js";

View File

@@ -0,0 +1,76 @@
# `fake-fetch.ts`
## Purpose
Deterministic test double for `FetchLike`, used by `GmailRestTransport`
tests. Queues scripted responses and captures every call's URL + init bag
so assertions target exact payloads, headers, and methods. Shipping the
fake from the package makes downstream tests against `GmailRestTransport`
match the conventions used in the package's own test suite without
re-implementing the contract.
## Features
- `push(response)` enqueues a scripted response: `status`, `headers`,
`json` (auto-stringified), `text` (raw), or `reject` (forces a thrown
fetch).
- `calls` captures every invocation with the resolved URL and the init
bag.
- The exported `fetch` callable is `FetchLike`-shaped and directly
injectable into any transport that accepts `FetchLike`.
## Usage: Basic
```ts
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
const fake = new FakeFetch();
fake.push({ status: 200, json: { id: "abc" } });
const response = await fake.fetch("https://example.com/api");
console.info(await response.json()); // { id: "abc" }
```
## Usage: Advanced
```ts
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
import { GmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
const fake = new FakeFetch();
fake.push({ status: 429, json: { error: { message: "slow down" } } });
const transport = new GmailRestTransport();
await transport.initialize({ accessToken: "t", fetch: fake.fetch });
const r = await transport.send({ to: "a@b.c", subject: "hi", text: "hi" });
// r.error.code === "EMAILER.RATE_LIMITED"
console.info(fake.calls[0].url);
```
## API Reference
- `class FakeFetch`
- `interface FakeFetchResponse { status?; headers?; json?; text?; reject?; }`
- `interface FakeFetchCall { url: string; init?: RequestInit; }`
## Implementation Notes
- Body resolution order: `text` > `json` > empty string. `headers` always
merge into the Response.
- `reject` short-circuits the entire fetch — useful for asserting
transport failure handling, abort wiring, and timeout classification.
- The fake never spawns network IO, never opens sockets, and never holds
timers; it is safe to instantiate inside synchronous setup and to share
across describe blocks without per-test reset cost.
- The captured `init` bag is the exact `RequestInit` the transport
produced. Tests assert against it directly to confirm method, headers
(including `Authorization`), and the JSON-encoded body. Because the
capture is structural — not a string snapshot — refactors that change
unrelated header ordering do not break the test.
- The exported `fetch` is a bound arrow function so it can be passed
directly into any `FetchLike` slot without `.bind(this)`. The class
itself can be discarded once the fetch closure has been captured if
the caller does not need to inspect the calls list.
- A future enhancement may add per-response `delay` scheduling driven by
an injectable `TimersLike` so consumers can assert backoff schedules
end-to-end via `GmailRestTransport`. For now, that is the retry
helper's job — see `src/core/retry/retry.md`.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeFetch } from "./fake-fetch.js";
describe("FakeFetch", () => {
it("returns scripted responses in FIFO", { timeout: 5000 }, async () => {
const f = new FakeFetch();
f.push({ status: 200, json: { ok: true } });
const r = await f.fetch("https://e.com/a");
expect(r.status).toBe(200);
expect(await r.json()).toEqual({ ok: true });
expect(f.calls[0]!.url).toBe("https://e.com/a");
});
it("rejects when queue empty", { timeout: 5000 }, async () => {
const f = new FakeFetch();
await expect(f.fetch("https://e.com/a")).rejects.toThrow(
"no response queued"
);
});
it("rejects when scripted reject", { timeout: 5000 }, async () => {
const f = new FakeFetch();
f.push({ reject: new Error("net down") });
await expect(f.fetch("https://e.com")).rejects.toThrow("net down");
});
it("captures URL form for URL and Request", { timeout: 5000 }, async () => {
const f = new FakeFetch();
f.push({ status: 200 });
f.push({ status: 200 });
f.push({ status: 200 });
await f.fetch("https://e.com/string");
await f.fetch(new URL("https://e.com/url"));
await f.fetch(new Request("https://e.com/request"));
expect(f.calls.map((c) => c.url)).toEqual([
"https://e.com/string",
"https://e.com/url",
"https://e.com/request",
]);
});
it("text body takes precedence", { timeout: 5000 }, async () => {
const f = new FakeFetch();
f.push({ status: 200, text: "raw" });
const r = await f.fetch("https://e.com");
expect(await r.text()).toBe("raw");
});
it("defaults to empty body", { timeout: 5000 }, async () => {
const f = new FakeFetch();
f.push({});
const r = await f.fetch("https://e.com");
expect(r.status).toBe(200);
expect(await r.text()).toBe("");
});
});

View File

@@ -0,0 +1,53 @@
import type { FetchLike } from "@suaveplan/types";
/** A scripted response queued on a `FakeFetch`. */
export interface FakeFetchResponse {
readonly status?: number;
readonly headers?: Readonly<Record<string, string>>;
readonly json?: unknown;
readonly text?: string;
readonly reject?: Error;
}
/** A captured request, recorded in FIFO order. */
export interface FakeFetchCall {
readonly url: string;
readonly init: RequestInit | undefined;
}
/**
* Deterministic test double for `FetchLike`. Tests queue scripted responses,
* invoke the wrapped transport, and inspect the captured calls.
*/
export class FakeFetch {
public readonly calls: FakeFetchCall[] = [];
readonly #queue: FakeFetchResponse[] = [];
public push(response: FakeFetchResponse): void {
this.#queue.push(response);
}
public readonly fetch: FetchLike = (input, init) => {
const url =
typeof input === "string" || input instanceof URL
? String(input)
: input.url;
this.calls.push({ url, init });
const scripted = this.#queue.shift();
if (scripted === undefined) {
return Promise.reject(new Error("no response queued"));
}
if (scripted.reject !== undefined) {
return Promise.reject(scripted.reject);
}
const status = scripted.status ?? 200;
const headers = new Headers(scripted.headers ?? {});
const body =
scripted.text !== undefined
? scripted.text
: scripted.json !== undefined
? JSON.stringify(scripted.json)
: "";
return Promise.resolve(new Response(body, { status, headers }));
};
}

View File

@@ -0,0 +1,65 @@
# `fake-smtp-client.ts`
## Purpose
Deterministic test double for `SmtpClientLike`. Tests queue scripted
responses, invoke the wrapped `SmtpTransport`, and inspect the captured
calls — without `vi.mock`, without a real SMTP server, and without
monkey-patching globals. Shipping the fake from the package itself means
downstream consumers can test their integration against the same exact
shape `SmtpTransport` produces, without re-implementing the protocol fake.
## Features
- `push(response)` queues a scripted outcome (`info` for success, `reject`
for failure).
- `calls` captures every `sendMail` invocation in FIFO order so
assertions can target individual recipients / headers / attachments.
- `scheduleVerify(true | false | Error)` programs the next `verify()` call.
- `verifyCalls` and `closeCalls` track lifecycle so tests can assert
cleanup behaviour.
## Usage: Basic
```ts
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
import { SmtpTransport } from "@suaveplan/emailer/transports/smtp";
const adapter = new FakeSmtpClient();
adapter.push({ info: { messageId: "<abc@host>" } });
const transport = new SmtpTransport();
await transport.initialize({ client: adapter });
const r = await transport.send({ to: "u@x.y", subject: "hi", text: "hi" });
```
## Usage: Advanced
```ts
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
const adapter = new FakeSmtpClient();
adapter.push({ reject: new Error("connection refused") });
adapter.push({ info: { messageId: "<1@h>", accepted: ["a@b.c"] } });
adapter.scheduleVerify(true);
// First send fails, second succeeds, verify returns true.
```
## API Reference
- `class FakeSmtpClient implements SmtpClientLike`
- `interface FakeSmtpResponse { info?, reject? }`
- `interface FakeSmtpCall { payload: SmtpSendPayload }`
## Implementation Notes
- The fake is intentionally minimal — it carries no SMTP state, no auth,
no connection pool. The point is that `SmtpTransport` only ever calls
three methods (`sendMail`, `verify`, `close`), and the fake reproduces
exactly those three semantics with FIFO scripting.
- `scheduleVerify(Error)` is single-use — after the next `verify` throws,
the reject queue clears. Subsequent calls return the most-recently
scheduled boolean (default `true`).
- `closeCalls` increments on every `close`, allowing tests to assert
idempotent close behaviour without inspecting private state.

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeSmtpClient } from "./fake-smtp-client.js";
describe("FakeSmtpClient", () => {
it("consumes scripted responses in FIFO order", {
timeout: 5000,
}, async () => {
const f = new FakeSmtpClient();
f.push({ info: { messageId: "<1@h>" } });
f.push({ info: { messageId: "<2@h>" } });
const a = await f.sendMail({ from: "a", to: "b", subject: "x" });
const b = await f.sendMail({ from: "a", to: "b", subject: "x" });
expect(a.messageId).toBe("<1@h>");
expect(b.messageId).toBe("<2@h>");
expect(f.calls.length).toBe(2);
});
it("rejects with no response queued", { timeout: 5000 }, async () => {
const f = new FakeSmtpClient();
await expect(
f.sendMail({ from: "a", to: "b", subject: "x" })
).rejects.toThrow("no response queued");
});
it("rejects when scripted reject is supplied", {
timeout: 5000,
}, async () => {
const f = new FakeSmtpClient();
f.push({ reject: new Error("nope") });
await expect(
f.sendMail({ from: "a", to: "b", subject: "x" })
).rejects.toThrow("nope");
});
it("throws when scripted info is missing", { timeout: 5000 }, async () => {
const f = new FakeSmtpClient();
f.push({});
await expect(
f.sendMail({ from: "a", to: "b", subject: "x" })
).rejects.toThrow("scripted response missing info");
});
it("verify returns scheduled boolean", { timeout: 5000 }, async () => {
const f = new FakeSmtpClient();
expect(await f.verify()).toBe(true);
f.scheduleVerify(false);
expect(await f.verify()).toBe(false);
});
it("verify rejects when scheduled error", { timeout: 5000 }, async () => {
const f = new FakeSmtpClient();
f.scheduleVerify(false);
f.scheduleVerify(new Error("boom"));
await expect(f.verify()).rejects.toThrow("boom");
// and reverts to the last scheduled boolean
expect(await f.verify()).toBe(false);
});
it("close increments closeCalls", { timeout: 5000 }, async () => {
const f = new FakeSmtpClient();
await f.close();
await f.close();
expect(f.closeCalls).toBe(2);
});
});

View File

@@ -0,0 +1,73 @@
import type {
SmtpClientLike,
SmtpSendInfo,
SmtpSendPayload,
} from "../transports/smtp/smtp-client-like.types.js";
/** Scripted response for one `sendMail` invocation. */
export interface FakeSmtpResponse {
readonly info?: SmtpSendInfo;
readonly reject?: Error;
}
/** Captured `sendMail` invocation. */
export interface FakeSmtpCall {
readonly payload: SmtpSendPayload;
}
/**
* Deterministic test double for `SmtpClientLike`. Tests queue scripted
* responses, invoke the wrapped transport, and inspect the captured calls
* — no `vi.mock`, no transport monkey-patching, no real SMTP server.
*/
export class FakeSmtpClient implements SmtpClientLike {
public readonly calls: FakeSmtpCall[] = [];
public verifyCalls = 0;
public closeCalls = 0;
readonly #queue: FakeSmtpResponse[] = [];
#verifyResult = true;
#verifyReject: Error | undefined;
/** Append a scripted response. */
public push(response: FakeSmtpResponse): void {
this.#queue.push(response);
}
/** Control `verify` outcome. Pass a boolean or an Error to throw. */
public scheduleVerify(result: boolean | Error): void {
if (result instanceof Error) {
this.#verifyReject = result;
return;
}
this.#verifyResult = result;
}
public async sendMail(payload: SmtpSendPayload): Promise<SmtpSendInfo> {
this.calls.push({ payload });
const scripted = this.#queue.shift();
if (scripted === undefined) {
throw new Error("no response queued");
}
if (scripted.reject !== undefined) {
throw scripted.reject;
}
if (scripted.info === undefined) {
throw new Error("scripted response missing info");
}
return scripted.info;
}
public async verify(): Promise<boolean> {
this.verifyCalls += 1;
if (this.#verifyReject !== undefined) {
const e = this.#verifyReject;
this.#verifyReject = undefined;
throw e;
}
return this.#verifyResult;
}
public async close(): Promise<void> {
this.closeCalls += 1;
}
}

View File

@@ -0,0 +1,10 @@
export {
FakeFetch,
type FakeFetchCall,
type FakeFetchResponse,
} from "./fake-fetch.js";
export {
type FakeSmtpCall,
FakeSmtpClient,
type FakeSmtpResponse,
} from "./fake-smtp-client.js";

View File

@@ -0,0 +1,92 @@
// Side-effect: registers the EMAILER_* error-code namespace so subpath
// consumers see registered codes even when they don't touch the main entry.
import "./error-codes/index.js";
export {
BaseEmailTransport,
EmailService,
} from "./core/email-service/index.js";
export {
computeBackoff,
defaultTimers,
delay,
type RetryHooks,
runWithRetry,
} from "./core/retry/retry.js";
export {
extractEmail,
formatAddress,
formatSingleAddress,
toEmailArray,
validateMessage,
} from "./core/validate/validate.js";
export {
EMAILER_CODES,
EmailerError,
type EmailerErrorCode,
} from "./error-codes/index.js";
export {
FakeFetch,
type FakeFetchCall,
type FakeFetchResponse,
type FakeSmtpCall,
FakeSmtpClient,
type FakeSmtpResponse,
} from "./fakes/index.js";
export {
recordQueue,
recordRetry,
recordSend,
recordSendError,
refreshEmailerMeters,
} from "./otel/metrics.js";
export {
AddressBagSchema,
DummyTransportOptionsSchema,
EmailAddressSchema,
EmailAttachmentSchema,
EmailerOptionsShapeSchema,
EmailMessageSchema,
GmailRestTransportOptionsSchema,
RetryPolicySchema,
SmtpTransportOptionsSchema,
} from "./schemas/schemas.js";
export {
createDummyTransport,
DummyTransport,
type DummyTransportOptions,
} from "./transports/dummy/dummy-transport.js";
export {
createGmailRestTransport,
GmailRestTransport,
type GmailRestTransportOptions,
toBase64Url,
} from "./transports/gmail-rest/gmail-rest-transport.js";
export type {
SmtpClientLike,
SmtpSendInfo,
SmtpSendPayload,
} from "./transports/smtp/smtp-client-like.types.js";
export {
createSmtpTransport,
SmtpTransport,
type SmtpTransportOptions,
} from "./transports/smtp/smtp-transport.js";
export type {
AddressBag,
EmailAddress,
EmailAttachment,
EmailErrorHookContext,
EmailerHooks,
EmailerOptions,
EmailerStats,
EmailHookContext,
EmailMessage,
EmailResultHookContext,
EmailSendResult,
EmailTransport,
EventBusLike,
HookFunction,
QueueLike,
RetryPolicy,
} from "./types/emailer.types.js";

View File

@@ -0,0 +1,7 @@
export {
recordQueue,
recordRetry,
recordSend,
recordSendError,
refreshEmailerMeters,
} from "./metrics.js";

View File

@@ -0,0 +1,75 @@
# `metrics.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single source of truth for every OpenTelemetry instrument emitted by
`@suaveplan/emailer`. Every production path that touches the wire — send
attempts, send failures, latency, retries, queue enqueues — records exactly
one observation through one of the helpers exported here. Centralising the
emission lets the spec-conformance test grep one file to assert that every
declared instrument is wired into a production call.
## Features
- `recordSend(transport, outcome, latencyMs)` — counter + histogram pair
for every send attempt.
- `recordSendError(transport, code)` — counter keyed on the typed
`EmailerErrorCode` so dashboards filter by code path, not message.
- `recordRetry(transport)` — counter on every retry attempt.
- `recordQueue(transport)` — counter on every enqueue.
- `refreshEmailerMeters()` — re-binds every instrument against the current
global `MeterProvider`; tests call this after installing an in-memory
provider so subsequent emissions land on the test exporter.
## Usage: Basic
```ts
import { recordSend } from "@suaveplan/emailer/otel/metrics";
const startedAt = Date.now();
// ... actual send ...
recordSend("dummy", "ok", Date.now() - startedAt);
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import { refreshEmailerMeters, recordSend } from "@suaveplan/emailer/otel/metrics";
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
refreshEmailerMeters();
recordSend("dummy", "ok", 12);
// `provider.collect()` now contains an `emailer.send.total` observation
// with attributes `{ transport: "dummy", outcome: "ok" }`.
```
## API Reference
- `refreshEmailerMeters(): void`
- `recordSend(transport: string, outcome: "ok" | "error", latencyMs: number): void`
- `recordSendError(transport: string, code: string): void`
- `recordRetry(transport: string): void`
- `recordQueue(transport: string): void`
## Implementation Notes
- The five instrument names — `emailer.send.total`,
`emailer.send.errors`, `emailer.send.latency_ms`, `emailer.retry.total`,
`emailer.queue.depth` — are part of the public surface. Renaming them
silently breaks every downstream dashboard, so any change must move
through OpenSpec.
- `refreshEmailerMeters` is required because the OpenTelemetry SDK does
not propagate provider changes to existing instruments. Tests that swap
the global provider must call it before invoking the production path,
otherwise observations land on the module-load meter and the test sees
zero data.
- All instruments are universally available — the helpers carry no IO and
are safe to call from synchronous hot paths.

View File

@@ -0,0 +1,66 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
recordQueue,
recordRetry,
recordSend,
recordSendError,
refreshEmailerMeters,
} from "./metrics.js";
let exporter: InMemoryMetricExporter;
let provider: MeterProvider;
let reader: PeriodicExportingMetricReader;
beforeEach(() => {
exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 100_000,
});
provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
refreshEmailerMeters();
});
afterEach(async () => {
await reader.shutdown();
metrics.disable();
});
describe("emailer OTel metrics", () => {
it("collects send / error / latency / retry / queue instruments", {
timeout: 5000,
}, async () => {
recordSend("dummy", "ok", 12);
recordSend("dummy", "error", 30);
recordSendError("dummy", "EMAILER.TRANSPORT_FAILED");
recordRetry("dummy");
recordQueue("dummy");
await reader.forceFlush();
const batches = exporter.getMetrics();
expect(batches.length).toBeGreaterThan(0);
const all = batches.flatMap((b) =>
b.scopeMetrics.flatMap((s) => s.metrics)
);
const names = all.map((m) => m.descriptor.name);
expect(names).toContain("emailer.send.total");
expect(names).toContain("emailer.send.errors");
expect(names).toContain("emailer.send.latency_ms");
expect(names).toContain("emailer.retry.total");
expect(names).toContain("emailer.queue.depth");
});
});

View File

@@ -0,0 +1,77 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/emailer";
const METER_VERSION = "0.1.0";
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
let sendCounter: Counter = meter.createCounter("emailer.send.total", {
description: "Count of email send attempts by outcome and transport",
});
let errorCounter: Counter = meter.createCounter("emailer.send.errors", {
description: "Count of email send errors by code and transport",
});
let latencyHistogram: Histogram = meter.createHistogram(
"emailer.send.latency_ms",
{
description: "Email send latency in milliseconds",
unit: "ms",
}
);
let retryCounter: Counter = meter.createCounter("emailer.retry.total", {
description: "Count of retry attempts by transport",
});
let queueDepthCounter: Counter = meter.createCounter("emailer.queue.depth", {
description: "Cumulative count of messages enqueued by transport",
});
/**
* Re-resolve every instrument against the current global `MeterProvider`.
* Tests call this after wiring an in-memory exporter so subsequent record
* calls land on the test provider rather than the module-load meter.
*/
export function refreshEmailerMeters(): void {
meter = metrics.getMeter(METER_NAME, METER_VERSION);
sendCounter = meter.createCounter("emailer.send.total", {
description: "Count of email send attempts by outcome and transport",
});
errorCounter = meter.createCounter("emailer.send.errors", {
description: "Count of email send errors by code and transport",
});
latencyHistogram = meter.createHistogram("emailer.send.latency_ms", {
description: "Email send latency in milliseconds",
unit: "ms",
});
retryCounter = meter.createCounter("emailer.retry.total", {
description: "Count of retry attempts by transport",
});
queueDepthCounter = meter.createCounter("emailer.queue.depth", {
description: "Cumulative count of messages enqueued by transport",
});
}
export function recordSend(
transport: string,
outcome: "ok" | "error",
latencyMs: number
): void {
sendCounter.add(1, { transport, outcome });
latencyHistogram.record(latencyMs, { transport, outcome });
}
export function recordSendError(transport: string, code: string): void {
errorCounter.add(1, { transport, code });
}
export function recordRetry(transport: string): void {
retryCounter.add(1, { transport });
}
export function recordQueue(transport: string): void {
queueDepthCounter.add(1, { transport });
}

View File

@@ -0,0 +1,11 @@
export {
AddressBagSchema,
DummyTransportOptionsSchema,
EmailAddressSchema,
EmailAttachmentSchema,
EmailerOptionsShapeSchema,
EmailMessageSchema,
GmailRestTransportOptionsSchema,
RetryPolicySchema,
SmtpTransportOptionsSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,89 @@
# `schemas.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Centralised Zod schemas for every public option bundle and wire payload in
`@suaveplan/emailer`. The schemas exist to enforce a hard boundary between
the package and its callers: every option object passed into the service or
into a concrete transport is `safeParse`d before it is trusted. On failure
the relevant constructor raises `EmailerError(EMAILER.INVALID_CONFIG)` with
the `ZodError` carried as the `cause` so observability, logging, and
problem+json responders downstream can render the offending field path.
## Features
- `EmailAddressSchema`, `AddressBagSchema`, `EmailAttachmentSchema`,
`EmailMessageSchema` — wire-level payloads.
- `RetryPolicySchema`, `EmailerOptionsShapeSchema` — service config.
- `DummyTransportOptionsSchema`, `SmtpTransportOptionsSchema`,
`GmailRestTransportOptionsSchema` — per-transport configs.
- All schemas use Zod 4's default import (`import z from "zod"`) per the
Bun-safe convention codified in MEMORY.md.
## Usage: Basic
```ts
import { EmailMessageSchema } from "@suaveplan/emailer/schemas/schemas";
const result = EmailMessageSchema.safeParse({
to: "user@example.com",
subject: "hello",
text: "hi",
});
if (!result.success) {
throw result.error;
}
```
## Usage: Advanced
```ts
import { GmailRestTransportOptionsSchema } from "@suaveplan/emailer/schemas/schemas";
import { EMAILER_CODES, EmailerError } from "@suaveplan/emailer/error-codes";
function loadGmailConfigFromEnv(env: Record<string, string | undefined>) {
const parsed = GmailRestTransportOptionsSchema.safeParse({
accessToken: env["GMAIL_ACCESS_TOKEN"],
userId: env["GMAIL_USER_ID"],
});
if (!parsed.success) {
throw new EmailerError(
EMAILER_CODES.INVALID_CONFIG,
"emailer.errors.invalidConfig",
{ cause: parsed.error, context: { source: "env" } },
);
}
return parsed.data;
}
```
## API Reference
- `EmailAddressSchema`
- `AddressBagSchema`
- `EmailAttachmentSchema`
- `EmailMessageSchema`
- `RetryPolicySchema`
- `EmailerOptionsShapeSchema`
- `DummyTransportOptionsSchema`
- `SmtpTransportOptionsSchema`
- `GmailRestTransportOptionsSchema`
## Implementation Notes
- `EmailerOptionsShapeSchema` only validates the serialisable subset of
`EmailerOptions`; the live objects (transport instance, logger, event
bus, queue, signal) are passed through unchanged because Zod cannot
meaningfully assert "this object implements `EmailTransport`."
- `AddressBagSchema` accepts either a single string, a structured
`EmailAddress`, or a non-empty array of either; this matches the wire
shape every transport accepts.
- `EmailAttachmentSchema` accepts `Uint8Array` (universal) or `string`; the
upstream `NodeJS.ReadableStream` form is deliberately dropped because it
would force a Node dependency.
- `RetryPolicySchema` enforces `maxAttempts >= 1` so the policy is always
attempt-count-meaningful — a `0` value would deadlock the runner.

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AddressBagSchema,
DummyTransportOptionsSchema,
EmailAddressSchema,
EmailAttachmentSchema,
EmailerOptionsShapeSchema,
EmailMessageSchema,
GmailRestTransportOptionsSchema,
RetryPolicySchema,
SmtpTransportOptionsSchema,
} from "./schemas.js";
describe("schemas", () => {
it("EmailAddressSchema accepts a valid address", { timeout: 5000 }, () => {
expect(EmailAddressSchema.safeParse({ email: "a@b.c" }).success).toBe(
true
);
expect(
EmailAddressSchema.safeParse({ email: "a@b.c", name: "Alice" })
.success
).toBe(true);
expect(EmailAddressSchema.safeParse({ email: "" }).success).toBe(false);
});
it("AddressBagSchema accepts string, object, and arrays", {
timeout: 5000,
}, () => {
expect(AddressBagSchema.safeParse("a@b.c").success).toBe(true);
expect(AddressBagSchema.safeParse({ email: "a@b.c" }).success).toBe(
true
);
expect(
AddressBagSchema.safeParse([{ email: "a@b.c" }, "x@y.z"]).success
).toBe(true);
expect(AddressBagSchema.safeParse([]).success).toBe(false);
});
it("EmailAttachmentSchema accepts Uint8Array and string", {
timeout: 5000,
}, () => {
expect(
EmailAttachmentSchema.safeParse({
filename: "a.txt",
content: new Uint8Array([1, 2, 3]),
}).success
).toBe(true);
expect(
EmailAttachmentSchema.safeParse({
filename: "a.txt",
content: "hi",
}).success
).toBe(true);
});
it("EmailMessageSchema requires to + subject", { timeout: 5000 }, () => {
expect(
EmailMessageSchema.safeParse({
to: "a@b.c",
subject: "hi",
text: "hi",
}).success
).toBe(true);
expect(
EmailMessageSchema.safeParse({
subject: "hi",
text: "hi",
}).success
).toBe(false);
});
it("RetryPolicySchema enforces maxAttempts >= 1", { timeout: 5000 }, () => {
expect(
RetryPolicySchema.safeParse({ maxAttempts: 1, delay: 0 }).success
).toBe(true);
expect(
RetryPolicySchema.safeParse({ maxAttempts: 0, delay: 0 }).success
).toBe(false);
expect(
RetryPolicySchema.safeParse({
maxAttempts: 3,
delay: 100,
backoff: "exponential",
}).success
).toBe(true);
});
it("rejects invalid EmailerOptions with ZodError", {
timeout: 5000,
}, () => {
const r = EmailerOptionsShapeSchema.safeParse({
retry: { maxAttempts: -1, delay: 0 },
});
expect(r.success).toBe(false);
});
it("DummyTransportOptionsSchema validates", { timeout: 5000 }, () => {
expect(
DummyTransportOptionsSchema.safeParse({
shouldFail: false,
delayMs: 0,
}).success
).toBe(true);
expect(
DummyTransportOptionsSchema.safeParse({ delayMs: -1 }).success
).toBe(false);
});
it("SmtpTransportOptionsSchema validates host/port", {
timeout: 5000,
}, () => {
expect(
SmtpTransportOptionsSchema.safeParse({
host: "smtp.x.y",
port: 465,
}).success
).toBe(true);
expect(SmtpTransportOptionsSchema.safeParse({ port: 0 }).success).toBe(
false
);
});
it("GmailRestTransportOptionsSchema requires accessToken", {
timeout: 5000,
}, () => {
expect(
GmailRestTransportOptionsSchema.safeParse({
accessToken: "x",
}).success
).toBe(true);
expect(GmailRestTransportOptionsSchema.safeParse({}).success).toBe(
false
);
expect(
GmailRestTransportOptionsSchema.safeParse({
accessToken: "x",
endpoint: "not a url",
}).success
).toBe(false);
});
});

View File

@@ -0,0 +1,88 @@
// schema: boundary-only
// Every schema in this file is exposed as a public boundary-validation
// surface for consumers (inbound API gateways, CLI parsers, ETL jobs,
// per-tenant config loaders). The package itself validates only the
// top-level `EmailerOptions` and per-transport configs internally — the
// remaining schemas (`EmailAddressSchema`, `AddressBagSchema`,
// `EmailAttachmentSchema`, `EmailMessageSchema`, `RetryPolicySchema`) are
// downstream-facing.
import z from "zod";
/** Zod schema for `EmailAddress`. */
export const EmailAddressSchema = z.object({
email: z.string().min(1),
name: z.string().optional(),
});
/** Address-bag — string, EmailAddress, or readonly array of either. */
export const AddressBagSchema = z.union([
z.string().min(1),
EmailAddressSchema,
z.array(z.union([z.string().min(1), EmailAddressSchema])).min(1),
]);
/** Zod schema for `EmailAttachment`. */
export const EmailAttachmentSchema = z.object({
filename: z.string().min(1),
content: z.union([z.instanceof(Uint8Array), z.string()]),
contentType: z.string().optional(),
cid: z.string().optional(),
encoding: z.string().optional(),
});
/** Zod schema for `EmailMessage`. */
export const EmailMessageSchema = z.object({
to: AddressBagSchema,
from: z.union([z.string().min(1), EmailAddressSchema]).optional(),
subject: z.string().min(1),
html: z.string().optional(),
text: z.string().optional(),
cc: AddressBagSchema.optional(),
bcc: AddressBagSchema.optional(),
replyTo: z.union([z.string().min(1), EmailAddressSchema]).optional(),
attachments: z.array(EmailAttachmentSchema).optional(),
headers: z.record(z.string(), z.string()).optional(),
priority: z.enum(["high", "normal", "low"]).optional(),
messageId: z.string().optional(),
providerOptions: z.record(z.string(), z.unknown()).optional(),
});
/** Zod schema for `RetryPolicy`. */
export const RetryPolicySchema = z.object({
maxAttempts: z.number().int().min(1),
delay: z.number().min(0),
backoff: z.enum(["linear", "exponential"]).optional(),
});
/**
* Zod schema for top-level `EmailerOptions` — partial because `transport` is a
* runtime object (cannot be Zod-validated structurally without burdening
* callers) and the remaining fields are optional.
*/
export const EmailerOptionsShapeSchema = z.object({
defaultFrom: z.union([z.string().min(1), EmailAddressSchema]).optional(),
retry: RetryPolicySchema.optional(),
});
/** Zod schema for `DummyTransport` config. */
export const DummyTransportOptionsSchema = z.object({
shouldFail: z.boolean().optional(),
delayMs: z.number().int().min(0).optional(),
messageIdPrefix: z.string().min(1).optional(),
verifySuccess: z.boolean().optional(),
});
/** Zod schema for `SmtpTransport` config. */
export const SmtpTransportOptionsSchema = z.object({
host: z.string().min(1).optional(),
port: z.number().int().min(1).max(65535).optional(),
secure: z.boolean().optional(),
user: z.string().optional(),
});
/** Zod schema for `GmailRestTransport` config. */
export const GmailRestTransportOptionsSchema = z.object({
accessToken: z.string().min(1),
userId: z.string().min(1).optional(),
endpoint: z.string().url().optional(),
});

View File

@@ -0,0 +1,96 @@
# `dummy-transport.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DummyTransport` is a deterministic in-memory transport designed for tests,
fixtures, and local development. It records every message it receives,
forwards lifecycle events to an injected `EventBusLike`, and can simulate
forced failure, fixed latency, and verification toggles — all without
lighting up any real network or holding any IO handle.
## Features
- `getSentEmails()`, `getLastEmail()`, `getSendCount()` — inspect the
recorded history.
- `clearSentEmails()` — reset the recorder.
- `shouldFail: true` — every `send` returns `{ success: false, error }`.
- `delayMs > 0` — simulated latency scheduled via injectable `TimersLike`.
- `messageIdPrefix` — controls the prefix used in generated message-ids.
- `verifySuccess` — controls the return value of `verify`.
- `events` — injectable `EventBusLike`; `provider:beforeSend`,
`provider:sendSuccess`, `provider:sendError`, `provider:validationError`,
`provider:verify`, `provider:closing`, `provider:closed`,
`provider:cleared` are forwarded.
- `rng` — injectable `Rng` for the message-id suffix; falls back to
`Date.now()` if not supplied so consumers don't need a CSPRNG just to
smoke-test.
## Usage: Basic
```ts
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
const transport = createDummyTransport();
await transport.initialize({});
const result = await transport.send({
to: "user@example.com",
subject: "hi",
text: "hello",
});
console.info(transport.getSentEmails().length); // 1
```
## Usage: Advanced
```ts
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
import type { EventBusLike, Rng, TimersLike } from "@suaveplan/types";
const events: EventBusLike = { emit: (e, p) => bus.publish(e, p) };
const rng: Rng = {
nextUint32: () => 0xdeadbeef,
nextFloat: () => 0.5,
};
const timers: TimersLike = {
setTimer: (cb) => {
cb();
return 0;
},
clearTimer: () => undefined,
};
const transport = createDummyTransport();
await transport.initialize({
shouldFail: false,
delayMs: 100,
messageIdPrefix: "test",
verifySuccess: true,
events,
rng,
timers,
});
const r = await transport.send({ to: "x@y.z", subject: "hi", text: "hi" });
// r.messageId === "test-1-deadbeef"
```
## API Reference
- `class DummyTransport extends BaseEmailTransport<DummyTransportOptions>`
- `createDummyTransport(): DummyTransport`
- `interface DummyTransportOptions`
## Implementation Notes
- The transport extends `BaseEmailTransport` and reuses every helper, so
the wire-shape rules are identical to the SMTP and Gmail transports.
- `delayMs` uses the injected timer's `setTimer` directly; tests can pass
a synchronous `setTimer` to drop the delay entirely.
- Message-id construction is deterministic when `rng` is supplied, which
lets snapshot tests assert against exact strings.
- The recorded history is exposed as a defensive copy
(`[...this.sentEmails]`) so callers cannot mutate the transport's
internal state.

View File

@@ -0,0 +1,203 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng, TimersLike } from "@suaveplan/types";
import { EMAILER_CODES } from "../../error-codes/error-codes.js";
import type { EventBusLike } from "../../types/emailer.types.js";
import { createDummyTransport, DummyTransport } from "./dummy-transport.js";
function recorder(): EventBusLike & { events: Array<[string, unknown]> } {
const events: Array<[string, unknown]> = [];
return {
events,
emit: (event, payload) => {
events.push([event, payload]);
},
};
}
const fixedRng: Rng = {
nextUint32: () => 0xdeadbeef,
nextFloat: () => 0.5,
};
const syncTimers: TimersLike = {
setTimer: (cb) => {
cb();
return 0;
},
clearTimer: () => {
/* noop */
},
};
describe("DummyTransport", () => {
it("records sent messages and forwards bus events", {
timeout: 5000,
}, async () => {
const rec = recorder();
const t = createDummyTransport();
await t.initialize({
messageIdPrefix: "test",
events: rec,
rng: fixedRng,
});
const r = await t.send({
to: "alice@example.com",
subject: "hi",
text: "hello",
});
expect(r.success).toBe(true);
expect(r.messageId?.startsWith("test-1-")).toBe(true);
expect(t.getSendCount()).toBe(1);
expect(t.getLastEmail()?.to).toBe("alice@example.com");
expect(t.getSentEmails().length).toBe(1);
const types = rec.events.map(([type]) => type);
expect(types).toContain("provider:initialized");
expect(types).toContain("provider:beforeSend");
expect(types).toContain("provider:sendSuccess");
});
it("returns failure result when shouldFail is true", {
timeout: 5000,
}, async () => {
const rec = recorder();
const t = createDummyTransport();
await t.initialize({ shouldFail: true, events: rec });
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(r.success).toBe(false);
expect(r.error).toBeDefined();
expect(rec.events.some(([e]) => e === "provider:sendError")).toBe(true);
});
it("returns validation error for missing fields", {
timeout: 5000,
}, async () => {
const rec = recorder();
const t = createDummyTransport();
await t.initialize({ events: rec });
const r = await t.send({
to: "",
subject: "hi",
text: "hi",
});
expect(r.success).toBe(false);
expect(rec.events.some(([e]) => e === "provider:validationError")).toBe(
true
);
});
it("skips delay when delayMs === 0", { timeout: 5000 }, async () => {
let calls = 0;
const timers: TimersLike = {
setTimer: (cb) => {
calls += 1;
cb();
return 0;
},
clearTimer: () => {
/* noop */
},
};
const t = createDummyTransport();
await t.initialize({ delayMs: 0, timers });
await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(calls).toBe(0);
});
it("simulates latency via injected timers", { timeout: 5000 }, async () => {
let calls = 0;
const timers: TimersLike = {
setTimer: (cb) => {
calls += 1;
cb();
return 0;
},
clearTimer: () => {
/* noop */
},
};
const t = createDummyTransport();
await t.initialize({ delayMs: 50, timers });
await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(calls).toBe(1);
});
it("verify honours verifySuccess flag", { timeout: 5000 }, async () => {
const t = new DummyTransport();
await t.initialize({ verifySuccess: false });
expect(await t.verify()).toBe(false);
const t2 = new DummyTransport();
await t2.initialize({});
expect(await t2.verify()).toBe(true);
});
it("close emits closed events", { timeout: 5000 }, async () => {
const rec = recorder();
const t = createDummyTransport();
await t.initialize({ events: rec });
await t.close();
const evs = rec.events.map(([e]) => e);
expect(evs).toContain("provider:closing");
expect(evs).toContain("provider:closed");
});
it("clearSentEmails wipes history", { timeout: 5000 }, async () => {
const rec = recorder();
const t = createDummyTransport();
await t.initialize({ events: rec });
await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(t.getSendCount()).toBe(1);
t.clearSentEmails();
expect(t.getSendCount()).toBe(0);
expect(t.getSentEmails().length).toBe(0);
expect(rec.events.some(([e]) => e === "provider:cleared")).toBe(true);
});
it("send throws if not initialised", { timeout: 5000 }, async () => {
const t = new DummyTransport();
await expect(
t.send({ to: "a@b.c", subject: "hi", text: "hi" })
).rejects.toMatchObject({
code: EMAILER_CODES.TRANSPORT_NOT_INITIALIZED,
});
});
it("verify throws if not initialised", { timeout: 5000 }, async () => {
const t = new DummyTransport();
await expect(t.verify()).rejects.toMatchObject({
code: EMAILER_CODES.TRANSPORT_NOT_INITIALIZED,
});
});
it("falls back to Date.now suffix without rng", {
timeout: 5000,
}, async () => {
const t = createDummyTransport();
await t.initialize({ timers: syncTimers });
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(r.messageId?.startsWith("dummy-1-")).toBe(true);
});
it("rejects invalid config via Zod", { timeout: 5000 }, async () => {
const t = createDummyTransport();
await expect(t.initialize({ delayMs: -5 })).rejects.toMatchObject({
code: EMAILER_CODES.INVALID_CONFIG,
});
});
it("uses default from when message.from is missing in envelope", {
timeout: 5000,
}, async () => {
const t = createDummyTransport();
await t.initialize({});
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(r.success).toBe(true);
expect(
(r.response as { envelope: { from: string } }).envelope.from
).toBe("noreply@example.com");
});
it("close from uninitialized is safe", { timeout: 5000 }, async () => {
const t = new DummyTransport();
await expect(t.close()).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,173 @@
import type { Rng, TimersLike } from "@suaveplan/types";
import { BaseEmailTransport } from "../../core/email-service/base-email-transport.js";
import { defaultTimers } from "../../core/retry/retry.js";
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
import { DummyTransportOptionsSchema } from "../../schemas/schemas.js";
import type {
EmailMessage,
EmailSendResult,
EventBusLike,
} from "../../types/emailer.types.js";
/** Configuration for `DummyTransport`. */
export interface DummyTransportOptions {
/** When `true`, every `send` returns a failed result. */
readonly shouldFail?: boolean;
/** Optional simulated latency in ms, scheduled via the injectable timer. */
readonly delayMs?: number;
/** Prefix for generated message-ids. Defaults to `"dummy"`. */
readonly messageIdPrefix?: string;
/** Result returned from `verify`. Defaults to `true`. */
readonly verifySuccess?: boolean;
/** Optional event bus for lifecycle event forwarding. */
readonly events?: EventBusLike;
/** Optional injected timers. */
readonly timers?: TimersLike;
/** Optional injected RNG used for the message-id suffix. */
readonly rng?: Rng;
}
/**
* Deterministic in-memory transport. Records every message it receives and
* forwards lifecycle events to the injected `EventBusLike` so tests can
* inspect both the wire payload and the observability stream without
* lighting up any network.
*/
export class DummyTransport extends BaseEmailTransport<DummyTransportOptions> {
public readonly name = "dummy";
readonly #sentEmails: EmailMessage[] = [];
#sendCount = 0;
#events: EventBusLike | undefined;
#timers: TimersLike = defaultTimers;
#rng: Rng | undefined;
public override async initialize(
config: DummyTransportOptions
): Promise<void> {
const parsed = DummyTransportOptionsSchema.safeParse({
shouldFail: config.shouldFail,
delayMs: config.delayMs,
messageIdPrefix: config.messageIdPrefix,
verifySuccess: config.verifySuccess,
});
if (!parsed.success) {
throw new EmailerError(
EMAILER_CODES.INVALID_CONFIG,
"emailer.errors.invalidConfig",
{ cause: parsed.error }
);
}
await super.initialize(config);
this.#events = config.events;
this.#timers = config.timers ?? defaultTimers;
this.#rng = config.rng;
this.#emit("provider:initialized", {
provider: this.name,
config: {
...config,
events: undefined,
timers: undefined,
rng: undefined,
},
});
}
public override async send(
message: EmailMessage
): Promise<EmailSendResult> {
this.assertInitialized();
this.#emit("provider:beforeSend", { message });
try {
this.validateMessage(message);
} catch (error) {
const result = this.createErrorResult(error as Error);
this.#emit("provider:validationError", { message, error });
return result;
}
const delayMs = this.config?.delayMs ?? 0;
if (delayMs > 0) {
await new Promise<void>((resolve) => {
this.#timers.setTimer(() => {
resolve();
}, delayMs);
});
}
if (this.config?.shouldFail === true) {
const error = new EmailerError(
EMAILER_CODES.TRANSPORT_FAILED,
"emailer.errors.dummyForcedFailure"
);
const result = this.createErrorResult(error);
this.#emit("provider:sendError", { message, error });
return result;
}
this.#sentEmails.push(message);
this.#sendCount += 1;
const prefix = this.config?.messageIdPrefix ?? "dummy";
const suffix =
this.#rng !== undefined
? this.#rng.nextUint32().toString(16).padStart(8, "0")
: `${Date.now()}`;
const messageId = `${prefix}-${this.#sendCount}-${suffix}`;
const result = this.createSuccessResult(messageId, {
accepted: this.toEmailArray(message.to),
rejected: [],
envelope: {
from: this.extractEmail(message.from ?? "noreply@example.com"),
to: this.toEmailArray(message.to),
},
});
this.#emit("provider:sendSuccess", { message, result });
return result;
}
public override async verify(): Promise<boolean> {
this.assertInitialized();
const success = this.config?.verifySuccess ?? true;
this.#emit("provider:verify", { success });
return success;
}
public override async close(): Promise<void> {
this.#emit("provider:closing", { sentCount: this.#sentEmails.length });
await super.close();
this.#emit("provider:closed", {});
}
/** All messages this transport has accepted. */
public getSentEmails(): readonly EmailMessage[] {
return [...this.#sentEmails];
}
/** The last accepted message, if any. */
public getLastEmail(): EmailMessage | undefined {
return this.#sentEmails[this.#sentEmails.length - 1];
}
/** Cumulative count of accepted messages. */
public getSendCount(): number {
return this.#sendCount;
}
/** Drop the recorded history. */
public clearSentEmails(): void {
this.#sentEmails.length = 0;
this.#sendCount = 0;
this.#emit("provider:cleared", {});
}
#emit(event: string, payload: unknown): void {
if (this.#events !== undefined) {
this.#events.emit(event, payload);
}
}
}
/** Construct a `DummyTransport`. */
export function createDummyTransport(): DummyTransport {
return new DummyTransport();
}

View File

@@ -0,0 +1,5 @@
export {
createDummyTransport,
DummyTransport,
type DummyTransportOptions,
} from "./dummy-transport.js";

View File

@@ -0,0 +1,98 @@
# `gmail-rest-transport.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GmailRestTransport` sends mail through Gmail's REST API directly, over an
injectable `FetchLike`. The transport builds a minimal RFC 822 envelope,
base64url-encodes it, and POSTs it to `users.messages.send`. Crucially the
package does not depend on `googleapis` or `nodemailer` — every token-
acquisition concern (OAuth2 refresh, service-account JWT, app passwords) is
the caller's, so this transport stays universal and runs unchanged in
Bun, Node, and the browser.
## Features
- Universal: only requires `FetchLike` plus a caller-supplied access
token.
- `toBase64Url` exported as a pure helper for downstream consumers that
need the same encoding.
- Status-code → error-code classifier:
- 401 / 403 → `EMAILER.AUTH_FAILED`
- 429 → `EMAILER.RATE_LIMITED`
- any other non-2xx → `EMAILER.TRANSPORT_FAILED`
- Carries the HTTP status and error detail on the thrown `EmailerError`'s
`context` bag so downstream loggers render structured detail without
scraping the message string.
- Zod validation on initialise via `GmailRestTransportOptionsSchema`.
## Usage: Basic
```ts
import { createGmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
const transport = createGmailRestTransport();
await transport.initialize({ accessToken: "ya29.a0..." });
const r = await transport.send({
to: "user@example.com",
subject: "hi",
text: "hello",
});
```
## Usage: Advanced
```ts
import { createGmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
// Test wiring with a deterministic fetch double:
const fake = new FakeFetch();
fake.push({ status: 200, json: { id: "1816fc...", threadId: "1816fc..." } });
const transport = createGmailRestTransport();
await transport.initialize({
accessToken: "ya29.test",
userId: "alice@example.com",
endpoint: "https://gmail.googleapis.com/gmail/v1",
fetch: fake.fetch,
});
const r = await transport.send({
to: "bob@example.com",
subject: "hi",
html: "<p>hello</p>",
});
const captured = fake.calls[0]!;
console.info(captured.url);
// → https://gmail.googleapis.com/gmail/v1/users/alice%40example.com/messages/send
```
## API Reference
- `class GmailRestTransport extends BaseEmailTransport<GmailRestTransportOptions>`
- `createGmailRestTransport(): GmailRestTransport`
- `interface GmailRestTransportOptions { accessToken; userId?; endpoint?; fetch?; }`
- `toBase64Url(input: string): string`
## Implementation Notes
- The endpoint URL is constructed via `encodeURIComponent(userId)` so
user-ids with `@` characters (e.g. delegated mailboxes) are escaped
correctly. The default `userId` is `"me"`, matching the Gmail REST
convention.
- Token refresh is deliberately outside the transport's scope; the access
token is a snapshot of the caller's identity at construction time.
Long-lived services should re-initialise (or re-construct) the
transport when the token expires.
- The MIME builder is intentionally minimal — `text/plain` or `text/html`
with optional caller-supplied headers. Multipart, inline images, and
attachment encoding are deferred to a follow-up because Gmail's REST
API itself accepts any valid MIME envelope, so the caller can pre-build
one and pass it via `message.headers` if they need more than the
built-in format.
- `toBase64Url` prefers the WHATWG `btoa` route so the transport works in
the browser; the Node `Buffer` fallback exists only for environments
that strip `btoa`.

View File

@@ -0,0 +1,314 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
import { FakeFetch } from "../../fakes/fake-fetch.js";
import {
createGmailRestTransport,
GmailRestTransport,
toBase64Url,
} from "./gmail-rest-transport.js";
function decodeBase64Url(input: string): string {
const padded = input.replace(/-/g, "+").replace(/_/g, "/");
const pad =
padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
const base64 = padded + pad;
if (typeof globalThis.atob === "function") {
return globalThis.atob(base64);
}
type BufferCtor = {
from(s: string, enc: string): { toString(enc: string): string };
};
const bufferCtor = (globalThis as unknown as { Buffer?: BufferCtor })
.Buffer;
return bufferCtor!.from(base64, "base64").toString("utf8");
}
describe("toBase64Url", () => {
it("encodes ascii", { timeout: 5000 }, () => {
const enc = toBase64Url("hello world");
expect(decodeBase64Url(enc)).toBe("hello world");
});
it("strips padding and url-safe", { timeout: 5000 }, () => {
const enc = toBase64Url("any +/ chars?");
expect(enc.endsWith("=")).toBe(false);
expect(enc.includes("+")).toBe(false);
expect(enc.includes("/")).toBe(false);
});
it("throws MIME_BUILD_FAILED when btoa is unavailable", {
timeout: 5000,
}, () => {
const original = globalThis.btoa;
try {
(globalThis as { btoa?: typeof globalThis.btoa }).btoa = undefined;
expect(() => toBase64Url("x")).toThrow(EmailerError);
} finally {
globalThis.btoa = original;
}
});
});
describe("GmailRestTransport", () => {
it("sends base64url MIME envelope via Gmail REST", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ status: 200, json: { id: "abc", threadId: "t1" } });
const t = createGmailRestTransport();
await t.initialize({
accessToken: "ya29.xyz",
userId: "me",
fetch: fake.fetch,
});
const r = await t.send({
to: { email: "bob@example.com", name: "Bob" },
from: "alice@example.com",
subject: "Hello",
html: "<p>hi</p>",
cc: "c@example.com",
replyTo: "r@example.com",
headers: { "X-Test": "yes" },
});
expect(r.success).toBe(true);
expect(r.messageId).toBe("abc");
expect(fake.calls.length).toBe(1);
const call = fake.calls[0]!;
expect(call.url).toBe(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
);
const auth = (call.init?.headers as Record<string, string>)[
"authorization"
];
expect(auth).toBe("Bearer ya29.xyz");
const body = JSON.parse(call.init?.body as string) as { raw: string };
const decoded = decodeBase64Url(body.raw);
expect(decoded).toContain('To: "Bob" <bob@example.com>');
expect(decoded).toContain("Subject: Hello");
expect(decoded).toContain("X-Test: yes");
});
it("reports TRANSPORT_FAILED on non-2xx response", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ status: 500, json: { error: { message: "boom" } } });
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
const r = await t.send({
to: "a@b.c",
subject: "hi",
text: "hi",
});
expect(r.success).toBe(false);
expect((r.error as EmailerError).code).toBe(
EMAILER_CODES.TRANSPORT_FAILED
);
});
it("classifies 401 as AUTH_FAILED", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 401, json: { error: { message: "no" } } });
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect((r.error as EmailerError).code).toBe(EMAILER_CODES.AUTH_FAILED);
});
it("classifies 429 as RATE_LIMITED", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 429, json: { error: { message: "slow down" } } });
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect((r.error as EmailerError).code).toBe(EMAILER_CODES.RATE_LIMITED);
});
it("handles non-JSON error body", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 503, text: "down for maintenance" });
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(r.success).toBe(false);
});
it("validation error returns failed result", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
const r = await t.send({ to: "", subject: "hi", text: "hi" });
expect(r.success).toBe(false);
expect(fake.calls.length).toBe(0);
});
it("init rejects invalid config", { timeout: 5000 }, async () => {
const t = createGmailRestTransport();
await expect(t.initialize({ accessToken: "" })).rejects.toMatchObject({
code: EMAILER_CODES.INVALID_CONFIG,
});
});
it("uses default endpoint and userId when omitted", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ status: 200, json: { id: "abc" } });
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(fake.calls[0]!.url).toBe(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
);
});
it("builds plain-text content type when html missing", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ status: 200, json: { id: "abc" } });
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
const body = JSON.parse(fake.calls[0]!.init?.body as string) as {
raw: string;
};
const decoded = decodeBase64Url(body.raw);
expect(decoded).toContain("Content-Type: text/plain");
});
it("returns failure when underlying fetch rejects", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ reject: new Error("net down") });
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(r.success).toBe(false);
expect(r.error?.message).toBe("net down");
});
it("send throws when not initialised", { timeout: 5000 }, async () => {
const t = new GmailRestTransport();
await expect(
t.send({ to: "a@b.c", subject: "hi", text: "hi" })
).rejects.toMatchObject({
code: EMAILER_CODES.TRANSPORT_NOT_INITIALIZED,
});
});
it("uses defaultFetch when fetch option omitted", {
timeout: 5000,
}, async () => {
const originalFetch = globalThis.fetch;
try {
let captured: { url: string; init?: RequestInit } | undefined;
globalThis.fetch = (
input: string | URL | Request,
init?: RequestInit
) => {
captured = {
url: String(input),
...(init !== undefined ? { init } : {}),
};
return Promise.resolve(
new Response(JSON.stringify({ id: "x" }), {
status: 200,
})
);
};
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t" });
const r = await t.send({
to: "a@b.c",
subject: "hi",
text: "hi",
});
expect(r.success).toBe(true);
expect(captured?.url).toContain("gmail.googleapis.com");
} finally {
globalThis.fetch = originalFetch;
}
});
it("handles malformed json error body via empty detail", {
timeout: 5000,
}, async () => {
const fake = new FakeFetch();
fake.push({ status: 500, json: {} });
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(r.success).toBe(false);
});
it("encodes cc array recipients", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 200, json: { id: "abc" } });
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
await t.send({
to: ["one@x.y", "two@x.y"],
subject: "hi",
text: "hi",
cc: ["a@x.y", { email: "b@x.y", name: "B" }],
});
const body = JSON.parse(fake.calls[0]!.init?.body as string) as {
raw: string;
};
const decoded = decodeBase64Url(body.raw);
expect(decoded).toContain("To: one@x.y, two@x.y");
expect(decoded).toContain('Cc: a@x.y, "B" <b@x.y>');
});
it("wraps non-Error throw as TRANSPORT_FAILED via fetch reject", {
timeout: 5000,
}, async () => {
const t = new GmailRestTransport();
// Initialise with a fetch that throws a non-Error string.
const failure: unknown = "string-thrown";
const evilFetch: typeof fetch = () => {
throw failure;
};
await t.initialize({
accessToken: "t",
fetch: evilFetch as unknown as typeof globalThis.fetch,
});
const r = await t.send({
to: "a@b.c",
subject: "hi",
text: "hi",
});
expect(r.success).toBe(false);
expect((r.error as EmailerError).code).toBe(
EMAILER_CODES.TRANSPORT_FAILED
);
});
it("classifies AbortError as TIMEOUT", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
const abortError = new Error("aborted");
abortError.name = "AbortError";
fake.push({ reject: abortError });
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
const r = await t.send({
to: "a@b.c",
subject: "hi",
text: "hi",
});
expect((r.error as EmailerError).code).toBe(EMAILER_CODES.TIMEOUT);
});
it("missing id falls back to unknown", { timeout: 5000 }, async () => {
const fake = new FakeFetch();
fake.push({ status: 200, json: {} });
const t = new GmailRestTransport();
await t.initialize({ accessToken: "t", fetch: fake.fetch });
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(r.messageId).toBe("unknown");
});
});

View File

@@ -0,0 +1,205 @@
import type { FetchLike } from "@suaveplan/types";
import { BaseEmailTransport } from "../../core/email-service/base-email-transport.js";
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
import { GmailRestTransportOptionsSchema } from "../../schemas/schemas.js";
import type {
EmailMessage,
EmailSendResult,
} from "../../types/emailer.types.js";
const DEFAULT_ENDPOINT = "https://gmail.googleapis.com/gmail/v1";
const DEFAULT_USER_ID = "me";
/** Configuration for `GmailRestTransport`. */
export interface GmailRestTransportOptions {
/** Bearer access token. Caller is responsible for refresh. */
readonly accessToken: string;
/** Gmail user id. Defaults to `"me"`. */
readonly userId?: string;
/** Override the API endpoint (useful for staging). */
readonly endpoint?: string;
/** Injectable `FetchLike`. Defaults to `globalThis.fetch`. */
readonly fetch?: FetchLike;
}
interface GmailErrorBody {
readonly error?: { readonly message?: string; readonly code?: number };
}
interface GmailSendResponse {
readonly id?: string;
readonly threadId?: string;
readonly labelIds?: readonly string[];
}
const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init);
/**
* Encode `input` as base64url (RFC 4648 §5). Uses WHATWG `btoa`, which is
* available in Bun, Node 18+, and every modern browser. If the runtime
* strips `btoa` (an exotic embedded environment) this throws
* `EMAILER.MIME_BUILD_FAILED` so callers see a typed error instead of a
* `ReferenceError`.
*/
export function toBase64Url(input: string): string {
if (typeof globalThis.btoa !== "function") {
throw new EmailerError(
EMAILER_CODES.MIME_BUILD_FAILED,
"emailer.errors.mimeBuildFailed",
{ context: { reason: "no-base64-encoder" } }
);
}
const bytes = new TextEncoder().encode(input);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
const base64 = globalThis.btoa(binary);
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, "");
}
/**
* Gmail REST transport. Builds a minimal RFC 822 envelope, encodes it as
* base64url, and POSTs to `users.messages.send` via an injectable
* `FetchLike`. Token acquisition is the caller's concern — pass any
* already-issued OAuth2 / service-account bearer token.
*/
export class GmailRestTransport extends BaseEmailTransport<GmailRestTransportOptions> {
public readonly name = "gmail-rest";
#accessToken: string | undefined;
#userId: string = DEFAULT_USER_ID;
#endpoint: string = DEFAULT_ENDPOINT;
#fetch: FetchLike = defaultFetch;
public override async initialize(
config: GmailRestTransportOptions
): Promise<void> {
const parsed = GmailRestTransportOptionsSchema.safeParse({
accessToken: config.accessToken,
userId: config.userId,
endpoint: config.endpoint,
});
if (!parsed.success) {
throw new EmailerError(
EMAILER_CODES.INVALID_CONFIG,
"emailer.errors.invalidConfig",
{ cause: parsed.error }
);
}
await super.initialize(config);
this.#accessToken = config.accessToken;
this.#userId = config.userId ?? DEFAULT_USER_ID;
this.#endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
this.#fetch = config.fetch ?? defaultFetch;
}
public override async send(
message: EmailMessage
): Promise<EmailSendResult> {
this.assertInitialized();
try {
this.validateMessage(message);
const mime = this.#buildMimeMessage(message);
const raw = toBase64Url(mime);
const url = `${this.#endpoint}/users/${encodeURIComponent(this.#userId)}/messages/send`;
const response = await this.#fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${this.#accessToken as string}`,
},
body: JSON.stringify({ raw }),
});
if (!response.ok) {
let detail = "";
try {
const body = (await response.json()) as GmailErrorBody;
detail = body.error?.message ?? "";
} catch {
detail = await response.text().catch(() => "");
}
const code =
response.status === 401 || response.status === 403
? EMAILER_CODES.AUTH_FAILED
: response.status === 429
? EMAILER_CODES.RATE_LIMITED
: EMAILER_CODES.TRANSPORT_FAILED;
return this.createErrorResult(
new EmailerError(code, "emailer.errors.transportFailed", {
context: { status: response.status, detail },
})
);
}
const data = (await response.json()) as GmailSendResponse;
return this.createSuccessResult(
data.id ?? "unknown",
data,
data.threadId !== undefined
? { threadId: data.threadId }
: undefined
);
} catch (error) {
if (error instanceof Error) {
if (
error.name === "AbortError" ||
error.name === "TimeoutError"
) {
return this.createErrorResult(
new EmailerError(
EMAILER_CODES.TIMEOUT,
"emailer.errors.timeout",
{ cause: error }
)
);
}
return this.createErrorResult(error);
}
return this.createErrorResult(
new EmailerError(
EMAILER_CODES.TRANSPORT_FAILED,
"emailer.errors.transportFailed"
)
);
}
}
#buildMimeMessage(message: EmailMessage): string {
const lines: string[] = [];
const to = this.formatAddress(message.to) as string | string[];
lines.push(`To: ${Array.isArray(to) ? to.join(", ") : to}`);
if (message.from !== undefined) {
const from = this.formatAddress(message.from) as string;
lines.push(`From: ${from}`);
}
if (message.cc !== undefined) {
const cc = this.formatAddress(message.cc) as string | string[];
lines.push(`Cc: ${Array.isArray(cc) ? cc.join(", ") : cc}`);
}
if (message.replyTo !== undefined) {
const rt = this.formatAddress(message.replyTo) as string;
lines.push(`Reply-To: ${rt}`);
}
lines.push(`Subject: ${message.subject}`);
if (message.headers !== undefined) {
for (const [key, value] of Object.entries(message.headers)) {
lines.push(`${key}: ${value}`);
}
}
const isHtml = message.html !== undefined && message.html.length > 0;
lines.push(
isHtml
? "Content-Type: text/html; charset=utf-8"
: "Content-Type: text/plain; charset=utf-8"
);
lines.push("");
// validateMessage guarantees at least one body is non-empty.
lines.push((message.html ?? message.text) as string);
return lines.join("\r\n");
}
}
/** Construct a `GmailRestTransport`. */
export function createGmailRestTransport(): GmailRestTransport {
return new GmailRestTransport();
}

View File

@@ -0,0 +1,6 @@
export {
createGmailRestTransport,
GmailRestTransport,
type GmailRestTransportOptions,
toBase64Url,
} from "./gmail-rest-transport.js";

View File

@@ -0,0 +1,21 @@
export {
createDummyTransport,
DummyTransport,
type DummyTransportOptions,
} from "./dummy/dummy-transport.js";
export {
createGmailRestTransport,
GmailRestTransport,
type GmailRestTransportOptions,
toBase64Url,
} from "./gmail-rest/gmail-rest-transport.js";
export type {
SmtpClientLike,
SmtpSendInfo,
SmtpSendPayload,
} from "./smtp/smtp-client-like.types.js";
export {
createSmtpTransport,
SmtpTransport,
type SmtpTransportOptions,
} from "./smtp/smtp-transport.js";

View File

@@ -0,0 +1,10 @@
export type {
SmtpClientLike,
SmtpSendInfo,
SmtpSendPayload,
} from "./smtp-client-like.types.js";
export {
createSmtpTransport,
SmtpTransport,
type SmtpTransportOptions,
} from "./smtp-transport.js";

View File

@@ -0,0 +1,82 @@
# `smtp-client-like.types.ts`
## Purpose
Defines the minimal structural contract every SMTP adapter must satisfy.
The contract is intentionally tiny — `sendMail`, optional `verify`, optional
`close` — so any SMTP library (nodemailer, lettre over a Node binding,
mailparser, a custom Bun/Node wrapper, ...) can be plugged in via a thin
wrapper without the emailer package ever importing the library directly.
## Features
- `SmtpClientLike` — the adapter contract: `sendMail(payload)`, optional
`verify()`, optional `close()`.
- `SmtpSendPayload` — the wire shape every adapter receives: `from`, `to`,
`cc`, `bcc`, `replyTo`, `subject`, `html`, `text`, `attachments`,
`headers`, `priority`, `messageId`.
- `SmtpSendInfo` — the response shape the adapter returns: `messageId`,
`accepted`, `rejected`, `response`.
## Usage: Basic
```ts
import type {
SmtpClientLike,
SmtpSendInfo,
SmtpSendPayload,
} from "@suaveplan/emailer/transports/smtp";
const adapter: SmtpClientLike = {
sendMail: async (payload: SmtpSendPayload): Promise<SmtpSendInfo> => {
// Forward `payload` to the underlying library
return { messageId: "abc" };
},
};
```
## Usage: Advanced
```ts
import type {
SmtpClientLike,
SmtpSendInfo,
SmtpSendPayload,
} from "@suaveplan/emailer/transports/smtp";
class TracingAdapter implements SmtpClientLike {
constructor(private readonly inner: SmtpClientLike) {}
async sendMail(payload: SmtpSendPayload): Promise<SmtpSendInfo> {
const started = Date.now();
try {
const info = await this.inner.sendMail(payload);
tracer.recordSend(info.messageId, Date.now() - started);
return info;
} catch (err) {
tracer.recordError(err, Date.now() - started);
throw err;
}
}
verify = () => this.inner.verify?.() ?? Promise.resolve(true);
close = () => this.inner.close?.();
}
```
## API Reference
- `interface SmtpClientLike`
- `interface SmtpSendPayload`
- `interface SmtpSendInfo`
## Implementation Notes
- The contract uses `readonly` properties on the payload so adapters can
safely store references without worrying about mutation by the
transport.
- `attachments` is typed without a `NodeJS.ReadableStream` member so the
package compiles without `@types/node` runtime types; if a caller needs
to stream from disk, they can buffer first or supply a `Uint8Array`.
- The interface intentionally omits connection pool / max-messages /
max-connections — those concerns belong to the adapter, not the
transport contract. Surfacing them here would couple the emailer to one
specific SMTP library's tuning knobs.

View File

@@ -0,0 +1,41 @@
/** Outcome of one `sendMail` call. */
export interface SmtpSendInfo {
readonly messageId: string;
readonly accepted?: readonly string[];
readonly rejected?: readonly string[];
readonly response?: string;
}
/** Payload accepted by `SmtpClientLike.sendMail`. */
export interface SmtpSendPayload {
readonly from: string | string[] | undefined;
readonly to: string | string[] | undefined;
readonly cc?: string | string[];
readonly bcc?: string | string[];
readonly replyTo?: string | string[];
readonly subject: string;
readonly html?: string;
readonly text?: string;
readonly attachments?: ReadonlyArray<{
readonly filename: string;
readonly content: Uint8Array | string;
readonly contentType?: string;
readonly cid?: string;
readonly encoding?: string;
}>;
readonly headers?: Readonly<Record<string, string>>;
readonly priority?: "high" | "normal" | "low";
readonly messageId?: string;
}
/**
* Structural contract every SMTP adapter (nodemailer, lettre, ...) must
* satisfy. The emailer package does not import nodemailer directly — the
* caller wires their library of choice and passes a thin adapter that
* conforms to this interface.
*/
export interface SmtpClientLike {
sendMail(payload: SmtpSendPayload): Promise<SmtpSendInfo>;
verify?(): Promise<boolean>;
close?(): void | Promise<void>;
}

View File

@@ -0,0 +1,86 @@
# `smtp-transport.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SmtpTransport` is a structural SMTP transport: it accepts a caller-supplied
`SmtpClientLike` adapter (the contract is small — `sendMail`, optional
`verify`, optional `close`) and forwards every send call through it. The
emailer package itself never imports nodemailer, lettre, or any other SMTP
library; consumers wire the library of their choice in their own app
shell and pass a thin adapter. This keeps the emailer package universal
(zero Node-only runtime deps) while preserving the upstream SMTP feature
parity.
## Features
- `sendMail` payload covers from / to / cc / bcc / replyTo / subject / html
/ text / attachments / headers / priority / messageId — the same surface
the upstream nodemailer transport accepted.
- `verify` delegates to the adapter's `verify` if present; absent, returns
the `initialized` flag.
- `close` delegates to the adapter's `close` if present.
- Zod validation on initialise via `SmtpTransportOptionsSchema`.
## Usage: Basic
```ts
import { createSmtpTransport } from "@suaveplan/emailer/transports/smtp";
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
const adapter = new FakeSmtpClient();
adapter.push({ info: { messageId: "<abc@host>" } });
const transport = createSmtpTransport();
await transport.initialize({ client: adapter });
const result = await transport.send({
to: "user@example.com",
subject: "hi",
text: "hello",
});
// result.messageId === "<abc@host>"
```
## Usage: Advanced
```ts
// Caller adapts nodemailer in their own app shell:
import nodemailer from "nodemailer";
import { createSmtpTransport } from "@suaveplan/emailer/transports/smtp";
import type { SmtpClientLike } from "@suaveplan/emailer/transports/smtp";
const nm = nodemailer.createTransport({ host: "smtp.fastmail.com", port: 465, secure: true });
const adapter: SmtpClientLike = {
sendMail: async (payload) => {
const info = await nm.sendMail(payload as unknown as Parameters<typeof nm.sendMail>[0]);
return { messageId: info.messageId, accepted: info.accepted, rejected: info.rejected };
},
verify: () => nm.verify().then(() => true).catch(() => false),
close: () => nm.close(),
};
const transport = createSmtpTransport();
await transport.initialize({ client: adapter, host: "smtp.fastmail.com", port: 465, secure: true });
```
## API Reference
- `class SmtpTransport extends BaseEmailTransport<SmtpTransportOptions>`
- `createSmtpTransport(): SmtpTransport`
- `interface SmtpTransportOptions { client: SmtpClientLike; host?; port?; secure?; user?; }`
- `interface SmtpClientLike` (re-exported from `./smtp-client-like.types`)
## Implementation Notes
- The transport never bundles nodemailer — the dependency stays in the
caller's app shell. This keeps the emailer package universal and
publishable to npm without bloating the install footprint of consumers
who use HTTP-based transports.
- `host` / `port` / `secure` / `user` are informational only — they are
recorded for OTel attribute purposes but the adapter owns the actual
connection.
- `send` catches every thrown error from the adapter and converts it into
a failed `EmailSendResult`. This matches the public contract of
`EmailTransport.send` (never throw on send failure).

View File

@@ -0,0 +1,158 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EMAILER_CODES } from "../../error-codes/error-codes.js";
import { FakeSmtpClient } from "../../fakes/fake-smtp-client.js";
import { createSmtpTransport, SmtpTransport } from "./smtp-transport.js";
describe("SmtpTransport", () => {
it("delegates send to injected SmtpClientLike", {
timeout: 5000,
}, async () => {
const client = new FakeSmtpClient();
client.push({ info: { messageId: "<abc@host>" } });
const t = createSmtpTransport();
await t.initialize({ client });
const r = await t.send({
to: { email: "user@example.com", name: "User" },
from: "noreply@example.com",
subject: "hi",
text: "hello",
cc: ["c@x.y"],
bcc: ["b@x.y"],
replyTo: "r@x.y",
html: "<p>hi</p>",
attachments: [{ filename: "a.txt", content: "hi" }],
headers: { "X-Custom": "yes" },
priority: "high",
messageId: "<id1>",
});
expect(r.success).toBe(true);
expect(r.messageId).toBe("<abc@host>");
const payload = client.calls[0]!.payload;
expect(payload.to).toBe('"User" <user@example.com>');
expect(payload.cc).toEqual(["c@x.y"]);
});
it("send returns error when adapter throws", {
timeout: 5000,
}, async () => {
const client = new FakeSmtpClient();
client.push({ reject: new Error("smtp boom") });
const t = new SmtpTransport();
await t.initialize({ client });
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(r.success).toBe(false);
expect(r.error?.message).toBe("smtp boom");
});
it("send returns error when message invalid", {
timeout: 5000,
}, async () => {
const client = new FakeSmtpClient();
const t = new SmtpTransport();
await t.initialize({ client });
const r = await t.send({
to: "",
subject: "hi",
text: "hi",
});
expect(r.success).toBe(false);
});
it("send throws if not initialised", { timeout: 5000 }, async () => {
const t = new SmtpTransport();
await expect(
t.send({ to: "a@b.c", subject: "hi", text: "hi" })
).rejects.toMatchObject({
code: EMAILER_CODES.TRANSPORT_NOT_INITIALIZED,
});
});
it("verify delegates to adapter", { timeout: 5000 }, async () => {
const client = new FakeSmtpClient();
client.scheduleVerify(true);
const t = new SmtpTransport();
await t.initialize({ client });
expect(await t.verify()).toBe(true);
});
it("surfaces verify failure as false", { timeout: 5000 }, async () => {
const client = new FakeSmtpClient();
client.scheduleVerify(new Error("nope"));
const t = new SmtpTransport();
await t.initialize({ client });
expect(await t.verify()).toBe(false);
});
it("verify without adapter.verify returns initialized", {
timeout: 5000,
}, async () => {
const client = new FakeSmtpClient();
const noVerifyClient = {
sendMail: client.sendMail.bind(client),
};
const t = new SmtpTransport();
await t.initialize({ client: noVerifyClient });
expect(await t.verify()).toBe(true);
});
it("close delegates to adapter when present", {
timeout: 5000,
}, async () => {
const client = new FakeSmtpClient();
const t = new SmtpTransport();
await t.initialize({ client });
await t.close();
expect(client.closeCalls).toBe(1);
});
it("close skips when adapter has no close", { timeout: 5000 }, async () => {
const client: { sendMail: typeof FakeSmtpClient.prototype.sendMail } = {
sendMail: new FakeSmtpClient().sendMail.bind(new FakeSmtpClient()),
};
const t = new SmtpTransport();
await t.initialize({ client });
await expect(t.close()).resolves.toBeUndefined();
});
it("send wraps non-Error throw as TRANSPORT_FAILED", {
timeout: 5000,
}, async () => {
const adapter = {
sendMail: async () => {
throw "string-error";
},
};
const t = new SmtpTransport();
await t.initialize({ client: adapter });
const r = await t.send({ to: "a@b.c", subject: "hi", text: "hi" });
expect(r.success).toBe(false);
expect((r.error as { code?: string }).code).toBe(
EMAILER_CODES.TRANSPORT_FAILED
);
});
it("send with html only (text undefined branch)", {
timeout: 5000,
}, async () => {
const client = new FakeSmtpClient();
client.push({ info: { messageId: "<h@h>" } });
const t = new SmtpTransport();
await t.initialize({ client });
const r = await t.send({
to: "a@b.c",
subject: "hi",
html: "<p>hi</p>",
});
expect(r.success).toBe(true);
expect(client.calls[0]!.payload.text).toBeUndefined();
expect(client.calls[0]!.payload.html).toBe("<p>hi</p>");
});
it("init rejects invalid config", { timeout: 5000 }, async () => {
const client = new FakeSmtpClient();
const t = new SmtpTransport();
await expect(t.initialize({ client, port: 0 })).rejects.toMatchObject({
code: EMAILER_CODES.INVALID_CONFIG,
});
});
});

View File

@@ -0,0 +1,131 @@
import { BaseEmailTransport } from "../../core/email-service/base-email-transport.js";
import { EMAILER_CODES, EmailerError } from "../../error-codes/error-codes.js";
import { SmtpTransportOptionsSchema } from "../../schemas/schemas.js";
import type {
EmailMessage,
EmailSendResult,
} from "../../types/emailer.types.js";
import type {
SmtpClientLike,
SmtpSendPayload,
} from "./smtp-client-like.types.js";
/** Configuration for `SmtpTransport`. */
export interface SmtpTransportOptions {
/** Caller-supplied SMTP client adapter. Required. */
readonly client: SmtpClientLike;
/** Optional human-readable host. */
readonly host?: string;
/** Optional port. */
readonly port?: number;
/** Optional TLS flag (purely informational; the adapter owns transport security). */
readonly secure?: boolean;
/** Optional authenticated user (informational only). */
readonly user?: string;
}
/**
* SMTP transport that delegates to a caller-supplied `SmtpClientLike`
* adapter. The emailer package itself does not bundle nodemailer (or any
* other SMTP library); consumers wire the library of their choice in their
* own app shell and inject a thin adapter.
*/
export class SmtpTransport extends BaseEmailTransport<SmtpTransportOptions> {
public readonly name = "smtp";
#client: SmtpClientLike | undefined;
public override async initialize(
config: SmtpTransportOptions
): Promise<void> {
const parsed = SmtpTransportOptionsSchema.safeParse({
host: config.host,
port: config.port,
secure: config.secure,
user: config.user,
});
if (!parsed.success) {
throw new EmailerError(
EMAILER_CODES.INVALID_CONFIG,
"emailer.errors.invalidConfig",
{ cause: parsed.error }
);
}
await super.initialize(config);
this.#client = config.client;
}
public override async send(
message: EmailMessage
): Promise<EmailSendResult> {
this.assertInitialized();
const client = this.#client as SmtpClientLike;
try {
this.validateMessage(message);
const payload: SmtpSendPayload = this.#toPayload(message);
const info = await client.sendMail(payload);
return this.createSuccessResult(info.messageId, info);
} catch (error) {
const err =
error instanceof Error
? error
: new EmailerError(
EMAILER_CODES.TRANSPORT_FAILED,
"emailer.errors.transportFailed"
);
return this.createErrorResult(err);
}
}
public override async verify(): Promise<boolean> {
if (this.#client === undefined || this.#client.verify === undefined) {
return this.initialized;
}
try {
return await this.#client.verify();
} catch {
return false;
}
}
public override async close(): Promise<void> {
if (this.#client !== undefined && this.#client.close !== undefined) {
await this.#client.close();
}
await super.close();
}
#toPayload(message: EmailMessage): SmtpSendPayload {
const out: {
-readonly [K in keyof SmtpSendPayload]: SmtpSendPayload[K];
} = {
from: this.formatAddress(message.from),
to: this.formatAddress(message.to),
subject: message.subject,
};
if (message.cc !== undefined) {
out.cc = this.formatAddress(message.cc) as string | string[];
}
if (message.bcc !== undefined) {
out.bcc = this.formatAddress(message.bcc) as string | string[];
}
if (message.replyTo !== undefined) {
out.replyTo = this.formatAddress(message.replyTo) as
| string
| string[];
}
if (message.html !== undefined) out.html = message.html;
if (message.text !== undefined) out.text = message.text;
if (message.attachments !== undefined) {
out.attachments = message.attachments;
}
if (message.headers !== undefined) out.headers = message.headers;
if (message.priority !== undefined) out.priority = message.priority;
if (message.messageId !== undefined) out.messageId = message.messageId;
return out;
}
}
/** Construct an `SmtpTransport`. */
export function createSmtpTransport(): SmtpTransport {
return new SmtpTransport();
}

View File

@@ -0,0 +1,97 @@
# `emailer.types.ts`
## Purpose
Single source of truth for every public type surface in `@suaveplan/emailer`.
The file pins the shape of email messages, transports, hooks, options, stats,
and address bags so every concrete transport and every downstream consumer
programs against one contract — never a near-identical local alias. Keeping
the types in one file also means the spec-conformance test can assert literal
type-unions without crawling every implementation module.
## Features
- `EmailAddress`, `EmailAttachment`, `EmailMessage`, `EmailSendResult` —
wire-level shapes shared by every transport.
- `EmailTransport<TConfig>` — pluggable contract with `initialize`, `send`,
optional `verify`, and optional `close`. Concrete transports (Dummy / SMTP
/ Gmail REST) implement this directly.
- `EmailerOptions<TTransport>` — top-level service config; the transport
parameter is generic so `EmailService<TTransport>` carries the transport's
capability through its public methods.
- `EmailerHooks` — nine lifecycle hook arrays with typed context unions.
- `EmailerStats` — running counters (`sent`, `failed`, `queued`, `retries`).
- `RetryPolicy`, `QueueLike`, `EventBusLike` — structural shims so callers
inject their own implementations.
## Usage: Basic
```ts
import type { EmailMessage, EmailerOptions } from "@suaveplan/emailer/types/emailer";
const message: EmailMessage = {
to: "user@example.com",
subject: "hello",
html: "<p>hi</p>",
};
```
## Usage: Advanced
```ts
import type {
EmailTransport,
EmailerHooks,
EmailerOptions,
} from "@suaveplan/emailer/types/emailer";
interface MyConfig {
readonly endpoint: string;
}
class MyTransport implements EmailTransport<MyConfig> {
public readonly name = "my-transport";
public async initialize(_: MyConfig) {
/* ... */
}
public async send() {
return { success: true, messageId: "x", timestamp: new Date() };
}
}
const hooks: EmailerHooks = {
beforeSend: [
async (ctx) => {
console.info(`about to send ${ctx.message.subject}`);
},
],
};
const options: EmailerOptions<MyTransport> = {
transport: new MyTransport(),
transportOptions: { endpoint: "https://my.example.com" },
hooks,
};
```
## API Reference
- `EmailAddress`, `EmailAttachment`, `EmailMessage`, `EmailSendResult`
- `EmailTransport<TConfig>`
- `EmailerOptions<TTransport>`
- `EmailerHooks`, `HookFunction`, `EmailHookContext`, `EmailResultHookContext`,
`EmailErrorHookContext`, `MutableEmailerHooks`
- `EmailerStats`
- `RetryPolicy`, `QueueLike`, `EventBusLike`, `AddressBag`
## Implementation Notes
- All shapes use `readonly` where the field is settable only at construction
time. `EmailMessage` itself is mutable because the service may inject
`defaultFrom` between validation and send.
- `EmailerOptions<TTransport>` uses `infer` to derive the transport's config
parameter so `transportOptions` is typed against the supplied transport,
not just `unknown`.
- The hook context types are nominal (`EmailHookContext` vs
`EmailResultHookContext` vs `EmailErrorHookContext`) so an `onError` hook
registered against the `afterSend` array fails at compile time.

View File

@@ -0,0 +1,147 @@
import type { Clock, LoggerLike, Rng, TimersLike } from "@suaveplan/types";
/** Email address with optional display name. */
export interface EmailAddress {
readonly email: string;
readonly name?: string;
}
/** Email attachment. */
export interface EmailAttachment {
readonly filename: string;
readonly content: Uint8Array | string;
readonly contentType?: string;
readonly cid?: string;
readonly encoding?: string;
}
/** Address-bag helper used in to / cc / bcc. */
export type AddressBag =
| string
| EmailAddress
| ReadonlyArray<string | EmailAddress>;
/** Single mutable email message. */
export interface EmailMessage {
to: AddressBag;
from?: string | EmailAddress;
subject: string;
html?: string;
text?: string;
cc?: AddressBag;
bcc?: AddressBag;
replyTo?: string | EmailAddress;
attachments?: readonly EmailAttachment[];
headers?: Readonly<Record<string, string>>;
priority?: "high" | "normal" | "low";
messageId?: string;
providerOptions?: Readonly<Record<string, unknown>>;
}
/** Outcome of a single send. */
export interface EmailSendResult {
readonly success: boolean;
readonly messageId?: string;
readonly response?: unknown;
readonly error?: Error;
readonly timestamp: Date;
readonly metadata?: Readonly<Record<string, unknown>>;
}
/** Pluggable transport contract. */
export interface EmailTransport<TConfig = unknown> {
/** Human-readable transport name; used as OTel attribute. */
readonly name: string;
initialize(config: TConfig): Promise<void>;
send(message: EmailMessage): Promise<EmailSendResult>;
verify?(): Promise<boolean>;
close?(): Promise<void>;
}
/** Hook function with a generic context. */
export type HookFunction<TContext> = (
context: TContext
) => Promise<void> | void;
/** Context passed to lifecycle hooks. */
export interface EmailHookContext {
readonly message: EmailMessage;
readonly metadata: Record<string, unknown>;
readonly timestamp: Date;
}
/** Context passed to `afterSend` hooks. */
export interface EmailResultHookContext extends EmailHookContext {
readonly result: EmailSendResult;
}
/** Context passed to error hooks. */
export interface EmailErrorHookContext extends EmailHookContext {
readonly error: Error;
readonly attempt?: number;
}
/** Nine lifecycle hooks. */
export interface EmailerHooks {
beforeValidate?: ReadonlyArray<HookFunction<EmailHookContext>>;
afterValidate?: ReadonlyArray<HookFunction<EmailHookContext>>;
beforeSend?: ReadonlyArray<HookFunction<EmailHookContext>>;
afterSend?: ReadonlyArray<HookFunction<EmailResultHookContext>>;
onError?: ReadonlyArray<HookFunction<EmailErrorHookContext>>;
afterError?: ReadonlyArray<HookFunction<EmailErrorHookContext>>;
beforeRetry?: ReadonlyArray<HookFunction<EmailErrorHookContext>>;
beforeQueue?: ReadonlyArray<HookFunction<EmailHookContext>>;
afterQueue?: ReadonlyArray<HookFunction<EmailHookContext>>;
}
/** Internal mutable hook map. */
export type MutableEmailerHooks = {
[K in keyof EmailerHooks]-?: Array<NonNullable<EmailerHooks[K]>[number]>;
};
/** Retry policy. */
export interface RetryPolicy {
readonly maxAttempts: number;
readonly delay: number;
readonly backoff?: "linear" | "exponential";
}
/** Structural queue contract. */
export interface QueueLike {
add(fn: () => Promise<void>): Promise<void>;
}
/** Minimal structural event bus. */
export interface EventBusLike {
emit(event: string, payload: unknown): void;
}
/** Top-level options for the `EmailService`. */
export interface EmailerOptions<
TTransport extends EmailTransport = EmailTransport,
> {
readonly transport: TTransport;
readonly transportOptions?: TTransport extends EmailTransport<infer TC>
? TC
: unknown;
readonly defaultFrom?: string | EmailAddress;
readonly logger?: LoggerLike;
readonly events?: EventBusLike;
readonly queue?: QueueLike;
readonly retry?: RetryPolicy;
readonly hooks?: EmailerHooks;
readonly clock?: Clock;
readonly timers?: TimersLike;
readonly rng?: Rng;
readonly signal?: AbortSignal;
}
/** Running statistics for an `EmailService`. */
export interface EmailerStats {
sent: number;
failed: number;
queued: number;
retries: number;
startTime: Date;
lastSendTime?: Date;
}

View File

@@ -0,0 +1,19 @@
export type {
AddressBag,
EmailAddress,
EmailAttachment,
EmailErrorHookContext,
EmailerHooks,
EmailerOptions,
EmailerStats,
EmailHookContext,
EmailMessage,
EmailResultHookContext,
EmailSendResult,
EmailTransport,
EventBusLike,
HookFunction,
MutableEmailerHooks,
QueueLike,
RetryPolicy,
} from "./emailer.types.js";

View File

@@ -0,0 +1,63 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const pkgPath = join(__dirname, "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
name: string;
version: string;
sideEffects: boolean;
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
exports: Record<string, unknown>;
files: string[];
};
describe("@suaveplan/emailer package.json", () => {
it("starts at 0.1.0", { timeout: 5000 }, () => {
expect(pkg.version).toBe("0.1.0");
});
it("is tree-shake friendly", { timeout: 5000 }, () => {
expect(pkg.sideEffects).toBe(false);
});
it("depends on declared workspace siblings only", { timeout: 5000 }, () => {
for (const dep of [
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/logger",
"@suaveplan/queue",
"@suaveplan/timing",
"@suaveplan/types",
]) {
expect(pkg.dependencies[dep]).toBe("workspace:^");
}
// No nodemailer / googleapis hard deps.
expect(pkg.dependencies["nodemailer"]).toBeUndefined();
expect(pkg.dependencies["googleapis"]).toBeUndefined();
});
it("declares subpath exports", { timeout: 5000 }, () => {
for (const subpath of [
".",
"./error-codes",
"./schemas/schemas",
"./types/emailer",
"./core/email-service",
"./core/retry",
"./core/validate",
"./transports/dummy",
"./transports/smtp",
"./transports/gmail-rest",
"./fakes/fake-smtp-client",
"./otel/metrics",
]) {
expect(pkg.exports[subpath]).toBeDefined();
}
});
it("publishes dist, src, README.md", { timeout: 5000 }, () => {
expect(pkg.files).toEqual(["dist", "src", "README.md"]);
});
});

View File

@@ -0,0 +1,71 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = join(__dirname, "..", "src");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...walk(full));
} else if (full.endsWith(".ts")) {
out.push(full);
}
}
return out;
}
function find(
pattern: RegExp,
options: { includeTests?: boolean; onlyIndex?: boolean } = {}
): string[] {
const files = walk(SRC).filter((f) => {
if (options.includeTests !== true && f.endsWith(".test.ts"))
return false;
if (options.onlyIndex === true && !f.endsWith("index.ts")) return false;
return true;
});
const hits: string[] = [];
for (const f of files) {
const content = readFileSync(f, "utf8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
if (pattern.test(lines[i]!)) {
hits.push(`${f}:${i + 1}: ${lines[i]!.trim()}`);
}
}
}
return hits;
}
describe("emailer no-disallowed patterns", () => {
it("no vi.mock / jest.mock", { timeout: 5000 }, () => {
expect(find(/\b(vi|jest)\.mock\(/, { includeTests: true })).toEqual([]);
});
it("no console.log in production src", { timeout: 5000 }, () => {
expect(find(/console\.log\(/)).toEqual([]);
});
it("no biome-ignore comments", { timeout: 5000 }, () => {
expect(find(/biome-ignore/, { includeTests: true })).toEqual([]);
});
it("no direct vitest / bun:test imports in test files", {
timeout: 5000,
}, () => {
expect(
find(/from ['"](vitest|bun:test)['"]/, { includeTests: true })
).toEqual([]);
});
it("no export * in non-root barrels", { timeout: 5000 }, () => {
const hits = find(/^\s*export\s+(type\s+)?\*\s+from/, {
onlyIndex: true,
includeTests: true,
}).filter((h) => !h.includes(`${SRC}/index.ts:`));
expect(hits).toEqual([]);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/emailer",
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: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/logger",
"@suaveplan/queue",
"@suaveplan/timing",
"@suaveplan/types",
"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,
},
});