first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

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