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:
@@ -0,0 +1,92 @@
|
||||
# `base-email-transport.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
`BaseEmailTransport` is the abstract helper that every concrete transport
|
||||
(`DummyTransport`, `SmtpTransport`, `GmailRestTransport`) extends. It
|
||||
centralises address formatting, message validation, initialisation
|
||||
bookkeeping, and result-construction so concrete transports stay focused
|
||||
on their wire format — not on cross-cutting concerns that would drift
|
||||
between implementations otherwise.
|
||||
|
||||
## Features
|
||||
|
||||
- `initialize(config)` records the config and flips an `initialized` flag;
|
||||
subclasses can override but typically just call `super.initialize`.
|
||||
- `assertInitialized()` raises `EmailerError(EMAILER.TRANSPORT_NOT_INITIALIZED)`
|
||||
if called before `initialize`, so every transport's `send` has a single
|
||||
guarded entry point.
|
||||
- `formatAddress`, `formatSingleAddress`, `extractEmail`, `toEmailArray` —
|
||||
thin proxies to `core/validate` so subclasses use the canonical
|
||||
formatters via `this.*`.
|
||||
- `createSuccessResult(messageId, response, metadata)` and
|
||||
`createErrorResult(error, metadata)` — produce `EmailSendResult` with the
|
||||
shape every transport returns. Conditional property assignment honours
|
||||
`exactOptionalPropertyTypes` from `tsconfig.base.json`.
|
||||
- `validateMessage(message)` — proxies to `core/validate.validateMessage`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BaseEmailTransport } from "@suaveplan/emailer/core/base-email-transport";
|
||||
|
||||
class NullTransport extends BaseEmailTransport<{}> {
|
||||
public readonly name = "null";
|
||||
public override async send(message) {
|
||||
this.assertInitialized();
|
||||
this.validateMessage(message);
|
||||
return this.createSuccessResult("null-1");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BaseEmailTransport } from "@suaveplan/emailer/core/base-email-transport";
|
||||
import type { EmailMessage, EmailSendResult } from "@suaveplan/emailer/types/emailer";
|
||||
|
||||
interface MyConfig {
|
||||
readonly target: URL;
|
||||
}
|
||||
|
||||
export class MyTransport extends BaseEmailTransport<MyConfig> {
|
||||
public readonly name = "my-transport";
|
||||
|
||||
public override async send(message: EmailMessage): Promise<EmailSendResult> {
|
||||
this.assertInitialized();
|
||||
try {
|
||||
this.validateMessage(message);
|
||||
const recipients = this.toEmailArray(message.to);
|
||||
const from = this.formatSingleAddress(message.from ?? "noreply@x.y");
|
||||
// ... actual wire send ...
|
||||
return this.createSuccessResult("ok", { recipients, from });
|
||||
} catch (error) {
|
||||
return this.createErrorResult(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `abstract class BaseEmailTransport<TConfig>`
|
||||
- `abstract readonly name: string`
|
||||
- `initialize(config: TConfig): Promise<void>`
|
||||
- `abstract send(message: EmailMessage): Promise<EmailSendResult>`
|
||||
- `verify(): Promise<boolean>` — defaults to the `initialized` flag.
|
||||
- `close(): Promise<void>` — flips `initialized` to `false`.
|
||||
- `assertInitialized(): void` (protected)
|
||||
- `formatSingleAddress`, `formatAddress`, `extractEmail`, `toEmailArray`,
|
||||
`validateMessage`, `createSuccessResult`, `createErrorResult` (protected)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The class is universal — every helper avoids Node-only APIs so the
|
||||
class compiles cleanly under the package's `"lib": ["ES2022", "DOM"]`
|
||||
target.
|
||||
- `createSuccessResult` and `createErrorResult` use the `-readonly` mapped
|
||||
type trick to build up the result object piece-by-piece without
|
||||
violating `exactOptionalPropertyTypes`.
|
||||
- `assertInitialized` is the only throw site in the base class; every
|
||||
other helper returns or proxies.
|
||||
@@ -0,0 +1,104 @@
|
||||
# `email-service.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EmailService<TTransport>` is the public entry point for `@suaveplan/emailer`.
|
||||
It orchestrates message validation, lifecycle hooks, retry with backoff,
|
||||
queue dispatch, OpenTelemetry emission, structured logging, and running
|
||||
statistics against any `EmailTransport` implementation. The service is
|
||||
generic over the transport so type-level capability (e.g. that a particular
|
||||
transport's config bag is `GmailRestTransportOptions`) propagates through
|
||||
`EmailService.initialize` without runtime widening.
|
||||
|
||||
## Features
|
||||
|
||||
- Validates `EmailerOptions` via Zod `safeParse` in the constructor; throws
|
||||
`EmailerError(EMAILER.INVALID_CONFIG)` carrying the `ZodError` on bad
|
||||
input.
|
||||
- Nine lifecycle hook arrays (`beforeValidate`, `afterValidate`,
|
||||
`beforeSend`, `afterSend`, `onError`, `afterError`, `beforeRetry`,
|
||||
`beforeQueue`, `afterQueue`) with `addHook` / `removeHook` / `clearHooks`.
|
||||
- Retry with linear or exponential backoff via injectable `TimersLike` and
|
||||
`AbortSignal`.
|
||||
- Optional queue dispatch via structural `QueueLike` (`{ add(fn) }`).
|
||||
- OTel emission on every send / error / retry / queue path.
|
||||
- Stats: `sent`, `failed`, `queued`, `retries`, `startTime`, `lastSendTime`.
|
||||
- `initialize` invokes `transport.initialize(transportOptions)` then
|
||||
`transport.verify()` if present; verification failure raises
|
||||
`EMAILER.VERIFY_FAILED`.
|
||||
- `close` calls the transport's `close` and emits `service:closed`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailService } from "@suaveplan/emailer/core/email-service";
|
||||
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
|
||||
|
||||
const service = new EmailService({
|
||||
transport: createDummyTransport(),
|
||||
transportOptions: {},
|
||||
defaultFrom: "no-reply@example.com",
|
||||
});
|
||||
await service.initialize();
|
||||
const result = await service.send({
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
text: "hi",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { EmailService } from "@suaveplan/emailer/core/email-service";
|
||||
import { GmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "abc123" } });
|
||||
|
||||
const ac = new AbortController();
|
||||
const service = new EmailService({
|
||||
transport: new GmailRestTransport(),
|
||||
transportOptions: { accessToken: "ya29.test", fetch: fake.fetch },
|
||||
retry: { maxAttempts: 3, delay: 50, backoff: "exponential" },
|
||||
signal: ac.signal,
|
||||
hooks: {
|
||||
beforeSend: [async (ctx) => logger.info({ to: ctx.message.to })],
|
||||
afterSend: [async (ctx) => audit.record(ctx.result.messageId)],
|
||||
},
|
||||
});
|
||||
await service.initialize();
|
||||
const result = await service.send({ to: "x@y.z", subject: "hi", text: "hi" });
|
||||
console.info(service.getStats().sent); // 1
|
||||
await service.close();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class EmailService<TTransport extends EmailTransport>`
|
||||
- `constructor(options: EmailerOptions<TTransport>)`
|
||||
- `initialize(): Promise<void>`
|
||||
- `send(message: EmailMessage): Promise<EmailSendResult>`
|
||||
- `queue(message: EmailMessage): Promise<void>`
|
||||
- `addHook<K>(event, hook)` / `removeHook<K>(event, hook)` / `clearHooks<K>(event)`
|
||||
- `getStats(): Readonly<EmailerStats>` / `resetStats(): void`
|
||||
- `close(): Promise<void>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `send` returns `EmailSendResult` (never throws) — failures surface as
|
||||
`{ success: false, error }` so callers can decide whether to log, retry,
|
||||
or escalate without a `try`/`catch`.
|
||||
- `queue` throws `EMAILER.QUEUE_NOT_CONFIGURED` if no `QueueLike` was
|
||||
supplied; that is the only public method that uses an exception for
|
||||
control flow.
|
||||
- The Zod validation in the constructor only checks the serialisable
|
||||
subset of options. Live objects (transport, logger, bus, signal) pass
|
||||
through unchanged because Zod cannot meaningfully validate them.
|
||||
- Stats are mutable internally but the `getStats()` snapshot is a copy so
|
||||
callers cannot observe a partially-mutated counter.
|
||||
@@ -0,0 +1,96 @@
|
||||
# `retry.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic retry helper used by `EmailService.send`. The helper takes a
|
||||
`RetryPolicy`, an injectable `TimersLike`, an optional `AbortSignal`, and a
|
||||
`RetryHooks` bag, then drives a bounded attempt loop with linear or
|
||||
exponential backoff. Injecting the timers means tests can drive the loop
|
||||
through synthetic delays without scheduling real `setTimeout` callbacks,
|
||||
which is essential for deterministic CI runs on WSL2.
|
||||
|
||||
## Features
|
||||
|
||||
- `computeBackoff(policy, attempt)` — returns the delay between `attempt`
|
||||
and `attempt + 1` (linear: constant `delay`; exponential: `delay * 2^(n-1)`).
|
||||
- `delay(timers, delayMs, signal)` — schedules a single delay and rejects
|
||||
early if `signal` is aborted.
|
||||
- `runWithRetry<TResult>(policy, timers, signal, hooks)` — top-level loop:
|
||||
invokes `hooks.attempt(n)`, checks `hooks.isSuccess`, fires
|
||||
`hooks.onError` / `hooks.onBeforeRetry`, increments retry stats via
|
||||
`hooks.onRetryIncrement`, and sleeps `computeBackoff` between failed
|
||||
attempts.
|
||||
- `defaultTimers` — host-default `setTimeout` / `clearTimeout` pair used
|
||||
when no `TimersLike` is supplied.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runWithRetry, defaultTimers } from "@suaveplan/emailer/core/retry";
|
||||
|
||||
const result = await runWithRetry(
|
||||
{ maxAttempts: 3, delay: 100, backoff: "exponential" },
|
||||
defaultTimers,
|
||||
undefined,
|
||||
{
|
||||
attempt: () => transport.send(message),
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: (r) => r.error,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { runWithRetry } from "@suaveplan/emailer/core/retry";
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
|
||||
const fakeTimers: TimersLike = {
|
||||
setTimer: (cb) => {
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
clearTimer: () => undefined,
|
||||
};
|
||||
|
||||
const ac = new AbortController();
|
||||
const result = await runWithRetry(
|
||||
{ maxAttempts: 5, delay: 50, backoff: "linear" },
|
||||
fakeTimers,
|
||||
ac.signal,
|
||||
{
|
||||
attempt: (n) => transport.send({ ...message, headers: { "X-Attempt": String(n) } }),
|
||||
isSuccess: (r) => r.success,
|
||||
extractError: (r) => r.error,
|
||||
onError: async (err, n) => logger.warn("send-failed", { err, n }),
|
||||
onBeforeRetry: async (err, next) => logger.info("retrying", { next }),
|
||||
onRetryIncrement: () => stats.retries++,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `computeBackoff(policy: RetryPolicy, attempt: number): number`
|
||||
- `delay(timers: TimersLike, delayMs: number, signal: AbortSignal | undefined): Promise<void>`
|
||||
- `runWithRetry<TResult>(policy, timers, signal, hooks): Promise<TResult>`
|
||||
- `defaultTimers: TimersLike`
|
||||
- `RetryHooks<TResult>` — interface
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The helper does not throw on a failed-but-defined result; it returns it
|
||||
unchanged so the caller can decide whether to surface the result or
|
||||
raise. This is what enables `EmailService` to record a stat increment
|
||||
and emit `email:failed` while still returning a result to the caller.
|
||||
- `AbortSignal` is honoured at three points: before every attempt, before
|
||||
every sleep, and as a sleep listener. Aborting between attempts raises
|
||||
`EmailerError(EMAILER.ABORTED)` rather than the last transport error.
|
||||
- The default `RetryPolicy` (`{ maxAttempts: 1, delay: 0 }`) effectively
|
||||
disables retry. `EmailService` substitutes this when no `retry` option
|
||||
is passed; the helper runs once and returns the result unchanged.
|
||||
@@ -0,0 +1,81 @@
|
||||
# `validate.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared address-formatting and message-validation helpers used by every
|
||||
concrete transport and by `EmailService` itself. Keeping these helpers in
|
||||
one module means the wire-format contract for `to` / `from` / `cc` / `bcc`
|
||||
/ `replyTo` is identical across `DummyTransport`, `SmtpTransport`, and
|
||||
`GmailRestTransport`, and that the "to / subject / body" required-field
|
||||
check is enforced at a single throw site rather than five subtly-different
|
||||
copies.
|
||||
|
||||
## Features
|
||||
|
||||
- `formatSingleAddress` — `{name, email}` → `"name" <email>` per RFC 5322.
|
||||
- `formatAddress` — address-bag (string | object | array) → string |
|
||||
string[].
|
||||
- `extractEmail` — strip the display name and unwrap `<addr>`.
|
||||
- `toEmailArray` — address-bag → string[] (used by accept/reject lists).
|
||||
- `validateMessage` — throws `EmailerError(EMAILER.INVALID_MESSAGE)` if the
|
||||
message lacks a recipient, subject, or both bodies.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatSingleAddress,
|
||||
validateMessage,
|
||||
} from "@suaveplan/emailer/core/validate";
|
||||
|
||||
formatSingleAddress({ email: "a@b.c", name: "Alice" });
|
||||
// → "\"Alice\" <a@b.c>"
|
||||
|
||||
validateMessage({ to: "x@y.z", subject: "hello", text: "hi" });
|
||||
// → no throw
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
extractEmail,
|
||||
formatAddress,
|
||||
toEmailArray,
|
||||
} from "@suaveplan/emailer/core/validate";
|
||||
|
||||
const bag = ["alice@example.com", { email: "bob@example.com", name: "Bob" }];
|
||||
const wire = formatAddress(bag);
|
||||
// → ["alice@example.com", "\"Bob\" <bob@example.com>"]
|
||||
|
||||
const emails = toEmailArray(wire);
|
||||
// → ["alice@example.com", "bob@example.com"]
|
||||
|
||||
const raw = extractEmail("\"Charlie\" <c@e.com>");
|
||||
// → "c@e.com"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `formatSingleAddress(address: string | EmailAddress): string`
|
||||
- `formatAddress(address: AddressBag | undefined): string | string[] | undefined`
|
||||
- `extractEmail(address: string | EmailAddress): string`
|
||||
- `toEmailArray(address: AddressBag | undefined): string[]`
|
||||
- `validateMessage(message: EmailMessage): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `validateMessage` raises `EmailerError(EMAILER.INVALID_MESSAGE)` with the
|
||||
failing field path (`to` / `subject` / `body`) carried as `context.field`
|
||||
so downstream loggers know which field to call out.
|
||||
- `extractEmail` accepts bare email strings (returns unchanged), addresses
|
||||
in `Name <addr>` form (extracted), and `EmailAddress` objects (returns
|
||||
`.email`). It deliberately does not validate the extracted string; that
|
||||
is the boundary's job, not the formatter's.
|
||||
- `formatAddress` returns `undefined` (not an empty string) when the input
|
||||
is undefined so callers can spread the result into wire payloads without
|
||||
emitting an empty header.
|
||||
@@ -0,0 +1,88 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/emailer` ships a single stable error-code registry that every
|
||||
failure path in the package routes through. The registry is built via
|
||||
`defineCodes("EMAILER", [...])` from `@suaveplan/error`, which freezes the
|
||||
codes into a `{ KEY: "EMAILER.KEY" }` map at compile time and preserves the
|
||||
literal-typed union all the way through to downstream consumers.
|
||||
|
||||
This guarantees three properties that hand-rolled `string` constants cannot:
|
||||
|
||||
1. Every code carries the `EMAILER.` prefix so it sorts and filters cleanly
|
||||
alongside other `@suaveplan/*` registries in logs, metrics, and
|
||||
problem+json responses.
|
||||
2. The `EmailerErrorCode` union is a true literal-type union (never widened
|
||||
to `string`), so exhaustive-`switch` checks and discriminated-union
|
||||
handlers downstream catch missing branches at compile time.
|
||||
3. `EmailerError extends SuaveplanError<EmailerErrorCode>` preserves the
|
||||
typed code on every thrown instance, so the OTel error-span adapter
|
||||
tags the right `error.code` attribute without inspecting the message.
|
||||
|
||||
## Features
|
||||
|
||||
- Twelve registered codes covering every failure mode: configuration,
|
||||
validation, transport lifecycle, retry exhaustion, abort, auth, rate
|
||||
limiting, timeout, MIME build.
|
||||
- Every code is reachable from at least one production throw site —
|
||||
pinned by `src/error-codes.test.ts::"every registered code is reachable"`.
|
||||
- `EmailerError` constructor accepts `(code, messageKey, options)` so the
|
||||
i18n hook in `@suaveplan/error` can resolve the human-readable string at
|
||||
format time without ever lighting up `console.error` paths in this
|
||||
package.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EMAILER_CODES, EmailerError } from "@suaveplan/emailer/error-codes";
|
||||
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_MESSAGE,
|
||||
"emailer.errors.invalidMessage",
|
||||
{ context: { field: "to" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { EMAILER_CODES, type EmailerErrorCode } from "@suaveplan/emailer/error-codes";
|
||||
|
||||
function describe(code: EmailerErrorCode): string {
|
||||
switch (code) {
|
||||
case EMAILER_CODES.RATE_LIMITED:
|
||||
return "back off and retry with jitter";
|
||||
case EMAILER_CODES.AUTH_FAILED:
|
||||
return "refresh the access token";
|
||||
case EMAILER_CODES.TRANSPORT_FAILED:
|
||||
return "fall over to the next transport in the chain";
|
||||
// ... every other code, exhaustively
|
||||
default:
|
||||
const _exhaustive: never = code;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EMAILER_CODES` — frozen record of every code.
|
||||
- `EmailerErrorCode` — literal-type union of every fully-qualified code.
|
||||
- `EmailerError` — `class EmailerError extends SuaveplanError<EmailerErrorCode>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is the contract; never delete a code to satisfy a gate. If
|
||||
the conformance auditor reports a code as dead, wire it into the
|
||||
production path the proposal designed it for.
|
||||
- Throw `EmailerError`, never `new Error(...)`. The base class carries the
|
||||
OTel attributes, redaction, and i18n hook that downstream consumers rely
|
||||
on.
|
||||
- The package `src/index.ts` imports `./error-codes.js` for side effects so
|
||||
subpath consumers see the registered namespace even when they do not
|
||||
touch the main entry point.
|
||||
@@ -0,0 +1,76 @@
|
||||
# `fake-fetch.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic test double for `FetchLike`, used by `GmailRestTransport`
|
||||
tests. Queues scripted responses and captures every call's URL + init bag
|
||||
so assertions target exact payloads, headers, and methods. Shipping the
|
||||
fake from the package makes downstream tests against `GmailRestTransport`
|
||||
match the conventions used in the package's own test suite without
|
||||
re-implementing the contract.
|
||||
|
||||
## Features
|
||||
|
||||
- `push(response)` enqueues a scripted response: `status`, `headers`,
|
||||
`json` (auto-stringified), `text` (raw), or `reject` (forces a thrown
|
||||
fetch).
|
||||
- `calls` captures every invocation with the resolved URL and the init
|
||||
bag.
|
||||
- The exported `fetch` callable is `FetchLike`-shaped and directly
|
||||
injectable into any transport that accepts `FetchLike`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "abc" } });
|
||||
const response = await fake.fetch("https://example.com/api");
|
||||
console.info(await response.json()); // { id: "abc" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
import { GmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 429, json: { error: { message: "slow down" } } });
|
||||
|
||||
const transport = new GmailRestTransport();
|
||||
await transport.initialize({ accessToken: "t", fetch: fake.fetch });
|
||||
const r = await transport.send({ to: "a@b.c", subject: "hi", text: "hi" });
|
||||
// r.error.code === "EMAILER.RATE_LIMITED"
|
||||
console.info(fake.calls[0].url);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FakeFetch`
|
||||
- `interface FakeFetchResponse { status?; headers?; json?; text?; reject?; }`
|
||||
- `interface FakeFetchCall { url: string; init?: RequestInit; }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Body resolution order: `text` > `json` > empty string. `headers` always
|
||||
merge into the Response.
|
||||
- `reject` short-circuits the entire fetch — useful for asserting
|
||||
transport failure handling, abort wiring, and timeout classification.
|
||||
- The fake never spawns network IO, never opens sockets, and never holds
|
||||
timers; it is safe to instantiate inside synchronous setup and to share
|
||||
across describe blocks without per-test reset cost.
|
||||
- The captured `init` bag is the exact `RequestInit` the transport
|
||||
produced. Tests assert against it directly to confirm method, headers
|
||||
(including `Authorization`), and the JSON-encoded body. Because the
|
||||
capture is structural — not a string snapshot — refactors that change
|
||||
unrelated header ordering do not break the test.
|
||||
- The exported `fetch` is a bound arrow function so it can be passed
|
||||
directly into any `FetchLike` slot without `.bind(this)`. The class
|
||||
itself can be discarded once the fetch closure has been captured if
|
||||
the caller does not need to inspect the calls list.
|
||||
- A future enhancement may add per-response `delay` scheduling driven by
|
||||
an injectable `TimersLike` so consumers can assert backoff schedules
|
||||
end-to-end via `GmailRestTransport`. For now, that is the retry
|
||||
helper's job — see `src/core/retry/retry.md`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# `fake-smtp-client.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic test double for `SmtpClientLike`. Tests queue scripted
|
||||
responses, invoke the wrapped `SmtpTransport`, and inspect the captured
|
||||
calls — without `vi.mock`, without a real SMTP server, and without
|
||||
monkey-patching globals. Shipping the fake from the package itself means
|
||||
downstream consumers can test their integration against the same exact
|
||||
shape `SmtpTransport` produces, without re-implementing the protocol fake.
|
||||
|
||||
## Features
|
||||
|
||||
- `push(response)` queues a scripted outcome (`info` for success, `reject`
|
||||
for failure).
|
||||
- `calls` captures every `sendMail` invocation in FIFO order so
|
||||
assertions can target individual recipients / headers / attachments.
|
||||
- `scheduleVerify(true | false | Error)` programs the next `verify()` call.
|
||||
- `verifyCalls` and `closeCalls` track lifecycle so tests can assert
|
||||
cleanup behaviour.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
|
||||
import { SmtpTransport } from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
const adapter = new FakeSmtpClient();
|
||||
adapter.push({ info: { messageId: "<abc@host>" } });
|
||||
|
||||
const transport = new SmtpTransport();
|
||||
await transport.initialize({ client: adapter });
|
||||
const r = await transport.send({ to: "u@x.y", subject: "hi", text: "hi" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
|
||||
|
||||
const adapter = new FakeSmtpClient();
|
||||
adapter.push({ reject: new Error("connection refused") });
|
||||
adapter.push({ info: { messageId: "<1@h>", accepted: ["a@b.c"] } });
|
||||
adapter.scheduleVerify(true);
|
||||
|
||||
// First send fails, second succeeds, verify returns true.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FakeSmtpClient implements SmtpClientLike`
|
||||
- `interface FakeSmtpResponse { info?, reject? }`
|
||||
- `interface FakeSmtpCall { payload: SmtpSendPayload }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The fake is intentionally minimal — it carries no SMTP state, no auth,
|
||||
no connection pool. The point is that `SmtpTransport` only ever calls
|
||||
three methods (`sendMail`, `verify`, `close`), and the fake reproduces
|
||||
exactly those three semantics with FIFO scripting.
|
||||
- `scheduleVerify(Error)` is single-use — after the next `verify` throws,
|
||||
the reject queue clears. Subsequent calls return the most-recently
|
||||
scheduled boolean (default `true`).
|
||||
- `closeCalls` increments on every `close`, allowing tests to assert
|
||||
idempotent close behaviour without inspecting private state.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `metrics.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Single source of truth for every OpenTelemetry instrument emitted by
|
||||
`@suaveplan/emailer`. Every production path that touches the wire — send
|
||||
attempts, send failures, latency, retries, queue enqueues — records exactly
|
||||
one observation through one of the helpers exported here. Centralising the
|
||||
emission lets the spec-conformance test grep one file to assert that every
|
||||
declared instrument is wired into a production call.
|
||||
|
||||
## Features
|
||||
|
||||
- `recordSend(transport, outcome, latencyMs)` — counter + histogram pair
|
||||
for every send attempt.
|
||||
- `recordSendError(transport, code)` — counter keyed on the typed
|
||||
`EmailerErrorCode` so dashboards filter by code path, not message.
|
||||
- `recordRetry(transport)` — counter on every retry attempt.
|
||||
- `recordQueue(transport)` — counter on every enqueue.
|
||||
- `refreshEmailerMeters()` — re-binds every instrument against the current
|
||||
global `MeterProvider`; tests call this after installing an in-memory
|
||||
provider so subsequent emissions land on the test exporter.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordSend } from "@suaveplan/emailer/otel/metrics";
|
||||
|
||||
const startedAt = Date.now();
|
||||
// ... actual send ...
|
||||
recordSend("dummy", "ok", Date.now() - startedAt);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { refreshEmailerMeters, recordSend } from "@suaveplan/emailer/otel/metrics";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshEmailerMeters();
|
||||
|
||||
recordSend("dummy", "ok", 12);
|
||||
// `provider.collect()` now contains an `emailer.send.total` observation
|
||||
// with attributes `{ transport: "dummy", outcome: "ok" }`.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `refreshEmailerMeters(): void`
|
||||
- `recordSend(transport: string, outcome: "ok" | "error", latencyMs: number): void`
|
||||
- `recordSendError(transport: string, code: string): void`
|
||||
- `recordRetry(transport: string): void`
|
||||
- `recordQueue(transport: string): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The five instrument names — `emailer.send.total`,
|
||||
`emailer.send.errors`, `emailer.send.latency_ms`, `emailer.retry.total`,
|
||||
`emailer.queue.depth` — are part of the public surface. Renaming them
|
||||
silently breaks every downstream dashboard, so any change must move
|
||||
through OpenSpec.
|
||||
- `refreshEmailerMeters` is required because the OpenTelemetry SDK does
|
||||
not propagate provider changes to existing instruments. Tests that swap
|
||||
the global provider must call it before invoking the production path,
|
||||
otherwise observations land on the module-load meter and the test sees
|
||||
zero data.
|
||||
- All instruments are universally available — the helpers carry no IO and
|
||||
are safe to call from synchronous hot paths.
|
||||
@@ -0,0 +1,89 @@
|
||||
# `schemas.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralised Zod schemas for every public option bundle and wire payload in
|
||||
`@suaveplan/emailer`. The schemas exist to enforce a hard boundary between
|
||||
the package and its callers: every option object passed into the service or
|
||||
into a concrete transport is `safeParse`d before it is trusted. On failure
|
||||
the relevant constructor raises `EmailerError(EMAILER.INVALID_CONFIG)` with
|
||||
the `ZodError` carried as the `cause` so observability, logging, and
|
||||
problem+json responders downstream can render the offending field path.
|
||||
|
||||
## Features
|
||||
|
||||
- `EmailAddressSchema`, `AddressBagSchema`, `EmailAttachmentSchema`,
|
||||
`EmailMessageSchema` — wire-level payloads.
|
||||
- `RetryPolicySchema`, `EmailerOptionsShapeSchema` — service config.
|
||||
- `DummyTransportOptionsSchema`, `SmtpTransportOptionsSchema`,
|
||||
`GmailRestTransportOptionsSchema` — per-transport configs.
|
||||
- All schemas use Zod 4's default import (`import z from "zod"`) per the
|
||||
Bun-safe convention codified in MEMORY.md.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailMessageSchema } from "@suaveplan/emailer/schemas/schemas";
|
||||
|
||||
const result = EmailMessageSchema.safeParse({
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
text: "hi",
|
||||
});
|
||||
if (!result.success) {
|
||||
throw result.error;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { GmailRestTransportOptionsSchema } from "@suaveplan/emailer/schemas/schemas";
|
||||
import { EMAILER_CODES, EmailerError } from "@suaveplan/emailer/error-codes";
|
||||
|
||||
function loadGmailConfigFromEnv(env: Record<string, string | undefined>) {
|
||||
const parsed = GmailRestTransportOptionsSchema.safeParse({
|
||||
accessToken: env["GMAIL_ACCESS_TOKEN"],
|
||||
userId: env["GMAIL_USER_ID"],
|
||||
});
|
||||
if (!parsed.success) {
|
||||
throw new EmailerError(
|
||||
EMAILER_CODES.INVALID_CONFIG,
|
||||
"emailer.errors.invalidConfig",
|
||||
{ cause: parsed.error, context: { source: "env" } },
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EmailAddressSchema`
|
||||
- `AddressBagSchema`
|
||||
- `EmailAttachmentSchema`
|
||||
- `EmailMessageSchema`
|
||||
- `RetryPolicySchema`
|
||||
- `EmailerOptionsShapeSchema`
|
||||
- `DummyTransportOptionsSchema`
|
||||
- `SmtpTransportOptionsSchema`
|
||||
- `GmailRestTransportOptionsSchema`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `EmailerOptionsShapeSchema` only validates the serialisable subset of
|
||||
`EmailerOptions`; the live objects (transport instance, logger, event
|
||||
bus, queue, signal) are passed through unchanged because Zod cannot
|
||||
meaningfully assert "this object implements `EmailTransport`."
|
||||
- `AddressBagSchema` accepts either a single string, a structured
|
||||
`EmailAddress`, or a non-empty array of either; this matches the wire
|
||||
shape every transport accepts.
|
||||
- `EmailAttachmentSchema` accepts `Uint8Array` (universal) or `string`; the
|
||||
upstream `NodeJS.ReadableStream` form is deliberately dropped because it
|
||||
would force a Node dependency.
|
||||
- `RetryPolicySchema` enforces `maxAttempts >= 1` so the policy is always
|
||||
attempt-count-meaningful — a `0` value would deadlock the runner.
|
||||
@@ -0,0 +1,96 @@
|
||||
# `dummy-transport.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DummyTransport` is a deterministic in-memory transport designed for tests,
|
||||
fixtures, and local development. It records every message it receives,
|
||||
forwards lifecycle events to an injected `EventBusLike`, and can simulate
|
||||
forced failure, fixed latency, and verification toggles — all without
|
||||
lighting up any real network or holding any IO handle.
|
||||
|
||||
## Features
|
||||
|
||||
- `getSentEmails()`, `getLastEmail()`, `getSendCount()` — inspect the
|
||||
recorded history.
|
||||
- `clearSentEmails()` — reset the recorder.
|
||||
- `shouldFail: true` — every `send` returns `{ success: false, error }`.
|
||||
- `delayMs > 0` — simulated latency scheduled via injectable `TimersLike`.
|
||||
- `messageIdPrefix` — controls the prefix used in generated message-ids.
|
||||
- `verifySuccess` — controls the return value of `verify`.
|
||||
- `events` — injectable `EventBusLike`; `provider:beforeSend`,
|
||||
`provider:sendSuccess`, `provider:sendError`, `provider:validationError`,
|
||||
`provider:verify`, `provider:closing`, `provider:closed`,
|
||||
`provider:cleared` are forwarded.
|
||||
- `rng` — injectable `Rng` for the message-id suffix; falls back to
|
||||
`Date.now()` if not supplied so consumers don't need a CSPRNG just to
|
||||
smoke-test.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
|
||||
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({});
|
||||
const result = await transport.send({
|
||||
to: "user@example.com",
|
||||
subject: "hi",
|
||||
text: "hello",
|
||||
});
|
||||
console.info(transport.getSentEmails().length); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createDummyTransport } from "@suaveplan/emailer/transports/dummy";
|
||||
import type { EventBusLike, Rng, TimersLike } from "@suaveplan/types";
|
||||
|
||||
const events: EventBusLike = { emit: (e, p) => bus.publish(e, p) };
|
||||
const rng: Rng = {
|
||||
nextUint32: () => 0xdeadbeef,
|
||||
nextFloat: () => 0.5,
|
||||
};
|
||||
const timers: TimersLike = {
|
||||
setTimer: (cb) => {
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
clearTimer: () => undefined,
|
||||
};
|
||||
|
||||
const transport = createDummyTransport();
|
||||
await transport.initialize({
|
||||
shouldFail: false,
|
||||
delayMs: 100,
|
||||
messageIdPrefix: "test",
|
||||
verifySuccess: true,
|
||||
events,
|
||||
rng,
|
||||
timers,
|
||||
});
|
||||
const r = await transport.send({ to: "x@y.z", subject: "hi", text: "hi" });
|
||||
// r.messageId === "test-1-deadbeef"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class DummyTransport extends BaseEmailTransport<DummyTransportOptions>`
|
||||
- `createDummyTransport(): DummyTransport`
|
||||
- `interface DummyTransportOptions`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The transport extends `BaseEmailTransport` and reuses every helper, so
|
||||
the wire-shape rules are identical to the SMTP and Gmail transports.
|
||||
- `delayMs` uses the injected timer's `setTimer` directly; tests can pass
|
||||
a synchronous `setTimer` to drop the delay entirely.
|
||||
- Message-id construction is deterministic when `rng` is supplied, which
|
||||
lets snapshot tests assert against exact strings.
|
||||
- The recorded history is exposed as a defensive copy
|
||||
(`[...this.sentEmails]`) so callers cannot mutate the transport's
|
||||
internal state.
|
||||
@@ -0,0 +1,98 @@
|
||||
# `gmail-rest-transport.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GmailRestTransport` sends mail through Gmail's REST API directly, over an
|
||||
injectable `FetchLike`. The transport builds a minimal RFC 822 envelope,
|
||||
base64url-encodes it, and POSTs it to `users.messages.send`. Crucially the
|
||||
package does not depend on `googleapis` or `nodemailer` — every token-
|
||||
acquisition concern (OAuth2 refresh, service-account JWT, app passwords) is
|
||||
the caller's, so this transport stays universal and runs unchanged in
|
||||
Bun, Node, and the browser.
|
||||
|
||||
## Features
|
||||
|
||||
- Universal: only requires `FetchLike` plus a caller-supplied access
|
||||
token.
|
||||
- `toBase64Url` exported as a pure helper for downstream consumers that
|
||||
need the same encoding.
|
||||
- Status-code → error-code classifier:
|
||||
- 401 / 403 → `EMAILER.AUTH_FAILED`
|
||||
- 429 → `EMAILER.RATE_LIMITED`
|
||||
- any other non-2xx → `EMAILER.TRANSPORT_FAILED`
|
||||
- Carries the HTTP status and error detail on the thrown `EmailerError`'s
|
||||
`context` bag so downstream loggers render structured detail without
|
||||
scraping the message string.
|
||||
- Zod validation on initialise via `GmailRestTransportOptionsSchema`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createGmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
|
||||
const transport = createGmailRestTransport();
|
||||
await transport.initialize({ accessToken: "ya29.a0..." });
|
||||
const r = await transport.send({
|
||||
to: "user@example.com",
|
||||
subject: "hi",
|
||||
text: "hello",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createGmailRestTransport } from "@suaveplan/emailer/transports/gmail-rest";
|
||||
import { FakeFetch } from "@suaveplan/emailer/fakes/fake-fetch";
|
||||
|
||||
// Test wiring with a deterministic fetch double:
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { id: "1816fc...", threadId: "1816fc..." } });
|
||||
|
||||
const transport = createGmailRestTransport();
|
||||
await transport.initialize({
|
||||
accessToken: "ya29.test",
|
||||
userId: "alice@example.com",
|
||||
endpoint: "https://gmail.googleapis.com/gmail/v1",
|
||||
fetch: fake.fetch,
|
||||
});
|
||||
const r = await transport.send({
|
||||
to: "bob@example.com",
|
||||
subject: "hi",
|
||||
html: "<p>hello</p>",
|
||||
});
|
||||
const captured = fake.calls[0]!;
|
||||
console.info(captured.url);
|
||||
// → https://gmail.googleapis.com/gmail/v1/users/alice%40example.com/messages/send
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class GmailRestTransport extends BaseEmailTransport<GmailRestTransportOptions>`
|
||||
- `createGmailRestTransport(): GmailRestTransport`
|
||||
- `interface GmailRestTransportOptions { accessToken; userId?; endpoint?; fetch?; }`
|
||||
- `toBase64Url(input: string): string`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The endpoint URL is constructed via `encodeURIComponent(userId)` so
|
||||
user-ids with `@` characters (e.g. delegated mailboxes) are escaped
|
||||
correctly. The default `userId` is `"me"`, matching the Gmail REST
|
||||
convention.
|
||||
- Token refresh is deliberately outside the transport's scope; the access
|
||||
token is a snapshot of the caller's identity at construction time.
|
||||
Long-lived services should re-initialise (or re-construct) the
|
||||
transport when the token expires.
|
||||
- The MIME builder is intentionally minimal — `text/plain` or `text/html`
|
||||
with optional caller-supplied headers. Multipart, inline images, and
|
||||
attachment encoding are deferred to a follow-up because Gmail's REST
|
||||
API itself accepts any valid MIME envelope, so the caller can pre-build
|
||||
one and pass it via `message.headers` if they need more than the
|
||||
built-in format.
|
||||
- `toBase64Url` prefers the WHATWG `btoa` route so the transport works in
|
||||
the browser; the Node `Buffer` fallback exists only for environments
|
||||
that strip `btoa`.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `smtp-client-like.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the minimal structural contract every SMTP adapter must satisfy.
|
||||
The contract is intentionally tiny — `sendMail`, optional `verify`, optional
|
||||
`close` — so any SMTP library (nodemailer, lettre over a Node binding,
|
||||
mailparser, a custom Bun/Node wrapper, ...) can be plugged in via a thin
|
||||
wrapper without the emailer package ever importing the library directly.
|
||||
|
||||
## Features
|
||||
|
||||
- `SmtpClientLike` — the adapter contract: `sendMail(payload)`, optional
|
||||
`verify()`, optional `close()`.
|
||||
- `SmtpSendPayload` — the wire shape every adapter receives: `from`, `to`,
|
||||
`cc`, `bcc`, `replyTo`, `subject`, `html`, `text`, `attachments`,
|
||||
`headers`, `priority`, `messageId`.
|
||||
- `SmtpSendInfo` — the response shape the adapter returns: `messageId`,
|
||||
`accepted`, `rejected`, `response`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SmtpClientLike,
|
||||
SmtpSendInfo,
|
||||
SmtpSendPayload,
|
||||
} from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
const adapter: SmtpClientLike = {
|
||||
sendMail: async (payload: SmtpSendPayload): Promise<SmtpSendInfo> => {
|
||||
// Forward `payload` to the underlying library
|
||||
return { messageId: "abc" };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SmtpClientLike,
|
||||
SmtpSendInfo,
|
||||
SmtpSendPayload,
|
||||
} from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
class TracingAdapter implements SmtpClientLike {
|
||||
constructor(private readonly inner: SmtpClientLike) {}
|
||||
async sendMail(payload: SmtpSendPayload): Promise<SmtpSendInfo> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const info = await this.inner.sendMail(payload);
|
||||
tracer.recordSend(info.messageId, Date.now() - started);
|
||||
return info;
|
||||
} catch (err) {
|
||||
tracer.recordError(err, Date.now() - started);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
verify = () => this.inner.verify?.() ?? Promise.resolve(true);
|
||||
close = () => this.inner.close?.();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SmtpClientLike`
|
||||
- `interface SmtpSendPayload`
|
||||
- `interface SmtpSendInfo`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The contract uses `readonly` properties on the payload so adapters can
|
||||
safely store references without worrying about mutation by the
|
||||
transport.
|
||||
- `attachments` is typed without a `NodeJS.ReadableStream` member so the
|
||||
package compiles without `@types/node` runtime types; if a caller needs
|
||||
to stream from disk, they can buffer first or supply a `Uint8Array`.
|
||||
- The interface intentionally omits connection pool / max-messages /
|
||||
max-connections — those concerns belong to the adapter, not the
|
||||
transport contract. Surfacing them here would couple the emailer to one
|
||||
specific SMTP library's tuning knobs.
|
||||
@@ -0,0 +1,86 @@
|
||||
# `smtp-transport.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SmtpTransport` is a structural SMTP transport: it accepts a caller-supplied
|
||||
`SmtpClientLike` adapter (the contract is small — `sendMail`, optional
|
||||
`verify`, optional `close`) and forwards every send call through it. The
|
||||
emailer package itself never imports nodemailer, lettre, or any other SMTP
|
||||
library; consumers wire the library of their choice in their own app
|
||||
shell and pass a thin adapter. This keeps the emailer package universal
|
||||
(zero Node-only runtime deps) while preserving the upstream SMTP feature
|
||||
parity.
|
||||
|
||||
## Features
|
||||
|
||||
- `sendMail` payload covers from / to / cc / bcc / replyTo / subject / html
|
||||
/ text / attachments / headers / priority / messageId — the same surface
|
||||
the upstream nodemailer transport accepted.
|
||||
- `verify` delegates to the adapter's `verify` if present; absent, returns
|
||||
the `initialized` flag.
|
||||
- `close` delegates to the adapter's `close` if present.
|
||||
- Zod validation on initialise via `SmtpTransportOptionsSchema`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSmtpTransport } from "@suaveplan/emailer/transports/smtp";
|
||||
import { FakeSmtpClient } from "@suaveplan/emailer/fakes/fake-smtp-client";
|
||||
|
||||
const adapter = new FakeSmtpClient();
|
||||
adapter.push({ info: { messageId: "<abc@host>" } });
|
||||
|
||||
const transport = createSmtpTransport();
|
||||
await transport.initialize({ client: adapter });
|
||||
const result = await transport.send({
|
||||
to: "user@example.com",
|
||||
subject: "hi",
|
||||
text: "hello",
|
||||
});
|
||||
// result.messageId === "<abc@host>"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Caller adapts nodemailer in their own app shell:
|
||||
import nodemailer from "nodemailer";
|
||||
import { createSmtpTransport } from "@suaveplan/emailer/transports/smtp";
|
||||
import type { SmtpClientLike } from "@suaveplan/emailer/transports/smtp";
|
||||
|
||||
const nm = nodemailer.createTransport({ host: "smtp.fastmail.com", port: 465, secure: true });
|
||||
const adapter: SmtpClientLike = {
|
||||
sendMail: async (payload) => {
|
||||
const info = await nm.sendMail(payload as unknown as Parameters<typeof nm.sendMail>[0]);
|
||||
return { messageId: info.messageId, accepted: info.accepted, rejected: info.rejected };
|
||||
},
|
||||
verify: () => nm.verify().then(() => true).catch(() => false),
|
||||
close: () => nm.close(),
|
||||
};
|
||||
const transport = createSmtpTransport();
|
||||
await transport.initialize({ client: adapter, host: "smtp.fastmail.com", port: 465, secure: true });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SmtpTransport extends BaseEmailTransport<SmtpTransportOptions>`
|
||||
- `createSmtpTransport(): SmtpTransport`
|
||||
- `interface SmtpTransportOptions { client: SmtpClientLike; host?; port?; secure?; user?; }`
|
||||
- `interface SmtpClientLike` (re-exported from `./smtp-client-like.types`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The transport never bundles nodemailer — the dependency stays in the
|
||||
caller's app shell. This keeps the emailer package universal and
|
||||
publishable to npm without bloating the install footprint of consumers
|
||||
who use HTTP-based transports.
|
||||
- `host` / `port` / `secure` / `user` are informational only — they are
|
||||
recorded for OTel attribute purposes but the adapter owns the actual
|
||||
connection.
|
||||
- `send` catches every thrown error from the adapter and converts it into
|
||||
a failed `EmailSendResult`. This matches the public contract of
|
||||
`EmailTransport.send` (never throw on send failure).
|
||||
@@ -0,0 +1,97 @@
|
||||
# `emailer.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Single source of truth for every public type surface in `@suaveplan/emailer`.
|
||||
The file pins the shape of email messages, transports, hooks, options, stats,
|
||||
and address bags so every concrete transport and every downstream consumer
|
||||
programs against one contract — never a near-identical local alias. Keeping
|
||||
the types in one file also means the spec-conformance test can assert literal
|
||||
type-unions without crawling every implementation module.
|
||||
|
||||
## Features
|
||||
|
||||
- `EmailAddress`, `EmailAttachment`, `EmailMessage`, `EmailSendResult` —
|
||||
wire-level shapes shared by every transport.
|
||||
- `EmailTransport<TConfig>` — pluggable contract with `initialize`, `send`,
|
||||
optional `verify`, and optional `close`. Concrete transports (Dummy / SMTP
|
||||
/ Gmail REST) implement this directly.
|
||||
- `EmailerOptions<TTransport>` — top-level service config; the transport
|
||||
parameter is generic so `EmailService<TTransport>` carries the transport's
|
||||
capability through its public methods.
|
||||
- `EmailerHooks` — nine lifecycle hook arrays with typed context unions.
|
||||
- `EmailerStats` — running counters (`sent`, `failed`, `queued`, `retries`).
|
||||
- `RetryPolicy`, `QueueLike`, `EventBusLike` — structural shims so callers
|
||||
inject their own implementations.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { EmailMessage, EmailerOptions } from "@suaveplan/emailer/types/emailer";
|
||||
|
||||
const message: EmailMessage = {
|
||||
to: "user@example.com",
|
||||
subject: "hello",
|
||||
html: "<p>hi</p>",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
EmailTransport,
|
||||
EmailerHooks,
|
||||
EmailerOptions,
|
||||
} from "@suaveplan/emailer/types/emailer";
|
||||
|
||||
interface MyConfig {
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
class MyTransport implements EmailTransport<MyConfig> {
|
||||
public readonly name = "my-transport";
|
||||
public async initialize(_: MyConfig) {
|
||||
/* ... */
|
||||
}
|
||||
public async send() {
|
||||
return { success: true, messageId: "x", timestamp: new Date() };
|
||||
}
|
||||
}
|
||||
|
||||
const hooks: EmailerHooks = {
|
||||
beforeSend: [
|
||||
async (ctx) => {
|
||||
console.info(`about to send ${ctx.message.subject}`);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: EmailerOptions<MyTransport> = {
|
||||
transport: new MyTransport(),
|
||||
transportOptions: { endpoint: "https://my.example.com" },
|
||||
hooks,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EmailAddress`, `EmailAttachment`, `EmailMessage`, `EmailSendResult`
|
||||
- `EmailTransport<TConfig>`
|
||||
- `EmailerOptions<TTransport>`
|
||||
- `EmailerHooks`, `HookFunction`, `EmailHookContext`, `EmailResultHookContext`,
|
||||
`EmailErrorHookContext`, `MutableEmailerHooks`
|
||||
- `EmailerStats`
|
||||
- `RetryPolicy`, `QueueLike`, `EventBusLike`, `AddressBag`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All shapes use `readonly` where the field is settable only at construction
|
||||
time. `EmailMessage` itself is mutable because the service may inject
|
||||
`defaultFrom` between validation and send.
|
||||
- `EmailerOptions<TTransport>` uses `infer` to derive the transport's config
|
||||
parameter so `transportOptions` is typed against the supplied transport,
|
||||
not just `unknown`.
|
||||
- The hook context types are nominal (`EmailHookContext` vs
|
||||
`EmailResultHookContext` vs `EmailErrorHookContext`) so an `onError` hook
|
||||
registered against the `afterSend` array fails at compile time.
|
||||
Reference in New Issue
Block a user