Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

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