first commit
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user