diff --git a/.github/assets/cover.png b/.github/assets/cover.png index c852105..54fe4db 100644 Binary files a/.github/assets/cover.png and b/.github/assets/cover.png differ diff --git a/.github/assets/cover.svg b/.github/assets/cover.svg index 7ab9294..9c6f23e 100644 --- a/.github/assets/cover.svg +++ b/.github/assets/cover.svg @@ -63,7 +63,7 @@ - + @@ -83,14 +83,15 @@ import { createEmail } from "unemail" - import ses from "unemail/driver/ses" - const email = createEmail({ - driver: ses({ region: "us-east-1" }), - }) + import ses from "unemail/drivers/ses" + const email = createEmail({ + driver: ses({ region: "us-east-1" }), + defaults: { from: "hi@acme.com" }, + }) await email.send({ to: "ada@acme.com", subject: "Welcome", - react: <Welcome name="Ada" />, + html: "<p>Glad you are here.</p>", }) @@ -120,48 +121,49 @@ - SendGrid + SMTP - Mailgun + Mock - Brevo + Fallback - SMTP + Retry - Loops + Rate limit - Cloudflare + Breaker - MailerSend + Logger - Zeptomail + Render - + 4 more + + Round-robin @@ -196,7 +198,7 @@ - One API. 15+ providers. Zero dependencies. Every runtime. + One API. Batch-native pipeline. Zero dependencies. Every runtime. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1da2a15..b2ccfcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,13 +6,11 @@ concurrency: permissions: contents: read - pull-requests: write on: push: branches: - main - pull_request: branches: - main @@ -22,22 +20,31 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - run: corepack enable - - uses: actions/setup-node@v6 + + - uses: oven-sh/setup-bun@v2 with: - node-version: latest - cache: pnpm + bun-version: latest + - name: 📦 Install dependencies - run: pnpm install --frozen-lockfile + run: bun install --frozen-lockfile + - name: 👀 Lint - run: pnpm lint + run: bun run lint + - name: 🔍 Typecheck - run: pnpm typecheck + run: bun run typecheck + - name: 🧪 Test - run: pnpm test + run: bun run test + + - name: 🔢 Version consistency + run: bun run check:version + - name: 🚀 Build - run: pnpm build + run: bun run build + - name: 📐 Bundle size budget - run: pnpm bundle-budget + run: bun run bundle-budget + - name: 🔍 Are The Types Wrong (ATTW) - run: pnpm attw + run: bun run attw diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bd4982..1d3022e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,34 +17,40 @@ jobs: with: fetch-depth: 0 - - run: corepack enable + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + # npm publish needs a Node with the npm registry configured; bun runs + # everything else. - uses: actions/setup-node@v6 with: node-version: lts/* registry-url: "https://registry.npmjs.org" - cache: pnpm - name: 📦 Install dependencies - run: pnpm install --frozen-lockfile + run: bun install --frozen-lockfile - name: 👀 Lint - run: pnpm lint + run: bun run lint - name: 🔍 Typecheck - run: pnpm typecheck + run: bun run typecheck - name: 🧪 Test - run: pnpm test + run: bun run test + + - name: 🔢 Version consistency + run: bun run check:version - name: 🚀 Build - run: pnpm build + run: bun run build - name: 📐 Bundle size budget - run: pnpm bundle-budget + run: bun run bundle-budget - name: 🔍 Are The Types Wrong (ATTW) - run: pnpm attw + run: bun run attw - name: 📦 Publish to npm (idempotent) env: @@ -55,27 +61,26 @@ jobs: if npm view "${PKG}@${VERSION}" version >/dev/null 2>&1; then echo "✅ ${PKG}@${VERSION} already on npm — skipping publish." else - pnpm publish --provenance --access public --no-git-checks + npm publish --provenance --access public fi - name: 🦕 Publish to JSR (OIDC, idempotent) run: | SCOPE_PKG=$(node -p "require('./jsr.json').name") VERSION=$(node -p "require('./jsr.json').version") - META_URL="https://jsr.io/${SCOPE_PKG}/${VERSION}_meta.json" - if curl -sSf -o /dev/null "$META_URL"; then + if curl -sSf -o /dev/null "https://jsr.io/${SCOPE_PKG}/${VERSION}_meta.json"; then echo "✅ ${SCOPE_PKG}@${VERSION} already on JSR — skipping publish." else - npx jsr publish + bunx jsr publish fi - name: 📝 Generate changelog - run: pnpm dlx changelogithub + run: bunx changelogithub env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: 📊 Summary run: | - echo "## Release Complete 🚀" >> $GITHUB_STEP_SUMMARY + echo "## Release complete 🚀" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Version:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index cf74abb..ce669f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ node_modules dist +coverage *.tgz .DS_Store -playground/dist -playground/.vite .env -.wrangler -coverage diff --git a/.oxfmtrc.json b/.oxfmtrc.json index c6f2243..a6e3977 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,5 +1,5 @@ { "$schema": "https://unpkg.com/oxfmt/configuration_schema.json", "semi": false, - "ignorePatterns": ["CHANGELOG.md", "playground/**"] + "ignorePatterns": ["CHANGELOG.md"] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 7e86a92..f5bdec2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,5 @@ { "$schema": "https://unpkg.com/oxlint/configuration_schema.json", "plugins": ["unicorn", "typescript", "oxc"], - "ignorePatterns": ["playground/**"], "rules": {} } diff --git a/MIGRATION.md b/MIGRATION.md index 5da8510..c4b92c2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,202 +1,202 @@ -# Migrating +# Migrating from 0.x to 1.0 -## v1.0 → v1.1 (sub-path rename) +v1 is a rewrite. The driver idea survives; almost every signature around it +changed. Read the two sections that apply to you and skip the rest. -The `drivers/` and `webhooks/` sub-path segments were renamed to their -singular forms, matching the sibling libraries `ahize` and `etiket`. -This is the only breaking change in v1.1; every import is a one-token -find-and-replace. +## Why -```diff -- import resend from "unemail/drivers/resend" -+ import resend from "unemail/driver/resend" +0.x had two composition models side by side — hook-based `Middleware` +(`beforeSend` / `afterSend` / `onError`) and driver decorators +(`withRetry(driver)`). The second existed because retry cannot be written +with the first. v1 has one model, and it operates on a list, which is what +lets retry re-send only the messages that failed. -- import resendWebhook from "unemail/webhooks/resend" -+ import resendWebhook from "unemail/webhook/resend" +Four defects came out with the old design: -- import { defineWebhookHandler } from "unemail/webhooks" -+ import { defineWebhookHandler } from "unemail/webhook" -``` +- `sendBatch` returned on the first failure, losing the results of messages + that had already been accepted. +- `initialize()` set its "done" flag before awaiting, so concurrent sends + raced past a half-initialized driver, and a driver mounted after the + first send was never initialized at all. +- `withRender` mutated the caller's message object, so a reused template + quietly accumulated `html` and `text` between sends. +- A driver that did not support `personalizations` had all but the first + silently dropped. + +## Scope + +v1 ships the core, five drivers and the render layer. These 0.x entry +points are **not** in 1.0.0 and are being reintroduced against the new core: -Nothing else moves. All other segments (`render/`, `queue/`, -`inbound/`, `parse/`, `middleware`, `events`, `suppression`, -`preferences`, `compliance`, `verify`, `dmarc`, `mta-sts`, `ics`, -`address`, `result`, `test`) stay identical. +`unemail/inbound/*` · `unemail/webhook/*` · `unemail/queue/*` · +`unemail/verify/*` · `unemail/parse/*` · `unemail/dmarc` · +`unemail/mta-sts` · `unemail/ics` · `unemail/suppression` · +`unemail/compliance` · `unemail/preferences` · `unemail/events` · +`unemail/test`, and the SendGrid, Mailgun, Brevo, MailerSend, Loops, +Mailtrap, Zeptomail, MailChannels, Mailcrab, Cloudflare and Tee drivers. -Quick sed recipe: +Pin `unemail@^0.5.0` if you depend on one of those today. -```bash -grep -rl 'unemail/drivers/\|unemail/webhooks' . --include='*.ts' --include='*.tsx' \ - | xargs sed -i '' -e 's|unemail/drivers/|unemail/driver/|g' \ - -e 's|unemail/webhooks/|unemail/webhook/|g' \ - -e 's|unemail/webhooks"|unemail/webhook"|g' +## Import paths + +`driver` became `drivers`, matching `unstorage`: + +```diff +-import resend from "unemail/driver/resend" ++import resend from "unemail/drivers/resend" ``` ---- +`unemail/test` is gone; the mock driver carries the inbox now. -## v0.x → v1.0 +## Messages -v1 was a full rewrite. The provider pattern was replaced with a driver-based -architecture modeled on [`unjs/unstorage`](https://github.com/unjs/unstorage), -the error shape is a proper discriminated union, and every provider now -runs unchanged on Node, Bun, Deno, Cloudflare Workers, and the browser. +Renderer-specific fields left the core message type. One `content` block +replaces all of them: -This guide walks through the breaking changes with before/after snippets. +```diff +-await email.send({ from, to, subject, react: }) ++await email.send({ to, subject, content: { type: "react", element: } }) +``` + +Same for `jsx`, `mjml`, `handlebars` + `handlebarsVars`, and +`liquid` + `liquidVars` — each becomes a `content.type` its renderer claims. -## At a glance +`from` can now come from the instance: -| Concern | v0.x | v1.0 | -| ------------------- | ---------------------------------- | ------------------------------------------------------------------------- | -| Factory | `createEmailService({ provider })` | `createEmail({ driver })` | -| Provider definition | `defineProvider(factory)` | `defineDriver(factory)` (identical ergonomics, renamed for consistency) | -| Import path | `unemail/providers/` | `unemail/driver/` | -| Result shape | `{ success, data?, error? }` | `{ data, error: null } \| { data: null, error: EmailError }` (narrowable) | -| Error type | plain `Error` | `EmailError` with `code` taxonomy + `retryable` flag | -| Runtime | Node-only for most providers | Node + Bun + Deno + Workers + browser for every HTTP driver | -| Rendering | manual `html` / `text` | `email.use(withRender(reactRender()))` + `send({ react: })` | -| Testing | stub the provider yourself | `createTestEmail()` with `.inbox` + `waitFor` + Vitest matchers | +```diff +-await email.send({ from: "Acme ", to, subject, text }) ++const email = createEmail({ driver, defaults: { from: "Acme " } }) ++await email.send({ to, subject, text }) +``` -## Step-by-step +Removed: `personalizations` (use `sendBatch`), `amp`, `dsn`, and +`template.locale`. -### 1. Install the new entry points +New: `preheader` injects a hidden preview line into the HTML, and a header +value containing `\r` or `\n` is now rejected instead of being written into +the message. -```bash -pnpm remove unemail -pnpm add unemail@next +## Batches + +The return type changed, and it no longer short-circuits: + +```diff +-const { data, error } = await email.sendBatch(messages) +-if (error) throw error // and every accepted message was lost +-console.log(data.length) ++const batch = await email.sendBatch(messages) ++console.log(batch.sent.length, batch.failed.length) ++for (const { index, error } of batch.failed) retryLater(messages[index], error) ``` -### 2. Replace `createEmailService` with `createEmail` +`batch.results[i]` always corresponds to `messages[i]`. + +`sendBatchStream` became `sendStream`, takes a sync or async iterable, and +accepts `{ chunkSize }`: ```diff -- import { createEmailService } from "unemail" -- import resendProvider from "unemail/providers/resend" -+ import { createEmail } from "unemail" -+ import resend from "unemail/driver/resend" - -- const email = createEmailService({ -- provider: resendProvider({ apiKey: process.env.RESEND_KEY! }), -- }) -+ const email = createEmail({ -+ driver: resend({ apiKey: process.env.RESEND_KEY! }), -+ }) +-for await (const r of email.sendBatchStream(messages)) … ++for await (const r of email.sendStream(messages, { chunkSize: 100 })) … ``` -### 3. Update your Result handling +## Middleware + +Hooks are gone. A middleware wraps the next handler: ```diff -- const result = await email.sendEmail(msg) -- if (result.success) { -- console.log(result.data!.messageId) -- } else { -- console.error(result.error!.message) -- } -+ const { data, error } = await email.send(msg) -+ if (error) { -+ console.error(error.message) // error.code, error.status, error.retryable also typed -+ return -+ } -+ console.log(data.id) // TS narrows — data is non-null here +-email.use({ +- name: "audit", +- async beforeSend(msg, ctx) { await log(msg) }, +- async afterSend(msg, ctx, result) { await log(result) }, +-}) ++email.use(defineMiddleware("audit", (next) => async (msgs, ctx) => { ++ await log(msgs) ++ const results = await next(msgs, ctx) ++ await log(results) ++ return results ++})) ``` -### 4. Rename custom provider implementations +`onError` has no direct equivalent — inspect the results after `next` and +return replacements, which is also how you recover. + +`withRetry`, `withRateLimit` and `withCircuitBreaker` are middleware now, +not driver decorators: ```diff -- import { defineProvider } from "unemail" -+ import { defineDriver } from "unemail" - -- export default defineProvider((options) => ({ -- name: "my-provider", -- async initialize() { ... }, -- async isAvailable() { ... }, -- async sendEmail(msg) { ... }, -- })) -+ export default defineDriver((options) => ({ -+ name: "my-driver", -+ async initialize() { ... }, -+ async isAvailable() { ... }, -+ async send(msg, ctx) { ... }, -+ })) +-const driver = withRetry(resend({ apiKey }), { retries: 3 }) +-const email = createEmail({ driver }) ++const email = createEmail({ driver: resend({ apiKey }), use: [withRetry({ retries: 3 })] }) ``` -`send` now takes a second `ctx` argument with `driver`, `stream`, `attempt`, -`signal`, and `meta` fields (middleware chain context). +To attach one to a single driver — the pattern that made +`fallback([withRetry(a), withRetry(b)])` work — use `wrap`: -### 5. Replace your ad-hoc provider mocks +```diff +-fallback([withRetry(resend({ apiKey })), withRetry(ses({ region }))]) ++fallback([wrap(resend({ apiKey }), withRetry()), wrap(ses({ region }), withRetry())]) +``` + +Idempotency moved out of `createEmail` into middleware: ```diff -- const spy = vi.fn() -- const email = createEmailService({ -- provider: { name: "test", initialize: () => {}, isAvailable: () => true, -- sendEmail: spy, features: {} } as any, -- }) -+ import { createTestEmail } from "unemail/test" -+ const email = createTestEmail() -+ // …run your code… -+ expect(email.inbox).toHaveLength(1) -+ expect(email.last?.subject).toMatch(/welcome/i) +-createEmail({ driver, idempotency: { store, ttlSeconds: 3600 } }) ++createEmail({ driver, use: [withIdempotency({ store, ttlSeconds: 3600 })] }) ``` -### 6. Provider-specific fields removed from the base message +`withRetry`'s `deadLetter` option is gone. Route failures yourself from +`batch.failed`, or put the dead-letter driver behind `fallback`. + +Default backoff changed from `exponential` to `exponential-jitter` — plain +exponential synchronizes every client that failed at the same moment into +the same retry wave. -These fields existed as top-level message options in v0.x: +## Rendering -- `useDkim`, `dsn`, `priority`, `inReplyTo`, `references`, `listUnsubscribe`, - `googleMailHeaders` (all SMTP-only) -- `customParams`, `endpointOverride`, `methodOverride` (HTTP-only) -- `templateId`, `templateData`, `scheduledAt`, `tags` (Resend) -- `configurationSetName`, `messageTags`, `sourceArn` (SES) -- `trackClicks`, `trackOpens`, `clientReference`, `mimeHeaders` (Zeptomail) +```diff +-email.use(withRender(reactRenderer())) ++email.use(withRender(reactRenderer())) // unchanged +``` -In v1 the base message stays narrow. Anything not in the core shape goes -through `msg.headers`, the driver's options (driver-scoped), or `msg.tags`. +But a `Renderer` now claims a content type rather than probing the message, +and returns `{ html, text? }` instead of a bare string: ```diff -- await email.sendEmail({ ..., priority: "high" }) -+ await email.send({ ..., headers: { "X-Priority": "1" } }) + const markdown: Renderer = { + name: "markdown", +- match: (msg) => msg.markdown != null, +- render: (msg) => toHtml(msg.markdown), ++ type: "markdown", ++ render: (content) => ({ html: toHtml(content.source as string) }), + } ``` -### 7. Retries and timeouts moved to middleware +`withRender` no longer writes into your message. If you relied on reading +`msg.html` back after `send()`, read the driver's copy instead. + +## Drivers + +`defineDriver` now makes required options actually required — +`resend()` with no key is a compile error rather than a runtime throw. + +Inside a driver, `msg` arrives normalized: ```diff -- const email = createEmailService({ -- provider: smtp(...), -- retries: 3, -- timeout: 5000, -- }) -+ import { withRetry } from "unemail" -+ const email = createEmail({ driver: smtp({ commandTimeoutMs: 5000 }) }) -+ email.use(withRetry({ retries: 3 })) +-const from = normalizeAddresses(msg.from)[0] +-if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required") +-const to = normalizeAddresses(msg.to).map(formatAddress) ++const from = formatAddress(msg.from) // guaranteed present ++const to = msg.to.map(formatAddress) // never undefined, never empty ``` -### 8. New capabilities you probably want - -- **Idempotency**: `createEmail({ driver, idempotency: true })` plus - `send({ idempotencyKey })` dedupes across retries and crashes. Works - with any driver; Resend/Postmark native headers used where available. -- **Streams**: `email.mount("marketing", ses(...))` then - `send({ stream: "marketing", ... })` — route by purpose without - juggling multiple `Email` instances. -- **Fallback**: `fallback({ drivers: [resend(...), ses(...)] })` tries - each driver in order on retryable failures. -- **Rendering**: `email.use(withRender(reactRender()))` and pass - `react: ` directly to `send()`. - -## Provider migration table - -| v0.x import | v1.0 import | -| ----------------------------- | --------------------------------- | -| `unemail/providers/smtp` | `unemail/driver/smtp` | -| `unemail/providers/resend` | `unemail/driver/resend` | -| `unemail/providers/aws-ses` | `unemail/driver/ses` (now SES v2) | -| `unemail/providers/http` | `unemail/driver/http` | -| `unemail/providers/zeptomail` | `unemail/driver/zeptomail` | -| (MailCrab helper only in v0) | `unemail/driver/mailcrab` | - -New in v1: `postmark`, `sendgrid`, `mailgun`, `brevo`, `mailersend`, -`loops`, `mailchannels`, `cloudflare-email`, plus meta drivers -`mock`, `fallback`, `round-robin`. - -## Feature flag matrix - -Each driver advertises what it supports via `driver.flags`. See -`docs/drivers.md` for the full matrix. +Other renames: `driver.flags` → `driver.features`; `SendStatusState` → +`SendState`; `driver.sendBatch` returns `Result[]` (one per +input, in order) instead of `Result`; `EmailError` lives in +`unemail` rather than in the types module. + +## Tooling + +The repo builds with Bun. There is no `pnpm-lock.yaml` and no +`packageManager` field; `bun install` and `bun run check`. This affects +contributors, not consumers — the published package is unchanged in how it +is installed. diff --git a/README.md b/README.md index 4a5badd..df24001 100644 --- a/README.md +++ b/README.md @@ -1,502 +1,385 @@ +

-
- unemail — One API for every email provider -

- unemail -

- Driver-based, zero-dependency TypeScript email library. -
- Send, batch, schedule, dedupe, render, parse, verify, and sign — one unified API across every runtime. -

- npm version - npm downloads - bundle size - license - sponsors + unemail

+ -## Design goals +# unemail -| Goal | How `unemail` delivers | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **One API, many transports** | `createEmail({ driver })` — 15+ built-in drivers (SMTP, Resend, SES, Postmark, SendGrid, Mailgun, Mailtrap, Brevo, MailerSend, Loops, Zeptomail, MailChannels, Cloudflare Email, …) | -| **Cross-runtime** | Node, Bun, Deno, Cloudflare Workers, browser — core is zero-dep and Web-API only. No `axios`, ever. | -| **Compliance-ready** | RFC 8058 one-click List-Unsubscribe, DKIM + ARC signing, suppression/preference stores, DMARC + TLS-RPT + ARF parsers | -| **Resilient by default** | Idempotency, retry w/ jitter, per-provider rate-limit, circuit breaker, dedupe, dead-letter, provider fallback | -| **Unified observability** | Structured logging, OpenTelemetry, Prometheus metrics, normalized `EmailEvent` stream across send + webhook paths | -| **Modern DX** | `{ data, error }` Result discriminated union, typed `Address` primitive, `react:`/`mjml:`/`handlebars:`/`liquid:` props | -| **Testing-first** | `createTestEmail()` with inbox + `waitFor` + 5 Vitest matchers + snapshot helper | - -## Install - -```bash -pnpm add unemail -``` - -Rendering, queue, and parser entries pull in optional peer deps only -when you import them: - -```bash -pnpm add @react-email/render # unemail/render/react -pnpm add mjml # unemail/render/mjml -pnpm add handlebars # unemail/render/handlebars -pnpm add liquidjs # unemail/render/liquid -pnpm add juice # htmlPipeline(inlineCss()) -pnpm add postal-mime # unemail/parse -pnpm add @opentelemetry/api # withTelemetry -pnpm add unstorage # unstorageQueue / unstorageSuppressionStore -pnpm add bullmq # unemail/queue/bullmq -pnpm add pg-boss # unemail/queue/pg-boss -``` - -## Hello world +A driver-based email library for TypeScript. ESM-only, zero runtime +dependencies, and the same code on Node, Bun, Deno, Cloudflare Workers and +the browser. ```ts import { createEmail } from "unemail" -import resend from "unemail/driver/resend" +import resend from "unemail/drivers/resend" -const email = createEmail({ driver: resend({ apiKey: process.env.RESEND_KEY! }) }) +const email = createEmail({ + driver: resend({ apiKey: process.env.RESEND_API_KEY! }), + defaults: { from: "Acme " }, +}) const { data, error } = await email.send({ - from: "Acme ", - to: "user@example.com", + to: "ada@example.com", subject: "Welcome", - text: "Thanks for signing up.", + html: "

Glad you are here.

", }) -if (error) throw error // error: EmailError — typed { code, status, retryable, ... } -console.log(data.id) // data: EmailResult — TS narrows after the error check +if (error) console.error(error.code, error.message) +else console.log("sent", data.id) ``` -Every driver implements the same contract, so swapping providers is a -one-line change. - -### Mailtrap (Email API + Email Sandbox) - -```ts -import mailtrap from "unemail/driver/mailtrap" - -const email = createEmail({ - driver: mailtrap({ - apiKey: process.env.MAILTRAP_API_KEY!, - inboxId: process.env.MAILTRAP_INBOX_ID, - sandbox: process.env.MAILTRAP_USE_SANDBOX === "true", - }), -}) +## Install -await email.send({ from: "a@b.com", to: "c@d.com", subject: "Test", text: "hi", sandbox: true }) +```sh +bun add unemail +# npm install unemail · pnpm add unemail · yarn add unemail ``` -See [docs/drivers.md](docs/drivers.md) for Email API vs sandbox routing. +```sh +bun add @react-email/render # only for unemail/render/react +``` -## Message streams (Postmark-style) +Requires Node 20.11+ (or any runtime with `fetch` and Web Crypto). There is +no CommonJS build. -```ts -import postmark from "unemail/driver/postmark" -import ses from "unemail/driver/ses" +## The idea -const email = createEmail({ driver: postmark({ token }) }) -email.mount("marketing", ses({ region: "us-east-1" })) +Three pieces, and nothing else: -await email.send({ stream: "transactional", to, subject, text }) -await email.send({ stream: "marketing", to, subject, html }) +``` +your code ──▶ normalize ──▶ middleware ──▶ driver ──▶ provider + (once) (a list) (a transport) ``` -## Deliverability & compliance +**Normalize once.** `email.send()` parses every address, validates the +message, rejects a header containing a line break, and derives +`List-Unsubscribe`. Drivers receive a frozen `NormalizedMessage` — which is +why no driver in this repo parses an address, and why your message object +is never written back into. -**Gmail + Yahoo 2024 bulk-sender compliance is one line:** +**One kind of middleware.** Retry, logging, rate limiting, the circuit +breaker and idempotency are all the same shape: ```ts -await email.send({ - from, - to, - subject, - html, - unsubscribe: { - url: `https://app.com/u?t=${token}`, // RFC 8058 one-click - mailto: "unsubscribe@acme.com", - }, -}) -// → auto-injects List-Unsubscribe + List-Unsubscribe-Post headers. +type SendHandler = (msgs, ctx) => Promise[]> +type Middleware = { name: string; handle: (next: SendHandler) => SendHandler } ``` -**DKIM sign outbound SMTP** (RSA or Ed25519, pure Web-Crypto): +**The unit of work is a list.** `send()` is the one-element case. This is +what lets retry re-send _only_ the messages that failed — even when the +driver reached the provider in a single batched request. A partial batch +failure costs one small retry instead of a full re-send with duplicate +deliveries. -```ts -import smtp from "unemail/driver/smtp" -const driver = smtp({ - host: "smtp.acme.com", - dkim: { selector: "s1", domain: "acme.com", privateKey: pem }, -}) -``` +## Sending -**Suppression + preferences** stop sends before they hit the provider: +### One message ```ts -import { withSuppression } from "unemail/middleware" -import { memorySuppressionStore } from "unemail/suppression" - -const store = memorySuppressionStore() -// webhook handler → store.add(recipient, "bounce") -const email = createEmail({ driver: withSuppression(resend({ apiKey }), { store }) }) +const { data, error } = await email.send({ + to: ["ada@example.com", { email: "bob@example.com", name: "Bob" }], + cc: "Cee ", + subject: "Your invoice", + text: "Attached.", + html: "

Attached.

", + preheader: "Invoice #1042 · due in 14 days", + attachments: [{ filename: "invoice.pdf", content: bytes, contentType: "application/pdf" }], + tags: [{ name: "campaign", value: "billing" }], +}) ``` -**Other deliverability utilities:** - -- `unemail/verify/arc` — ARC-Set signer (RFC 8617) for forwarders -- `unemail/dmarc` — aggregate (RUA) XML + gzip parser -- `unemail/mta-sts` — policy file generator + TLS-RPT JSON parser -- `unemail/parse/arf` — RFC 5965 feedback-loop (FBL) reports +`error` is an `EmailError` with a stable `code` (`AUTH`, `RATE_LIMIT`, +`NETWORK`, `TIMEOUT`, `PROVIDER`, `INVALID_OPTIONS`, `UNSUPPORTED`, +`CANCELLED`) and a `retryable` flag that means the same thing whichever +provider produced it. -## Provider-side templates +### Many messages -Eight drivers map `msg.template` into native template APIs: +`sendBatch` never short-circuits. `results[i]` always corresponds to +`messages[i]`: ```ts -await email.send({ - from, - to, - subject, - template: { id: "tpl_welcome", variables: { name: "Ada" } }, -}) -// → SendGrid dynamic_template_data, Postmark TemplateModel, -// Mailgun h:X-Mailgun-Variables, Brevo params, MailerSend -// personalization.data, Loops dataVariables, Zeptomail merge_info. +const batch = await email.sendBatch(users.map((u) => ({ to: u.email, subject, html }))) + +batch.ok // false if any failed +batch.sent // the EmailResults that got through +batch.failed // [{ index, error }] for the rest ``` -## Personalizations & batch +One invalid address in a batch of a thousand fails its own slot and nothing +else. -SendGrid-style per-recipient fan-out — one batched API call when the -driver supports it, or an automatic loop when it doesn't: +### Very many messages ```ts -await email.send({ - from, - subject: "Welcome", - personalizations: [ - { to: "ada@x.com", variables: { name: "Ada" } }, - { to: "bob@x.com", variables: { name: "Bob" }, subject: "Just for Bob" }, - ], - template: { id: "tpl_welcome" }, -}) - -// Or stream results for huge fan-outs: -for await (const result of email.sendBatchStream(messages)) { - if (result.error) report(result.error) +for await (const result of email.sendStream(rowsFromDatabase(), { chunkSize: 100 })) { + if (result.error) await recordFailure(result.error) } ``` -## Rendering +Accepts a sync or async iterable, so the source can be a cursor. Nothing +larger than a chunk is held in memory. + +## Middleware -**React Email / jsx-email / MJML / Handlebars / Liquid** all plug in -as renderers: +Registered outermost-first: ```ts -import { createEmail, withRender } from "unemail" -import reactRender from "unemail/render/react" -import { handlebarsRenderer } from "unemail/render/handlebars" +import { + withCircuitBreaker, + withLogger, + withRateLimit, + withRetry, + rateLimitPresets, +} from "unemail/middleware" -const email = createEmail({ driver }).use(withRender(reactRender(), handlebarsRenderer())) +const email = createEmail({ + driver: resend({ apiKey }), + defaults: { from }, + use: [ + withLogger(), // measures everything below, retries included + withCircuitBreaker({ threshold: 5 }), // stops calling a provider that is down + withRetry({ retries: 3 }), // re-sends only the failures + withRateLimit(rateLimitPresets.resend), // one token per message, batches included + ], +}) ``` -**HTML post-processing pipeline** — preheader, dark-mode, CID -auto-rewrite, juice inlining: +| Middleware | What it does | +| -------------------- | -------------------------------------------------------------------------------------- | +| `withRetry` | Retries the failed indices with backoff. Honors the provider's `Retry-After`. | +| `withRateLimit` | Token bucket. A 500-message batch takes 500 tokens, not one. | +| `withCircuitBreaker` | Opens after N failures, probes once after the reset window. Ignores caller errors. | +| `withLogger` | One structured entry per pipeline trip. Redacts recipients by default. | +| `withIdempotency` | Returns the previous result for a repeated `idempotencyKey`. Only remembers successes. | + +### Writing one ```ts -import { - htmlPipeline, - withPreheader, - cidRewrite, - darkModeHook, - inlineCss, -} from "unemail/render/pipeline" - -email.use( - htmlPipeline( - withPreheader(), // reads msg.preheader - cidRewrite(), // → cid:logo - darkModeHook({ darkCss: "body{background:#000}" }), - inlineCss(), // peer: juice - ), +import { defineMiddleware } from "unemail" + +const stamp = defineMiddleware( + "stamp", + (next) => (msgs, ctx) => + next( + msgs.map((m) => ({ ...m, headers: { ...m.headers, "X-Sent-By": "acme" } })), + ctx, + ), ) + +email.use(stamp) ``` -**i18n** dispatches per-locale renderers: +Return one result per message. If yours throws, or returns the wrong +number, the core reports it per message instead of losing the batch. -```ts -import { i18nRenderer } from "unemail/render/i18n" +## Drivers -email.use( - withRender( - i18nRenderer({ - fallback: handlebarsRenderer({ - /* defaults */ - }), - byLocale: { - tr: handlebarsRenderer({ - /* tr */ - }), - en: handlebarsRenderer({ - /* en */ - }), - }, - }), - ), -) -``` +| Driver | Import | Notes | +| ----------- | ----------------------------- | --------------------------------------------------------- | +| Resend | `unemail/drivers/resend` | Native batch, scheduling, cancel, retrieve | +| Postmark | `unemail/drivers/postmark` | Message streams, templates, per-message batch errors | +| Amazon SES | `unemail/drivers/ses` | SigV4 over Web Crypto — no `@aws-sdk/*` | +| SMTP | `unemail/drivers/smtp` | Own protocol implementation, pooling, DKIM. Node/Bun only | +| Mock | `unemail/drivers/mock` | Records instead of sending | +| Fallback | `unemail/drivers/fallback` | Composite: try each provider in turn | +| Round-robin | `unemail/drivers/round-robin` | Composite: spread across providers | -**Calendar invites** (ICS) attach to any message: +A driver advertises what it can do, so you gate on capability rather than +on a name: ```ts -import { icalEvent } from "unemail/ics" - -await email.send({ - from, - to, - subject: "Design sync", - text: "...", - attachments: [ - icalEvent({ - uid: "evt-1@acme.com", - start: new Date("2026-05-01T10:00:00Z"), - end: new Date("2026-05-01T11:00:00Z"), - summary: "Design sync", - organizer: { email: "host@acme.com" }, - attendees: [{ email: "ada@acme.com", rsvp: true }], - }), - ], -}) +if (email.driver.features?.scheduling) await email.send({ ...msg, scheduledAt }) ``` -## Resilience middleware +### Failover ```ts -import { - withRetry, - withCircuitBreaker, - withRateLimit, - rateLimitPresets, - withDedupe, - withLogger, - withTelemetry, - withMetrics, - createMetricsRegistry, -} from "unemail/middleware" -import { trace } from "@opentelemetry/api" +import { wrap } from "unemail" +import { fallback } from "unemail/drivers/fallback" -const metrics = createMetricsRegistry() - -email - .use(withDedupe({ strategy: "contentHash", ttlSeconds: 60 })) - .use(withRetry({ retries: 3, backoff: "full-jitter", deadLetter: dlqDriver })) - .use(withRateLimit(rateLimitPresets.sendgrid())) - .use(withCircuitBreaker({ threshold: 5, cooldownMs: 30_000 })) - .use(withLogger({ redactLocalPart: true })) - .use(withTelemetry({ tracer: trace.getTracer("unemail") })) - .use(withMetrics({ registry: metrics })) - -// Prometheus exposition: -app.get("/metrics", () => new Response(metrics.expose())) +const driver = fallback([ + wrap(resend({ apiKey }), withRetry()), // retries within the leg + ses({ region: "eu-central-1" }), // only sees what Resend could not send +]) ``` -### OAuth2 (Gmail / Microsoft 365) - -```ts -import { oauth2Gmail } from "unemail/middleware" - -email.use( - oauth2Gmail({ - clientId: process.env.GOOGLE_CLIENT_ID!, - clientSecret: process.env.GOOGLE_CLIENT_SECRET!, - refreshToken: process.env.GOOGLE_REFRESH_TOKEN!, - }), -) -``` +Failover is per message. If 3 of 500 fail at the primary, only those 3 go +to the secondary — nobody receives the same mail twice. -## Provider fallback + composition +### Streams ```ts -import fallback from "unemail/driver/fallback" -import roundRobin from "unemail/driver/round-robin" -import resend from "unemail/driver/resend" -import ses from "unemail/driver/ses" - const email = createEmail({ - driver: fallback({ - drivers: [resend({ apiKey: process.env.RESEND_KEY! }), ses({ region: "us-east-1" })], - }), + driver: postmark({ token, messageStream: "outbound" }), + mounts: { broadcast: postmark({ token: broadcastToken, messageStream: "broadcast" }) }, + defaults: { from }, }) + +await email.send({ ...msg, stream: "broadcast" }) ``` -## Queues +A batch spanning several streams is split across their drivers and +reassembled in order. -In-memory / unstorage / BullMQ / pg-boss / AWS SQS all implement the -same `EmailQueue` contract. `msg.scheduledAt` defers the send through -every backend: +### Writing one ```ts -import memoryQueue from "unemail/queue/memory" -import { startWorker } from "unemail/queue/worker" +import { defineDriver, ok, err, createError } from "unemail" -const queue = memoryQueue() -startWorker(email, queue, { concurrency: 5, maxAttempts: 5 }).start() - -await queue.enqueue({ - from, - to, - subject, - scheduledAt: new Date(Date.now() + 60 * 60 * 1000), // send in 1h -}) +export default defineDriver<{ apiKey: string }>((options) => ({ + name: "acme", + features: { html: true, text: true }, + async send(msg) { + const response = await fetch("https://api.acme.com/send", { + method: "POST", + headers: { authorization: `Bearer ${options.apiKey}` }, + body: JSON.stringify({ + to: msg.to.map((a) => a.email), + subject: msg.subject, + html: msg.html, + }), + }) + if (!response.ok) return err(createError("acme", "PROVIDER", `HTTP ${response.status}`)) + const body = await response.json() + return ok({ id: body.id, driver: "acme", at: new Date() }) + }, +})) ``` -Swap for `bullmqQueue({ bull })`, `pgBossQueue({ boss })`, or -`sqsQueue({ sqs, queueUrl })` for durable multi-process sending. +`msg` arrives normalized. Add `sendBatch` only if the provider has a real +batch endpoint — it must return one result per input, in order. -## Inbound + webhooks +## Rendering -Pre-normalized handlers for Cloudflare Email, Postmark, SendGrid, -Mailgun, and SES (via SNS): +`message.content` is opaque to the core; a renderer claims it by `type`. +Adding a template language is a package, not a core change. ```ts -import { defineInboundHandler } from "unemail/inbound" -import sendgridInbound from "unemail/inbound/sendgrid" -import { defineSesInboundHandler } from "unemail/inbound/ses" - -export default defineInboundHandler({ - providers: [sendgridInbound()], - onEmail(mail) { - /* ParsedEmail */ - }, +import { withRender } from "unemail/render" +import reactRenderer from "unemail/render/react" + +email.use(withRender(reactRenderer())) + +await email.send({ + to, + subject: "Welcome", + content: { type: "react", element: }, }) ``` -**Reply-only text extraction** (EN/TR/DE/FR/ES): +The plain-text alternative is derived from the HTML unless you set `text` +or the renderer produces one. Your message object is never mutated. ```ts -import { stripReply } from "unemail/inbound/reply" -import { threadKey } from "unemail/inbound/thread" +import { defineTemplate } from "unemail/render" + +const welcome = defineTemplate<{ name: string }>(({ name }) => ({ + subject: `Welcome, ${name}`, + content: { type: "react", element: }, +})) -const { text, quoted } = stripReply(parsed.text ?? "") -const thread = threadKey(parsed) // canonical root Message-ID +await email.send({ to, ...welcome({ name: "Ada" }) }) ``` -**Webhook signature verification** — Resend, Postmark, Mailgun, -SendGrid, SES, plus a zero-dep **Standard Webhooks** -(`standardwebhooks.com`) verifier that's <5 kB (vs Svix's ~1 MB): +Your own renderer: ```ts -import { verifyStandardWebhook } from "unemail/webhook/standard" +import type { Renderer } from "unemail/render" -const body = await verifyStandardWebhook(request, { - secret: process.env.WHSEC!, -}) +const markdown: Renderer = { + name: "markdown", + type: "markdown", + render: (content) => ({ html: toHtml(content.source as string) }), +} ``` -## Unified event stream - -Send events + webhook events converge on one `EmailEvent` shape: +## Testing ```ts -import { EventBus, withEvents, memoryEventStore } from "unemail/events" +import { createEmail } from "unemail" +import mock from "unemail/drivers/mock" -const bus = new EventBus() -const store = memoryEventStore() -bus.on((e) => store.append(e)) +const driver = mock() +const email = createEmail({ driver, defaults: { from: "hi@acme.com" } }) -const email = createEmail({ driver: withEvents(resend({ apiKey }), bus) }) +await email.send({ to: "ada@example.com", subject: "hi", text: "hello" }) -// later: -const timeline = await store.list!(messageId) -// [send.queued, send.attempt, send.success, delivered, opened, ...] +const inbox = driver.getInstance() +inbox.last()?.subject // "hi" +inbox.find("ada@example.com") // every message addressed to Ada ``` -## Typed addresses - -Validate at system boundaries — rejects malformed input before it -reaches a driver: +Simulate failures without a network: ```ts -import { parseAddress } from "unemail/address" - -const { data, error } = parseAddress("Ada ") -if (error) throw error -data.local // "ada" -data.domain // "acme.com" +mock({ fail: { code: "RATE_LIMIT" } }) // everything fails +mock({ failWhen: (msg, i) => i === 1 }) // partial batch failure +mock({ latencyMs: 50 }) // slow provider ``` -## Testing +## API -```ts -import { createTestEmail, emailMatchers, toEmailSnapshot } from "unemail/test" -import { expect } from "vitest" +### `createEmail(options)` -expect.extend(emailMatchers) +| Option | Type | | +| ---------- | ---------------------------------------------------- | ---------------------------------- | +| `driver` | `EmailDriver` | Required | +| `mounts` | `Record` | Routed by `message.stream` | +| `use` | `Middleware[]` | Outermost first | +| `defaults` | `{ from, replyTo, headers, tags, metadata, stream }` | Applied to messages that omit them | +| `signal` | `AbortSignal` | Cancels in-flight sends | -const email = createTestEmail() -await onboardingFlow(email, user) +Returns an `Email` with `send`, `sendBatch`, `sendStream`, `cancel`, +`retrieve`, `use`, `mount`, `unmount`, `getMount`, `getMounts`, +`isAvailable` and `dispose`. -expect(email).toHaveSentTo("ada@acme.com") -expect(email).toHaveSentWithSubject(/welcome/i) -expect(email).toHaveSentWithAttachment("invite.ics") -expect(email).toHaveSentMatching((m) => m.metadata?.userId === user.id) -expect(toEmailSnapshot(email.last!)).toMatchSnapshot() -``` +`cancel` and `retrieve` return `UNSUPPORTED` on a driver that lacks them, +rather than throwing. -## Authoring a driver +### Message -```ts -import { defineDriver } from "unemail" +`from` `to` `cc` `bcc` `replyTo` `subject` `preheader` `text` `html` +`content` `headers` `attachments` `tags` `metadata` `idempotencyKey` +`scheduledAt` `unsubscribe` `template` `tracking` `sandbox` `raw` `stream` -export default defineDriver<{ apiKey: string }>((opts) => ({ - name: "my-driver", - options: opts, - flags: { html: true, attachments: true, batch: true, cancelable: true }, - async send(msg) { - const res = await fetch("https://api.example.com/send", { - method: "POST", - headers: { authorization: `Bearer ${opts!.apiKey}` }, - body: JSON.stringify(msg), - }) - if (!res.ok) return { data: null, error: new Error("send failed") as never } - const body = (await res.json()) as { id: string } - return { data: { id: body.id, driver: "my-driver", at: new Date() }, error: null } - }, - async cancel(id) { - /* optional */ - }, - async retrieve(id) { - /* optional */ - }, -})) -``` +Addresses accept `"a@b.com"`, `"Ada "`, `{ email, name }`, or a +list of any of those. -## Result helpers +## Compatibility -```ts -import { isOk, isErr, unwrap, unwrapOr, mapOk, tryAsync } from "unemail/result" +| Runtime | Core | HTTP drivers | SMTP | +| ------------------ | ---- | ------------ | ---- | +| Node 20.11+ | ✅ | ✅ | ✅ | +| Bun | ✅ | ✅ | ✅ | +| Deno | ✅ | ✅ | — | +| Cloudflare Workers | ✅ | ✅ | — | +| Browser | ✅ | ✅ | — | -const res = await email.send({ ... }) -if (isOk(res)) console.log(res.data.id) -const id = unwrapOr(res, { id: "offline", driver: "mock", at: new Date() }).id -``` +SMTP needs `node:net` and `node:tls`. Everything else is `fetch` and Web +Crypto. ## Docs -- [docs/drivers.md](./docs/drivers.md) — driver matrix + authoring guide + error taxonomy -- [docs/rendering.md](./docs/rendering.md) — React Email / jsx-email / MJML / Handlebars / Liquid / i18n / HTML pipeline -- [docs/inbound.md](./docs/inbound.md) — `unemail/parse` + unified inbound handler + reply stripper + thread stitcher -- [docs/webhooks.md](./docs/webhooks.md) — signature verification for 6 providers + Standard Webhooks -- [docs/deliverability.md](./docs/deliverability.md) — DKIM + ARC + List-Unsubscribe + DMARC + MTA-STS + suppression + preferences -- [docs/testing.md](./docs/testing.md) — `createTestEmail`, `waitFor`, 5 Vitest matchers + snapshots -- [docs/observability.md](./docs/observability.md) — logging + OTel + Prometheus metrics + unified event stream -- [docs/queue.md](./docs/queue.md) — memory / unstorage / BullMQ / pg-boss / SQS -- [docs/rfcs/001-packaging.md](./docs/rfcs/001-packaging.md) — why single package with sub-paths -- [MIGRATION.md](./MIGRATION.md) — upgrading from v0.x +- [Architecture](./docs/architecture.md) — the pipeline, and why the unit of + work is a list +- [Drivers](./docs/drivers.md) — every option, provider quirks, capability + matrix +- [Migration](./MIGRATION.md) — upgrading from 0.x -## License +## Upgrading from 0.x + +v1 is a rewrite. See [MIGRATION.md](./MIGRATION.md). + +## Contributing -Published under the [MIT](./LICENSE) license. Made by -[@productdevbook](https://github.com/productdevbook) and -[community](https://github.com/productdevbook/unemail/graphs/contributors). +```sh +bun install +bun run check # lint, typecheck, tests, version consistency +bun run build +``` + +## License -Architecture inspired by [`unjs/unstorage`](https://github.com/unjs/unstorage). +[MIT](./LICENSE) © [productdevbook](https://github.com/productdevbook) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..75e9870 --- /dev/null +++ b/bun.lock @@ -0,0 +1,453 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "unemail", + "devDependencies": { + "@types/node": "^26.0.0", + "@typescript/native-preview": "7.0.0-dev.20260707.2", + "@vitest/coverage-v8": "^4.1.9", + "bumpp": "^12.2.2", + "obuild": "^0.4.36", + "oxfmt": "^0.66.0", + "oxlint": "^1.70.0", + "typescript": "^7.0.2", + "vitest": "^4.1.9", + }, + "peerDependencies": { + "@react-email/render": "*", + }, + "optionalPeers": [ + "@react-email/render", + ], + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], + + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.66.0", "", { "os": "android", "cpu": "arm" }, "sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.66.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.66.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.66.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.66.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.66.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.66.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.66.0", "", { "os": "none", "cpu": "arm64" }, "sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.66.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.66.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.66.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.81.0", "", { "os": "android", "cpu": "arm" }, "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.81.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.81.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.81.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.81.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.81.0", "", { "os": "linux", "cpu": "arm" }, "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.81.0", "", { "os": "linux", "cpu": "arm" }, "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.81.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.81.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.81.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.81.0", "", { "os": "linux", "cpu": "none" }, "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.81.0", "", { "os": "linux", "cpu": "none" }, "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.81.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.81.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.81.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.81.0", "", { "os": "none", "cpu": "arm64" }, "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.81.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.81.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.81.0", "", { "os": "win32", "cpu": "x64" }, "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA=="], + + "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], + + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260707.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260707.2" }, "bin": { "tsgo": "bin/tsgo" } }, "sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg=="], + + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg=="], + + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw=="], + + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2", "", { "os": "linux", "cpu": "arm" }, "sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ=="], + + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg=="], + + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2", "", { "os": "linux", "cpu": "x64" }, "sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA=="], + + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg=="], + + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.11", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.11", "vitest": "4.1.11" }, "optionalPeers": ["@vitest/browser"] }, "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw=="], + + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + + "@yuku-codegen/binding-android-arm64": ["@yuku-codegen/binding-android-arm64@0.8.7", "", { "os": "android", "cpu": "arm64" }, "sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw=="], + + "@yuku-codegen/binding-darwin-arm64": ["@yuku-codegen/binding-darwin-arm64@0.8.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/u+REDMI4a0/lsJXTM4c53/w31OGZLOReZIyg62uhgLs0kc8NHsj/nOcxTdlQjq5gi0zhdkccD9LaLTXcdzPvw=="], + + "@yuku-codegen/binding-darwin-x64": ["@yuku-codegen/binding-darwin-x64@0.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-sMMzFOwCo4WXR+/6zIBThOocSC50iIIZZdfiIDbaLvj0Ax/rWt/iavyfEAqajyvzydLyCqR/ZItdLWSRlu1umw=="], + + "@yuku-codegen/binding-freebsd-x64": ["@yuku-codegen/binding-freebsd-x64@0.8.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MpdpKXix9P+Y1rKgjvcNeNtGjXeL1CmttNhYINrWls8kRpm4xM/oBGTmn6w7to8lAlwj5jm8q03dQPl5mRv4Qw=="], + + "@yuku-codegen/binding-linux-arm-gnu": ["@yuku-codegen/binding-linux-arm-gnu@0.8.7", "", { "os": "linux", "cpu": "arm" }, "sha512-rr1srFLlPAmC1vtxfc9C1YLDe3iH09YjfSeeIidBqKhzx1MATjOAq4mjlRUOnhr/L27MotWIYOFKwVsd5JZFOg=="], + + "@yuku-codegen/binding-linux-arm-musl": ["@yuku-codegen/binding-linux-arm-musl@0.8.7", "", { "os": "linux", "cpu": "arm" }, "sha512-eAufXh8qBRpiSO6ueaMDL+yyoXIGLhpUce72YbcACtZU2qhExwBIJyEtQf5kH2Ki0X2aqkSjSfog4OPtKXan3Q=="], + + "@yuku-codegen/binding-linux-arm64-gnu": ["@yuku-codegen/binding-linux-arm64-gnu@0.8.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-gw4w6wPoHObBrdIC4duVWLmJOvpdE25j5D7yrM5mACNlK4klRz/lv8hK+ssQk9EJHBgZjSaqZIJVFgqNYbfv7A=="], + + "@yuku-codegen/binding-linux-arm64-musl": ["@yuku-codegen/binding-linux-arm64-musl@0.8.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-L68N6Y4XkqcIaKo3Ra88JEvBEH4AHff44A4INcrxeVWZ8CZtu2tCpfVxe3hR8qQQMcvBSQlDn24KpkCKEhVvfA=="], + + "@yuku-codegen/binding-linux-x64-gnu": ["@yuku-codegen/binding-linux-x64-gnu@0.8.7", "", { "os": "linux", "cpu": "x64" }, "sha512-dzyAbltJmf3Cqlb8HcFuYIf5Yn0fl1vTr3XJ9HiVNNvOlhqPSArOqtw9vI1p6/VTXTjrMLXlU+s+/kNHiIy/Cw=="], + + "@yuku-codegen/binding-linux-x64-musl": ["@yuku-codegen/binding-linux-x64-musl@0.8.7", "", { "os": "linux", "cpu": "x64" }, "sha512-Otw4MH3404q0Bbvl+YTdW9aoUV5vXmUw8260bWvt1XlaoIX/ceSgI4ygheRDMfBPoHt6FDayQaO9OLVUZAgkFA=="], + + "@yuku-codegen/binding-win32-arm64": ["@yuku-codegen/binding-win32-arm64@0.8.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-qo/jyrzryiBuKEsFiuWaBCBe3tRMynQ0qFWFgOEjcCMQeZfBm+wKiVEUEFXXLc7bh8YezguAWp0Mtnhq4ARNyA=="], + + "@yuku-codegen/binding-win32-x64": ["@yuku-codegen/binding-win32-x64@0.8.7", "", { "os": "win32", "cpu": "x64" }, "sha512-D5lDsVDx6m00E6bWySlWdH72Ca4TPSaphDqB6QjU6MpuNLIJqoGoatYyq2rOmBE8Zv/kunot/o58KGL03P3eiA=="], + + "@yuku-parser/binding-android-arm64": ["@yuku-parser/binding-android-arm64@0.8.7", "", { "os": "android", "cpu": "arm64" }, "sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ=="], + + "@yuku-parser/binding-darwin-arm64": ["@yuku-parser/binding-darwin-arm64@0.8.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ=="], + + "@yuku-parser/binding-darwin-x64": ["@yuku-parser/binding-darwin-x64@0.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA=="], + + "@yuku-parser/binding-freebsd-x64": ["@yuku-parser/binding-freebsd-x64@0.8.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA=="], + + "@yuku-parser/binding-linux-arm-gnu": ["@yuku-parser/binding-linux-arm-gnu@0.8.7", "", { "os": "linux", "cpu": "arm" }, "sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg=="], + + "@yuku-parser/binding-linux-arm-musl": ["@yuku-parser/binding-linux-arm-musl@0.8.7", "", { "os": "linux", "cpu": "arm" }, "sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ=="], + + "@yuku-parser/binding-linux-arm64-gnu": ["@yuku-parser/binding-linux-arm64-gnu@0.8.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA=="], + + "@yuku-parser/binding-linux-arm64-musl": ["@yuku-parser/binding-linux-arm64-musl@0.8.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg=="], + + "@yuku-parser/binding-linux-x64-gnu": ["@yuku-parser/binding-linux-x64-gnu@0.8.7", "", { "os": "linux", "cpu": "x64" }, "sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg=="], + + "@yuku-parser/binding-linux-x64-musl": ["@yuku-parser/binding-linux-x64-musl@0.8.7", "", { "os": "linux", "cpu": "x64" }, "sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ=="], + + "@yuku-parser/binding-win32-arm64": ["@yuku-parser/binding-win32-arm64@0.8.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw=="], + + "@yuku-parser/binding-win32-x64": ["@yuku-parser/binding-win32-x64@0.8.7", "", { "os": "win32", "cpu": "x64" }, "sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w=="], + + "@yuku-toolchain/types": ["@yuku-toolchain/types@0.8.7", "", {}, "sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA=="], + + "args-tokenizer": ["args-tokenizer@0.3.0", "", {}, "sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="], + + "bumpp": ["bumpp@12.2.2", "", { "dependencies": { "args-tokenizer": "^0.3.0", "cac": "^7.0.0", "jsonc-parser": "^3.3.1", "package-manager-detector": "^1.8.0", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "unconfig": "^7.5.0", "verkit": "^0.3.2", "yaml": "^2.9.0" }, "bin": { "bumpp": "bin/bumpp.mjs" } }, "sha512-tS7KZs+e1mRpN+N9mQU2greqHbqMLZrjBA0iQ4qVS/UB8yFPUgcvQHps4dVvRe37AEMNSOyNlnGuhnoklKlacg=="], + + "c12": ["c12@4.0.0-beta.5", "", { "dependencies": { "confbox": "^0.2.4", "defu": "^6.1.7", "exsolve": "^1.0.8", "pathe": "^2.0.3", "pkg-types": "^2.3.1", "rc9": "^3.0.1" }, "peerDependencies": { "chokidar": "^5", "dotenv": "*", "giget": "*", "jiti": "*", "magicast": "*" }, "optionalPeers": ["chokidar", "dotenv", "giget", "jiti", "magicast"] }, "sha512-yWGCPCQGJeFq4R0mFg5HOhC3Rg+B0PCdM+ldXWUhughoGgeeq8/tjRmXh4/lmhKWyhf+KOFxB/JMXf0Yv1Fd5A=="], + + "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + + "obuild": ["obuild@0.4.38", "", { "dependencies": { "c12": "4.0.0-beta.5", "consola": "^3.4.2", "defu": "^6.1.7", "exsolve": "^1.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3", "rolldown": "^1.1.5", "rolldown-plugin-dts": "^0.27.4", "tinyglobby": "^0.2.17" }, "bin": { "obuild": "dist/cli.mjs" } }, "sha512-82uy8eU+qmdtWkKlJb/jE47caBGMAGO3CrZVwGmc0Mn63hwc5Z4Fb82nBY7lwPzc44Dmv2eMBVuvnESdqHFT6Q=="], + + "oxfmt": ["oxfmt@0.66.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.66.0", "@oxfmt/binding-android-arm64": "0.66.0", "@oxfmt/binding-darwin-arm64": "0.66.0", "@oxfmt/binding-darwin-x64": "0.66.0", "@oxfmt/binding-freebsd-x64": "0.66.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.66.0", "@oxfmt/binding-linux-arm-musleabihf": "0.66.0", "@oxfmt/binding-linux-arm64-gnu": "0.66.0", "@oxfmt/binding-linux-arm64-musl": "0.66.0", "@oxfmt/binding-linux-ppc64-gnu": "0.66.0", "@oxfmt/binding-linux-riscv64-gnu": "0.66.0", "@oxfmt/binding-linux-riscv64-musl": "0.66.0", "@oxfmt/binding-linux-s390x-gnu": "0.66.0", "@oxfmt/binding-linux-x64-gnu": "0.66.0", "@oxfmt/binding-linux-x64-musl": "0.66.0", "@oxfmt/binding-openharmony-arm64": "0.66.0", "@oxfmt/binding-win32-arm64-msvc": "0.66.0", "@oxfmt/binding-win32-ia32-msvc": "0.66.0", "@oxfmt/binding-win32-x64-msvc": "0.66.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w=="], + + "oxlint": ["oxlint@1.81.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.81.0", "@oxlint/binding-android-arm64": "1.81.0", "@oxlint/binding-darwin-arm64": "1.81.0", "@oxlint/binding-darwin-x64": "1.81.0", "@oxlint/binding-freebsd-x64": "1.81.0", "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", "@oxlint/binding-linux-arm-musleabihf": "1.81.0", "@oxlint/binding-linux-arm64-gnu": "1.81.0", "@oxlint/binding-linux-arm64-musl": "1.81.0", "@oxlint/binding-linux-ppc64-gnu": "1.81.0", "@oxlint/binding-linux-riscv64-gnu": "1.81.0", "@oxlint/binding-linux-riscv64-musl": "1.81.0", "@oxlint/binding-linux-s390x-gnu": "1.81.0", "@oxlint/binding-linux-x64-gnu": "1.81.0", "@oxlint/binding-linux-x64-musl": "1.81.0", "@oxlint/binding-openharmony-arm64": "1.81.0", "@oxlint/binding-win32-arm64-msvc": "1.81.0", "@oxlint/binding-win32-ia32-msvc": "1.81.0", "@oxlint/binding-win32-x64-msvc": "1.81.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg=="], + + "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + + "rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="], + + "rolldown-plugin-dts": ["rolldown-plugin-dts@0.27.14", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.4", "yuku-ast": "^0.8.0", "yuku-codegen": "^0.8.0", "yuku-parser": "^0.8.0" }, "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@typescript/native-preview", "@volar/typescript", "typescript", "vue-tsc"] }, "sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "unconfig": ["unconfig@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "defu": "^6.1.4", "jiti": "^2.6.1", "quansync": "^1.0.0", "unconfig-core": "7.5.0" } }, "sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA=="], + + "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "verkit": ["verkit@0.3.2", "", {}, "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg=="], + + "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], + + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yuku-ast": ["yuku-ast@0.8.7", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.7" } }, "sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ=="], + + "yuku-codegen": ["yuku-codegen@0.8.7", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.7" }, "optionalDependencies": { "@yuku-codegen/binding-android-arm64": "0.8.7", "@yuku-codegen/binding-darwin-arm64": "0.8.7", "@yuku-codegen/binding-darwin-x64": "0.8.7", "@yuku-codegen/binding-freebsd-x64": "0.8.7", "@yuku-codegen/binding-linux-arm-gnu": "0.8.7", "@yuku-codegen/binding-linux-arm-musl": "0.8.7", "@yuku-codegen/binding-linux-arm64-gnu": "0.8.7", "@yuku-codegen/binding-linux-arm64-musl": "0.8.7", "@yuku-codegen/binding-linux-x64-gnu": "0.8.7", "@yuku-codegen/binding-linux-x64-musl": "0.8.7", "@yuku-codegen/binding-win32-arm64": "0.8.7", "@yuku-codegen/binding-win32-x64": "0.8.7" } }, "sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw=="], + + "yuku-parser": ["yuku-parser@0.8.7", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.7", "yuku-ast": "^0.8.7" }, "optionalDependencies": { "@yuku-parser/binding-android-arm64": "0.8.7", "@yuku-parser/binding-darwin-arm64": "0.8.7", "@yuku-parser/binding-darwin-x64": "0.8.7", "@yuku-parser/binding-freebsd-x64": "0.8.7", "@yuku-parser/binding-linux-arm-gnu": "0.8.7", "@yuku-parser/binding-linux-arm-musl": "0.8.7", "@yuku-parser/binding-linux-arm64-gnu": "0.8.7", "@yuku-parser/binding-linux-arm64-musl": "0.8.7", "@yuku-parser/binding-linux-x64-gnu": "0.8.7", "@yuku-parser/binding-linux-x64-musl": "0.8.7", "@yuku-parser/binding-win32-arm64": "0.8.7", "@yuku-parser/binding-win32-x64": "0.8.7" } }, "sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ=="], + } +} diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 79dc092..0000000 --- a/docs/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Documentation - -- [drivers.md](./drivers.md) — built-in drivers + authoring guide + error taxonomy -- [rendering.md](./rendering.md) — React Email, jsx-email, MJML, `defineTemplate` -- [inbound.md](./inbound.md) — `unemail/parse` + `unemail/inbound` route handlers -- [webhooks.md](./webhooks.md) — unified webhook schema + signature verification -- [testing.md](./testing.md) — `createTestEmail`, `waitFor`, Vitest matchers -- [observability.md](./observability.md) — logging + OpenTelemetry -- [queue.md](./queue.md) — background sending + retries + durability - -See also: - -- [README](../README.md) — hello world + design goals -- [MIGRATION.md](../MIGRATION.md) — migrating from v0.x diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9430566 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,123 @@ +# Architecture + +Three moving parts. Everything else in the repo is one of them. + +``` +send(msg) ─┐ + ├─▶ normalizeMessage ─▶ compose(middleware) ─▶ driverHandler ─▶ provider +sendBatch ─┘ (once) (a list) (a transport) +``` + +## The unit of work is a list + +```ts +type SendHandler = ( + msgs: readonly NormalizedMessage[], + ctx: SendContext, +) => Promise[]> + +interface Middleware { + name: string + handle: (next: SendHandler) => SendHandler +} +``` + +`send()` is `sendBatch()` with one element. Making the list the primitive +rather than a special case is what buys the property that matters: retry +re-sends only the failed indices, even when the driver reached the provider +in a single request. + +```ts +defineMiddleware("retry", (next) => async (msgs, ctx) => { + const results = [...(await next(msgs, ctx))] + const pending = results.flatMap((r, i) => (r.error?.retryable ? [i] : [])) + const redo = await next( + pending.map((i) => msgs[i]!), + { ...ctx, attempt: 2 }, + ) + for (const [slot, i] of pending.entries()) results[i] = redo[slot]! + return results +}) +``` + +With a single-message handler this is not expressible: once the driver has +batched, there is no way to reach back and re-send three of five. + +The cost is that a middleware that does not care about the batch still has +to map over it. `perMessage()` lifts a per-message function for that case. + +## Normalize once, at the edge + +`normalizeMessage()` runs exactly once per message, in `createEmail`. It +parses addresses, validates them, guarantees the list fields are present, +rejects a header value containing a line break, derives `List-Unsubscribe`, +injects the preheader, and freezes the result. + +That is why no driver in this repo calls an address parser, and why a +message object you pass to `send()` is byte-identical afterwards. + +Middleware that changes a message returns a new one. `patchMessage()` is +the supported way: + +```ts +return next( + msgs.map((m) => patchMessage(m, { html })), + ctx, +) +``` + +## Results, not exceptions + +`send()` and `sendBatch()` do not throw. A normalization failure, a driver +that throws, a middleware with a bug, a driver that returns the wrong +number of results — each becomes a `Result` in the slot it belongs to. + +`sendBatch` is positional by contract: `results[i]` corresponds to +`messages[i]`, always. A driver whose `sendBatch` breaks that mapping fails +its whole batch loudly, because every downstream index would otherwise be +silently wrong. + +## Drivers are transports + +A driver takes a normalized message and gets it to a provider. It does not +retry, rate limit, or log — those are middleware, and they work the same +for every driver. + +`fallback` and `roundRobin` are drivers too, not a separate concept: they +take messages and produce results, and they compose with middleware in +either direction. + +```ts +fallback([wrap(resend(...), withRetry()), ses(...)]) // retry inside each leg +createEmail({ driver: fallback([...]), use: [withRetry()] }) // retry around the whole thing +``` + +## Initialization + +Per driver, at most once, and the promise is stored before it is awaited — +so two concurrent sends share one initialization instead of racing past a +half-open connection. Keyed by driver rather than by instance, so a driver +mounted after the first send is still initialized. + +## What lives where + +| Path | Contains | Imports Node? | +| ----------------- | ---------------------------------------------------------- | ------------- | +| `src/core/` | types, errors, results, addresses, normalization, pipeline | no | +| `src/drivers/` | transports, one shared `fetch` layer, the MIME builder | `smtp` only | +| `src/middleware/` | retry, rate limit, circuit breaker, logger, idempotency | no | +| `src/render/` | the render middleware and the React adapter | no | + +`src/core/types.ts` compiles to nothing — it is types only, so importing it +costs no bytes in a Worker bundle. + +## Invariants CI enforces + +- **Bundle budgets** (`scripts/bundle-budget.mjs`) — every entry has a + ceiling; exceeding one is a deliberate decision, not a drift. +- **Version consistency** (`scripts/check-version.mjs`) — `package.json`, + `jsr.json` and the `version` constant must agree. They drifted in 0.x. +- **`isolatedDeclarations`** — a file that cannot emit its own `.d.mts` + fails typecheck rather than shipping a package with missing types. +- **ATTW** — the published `exports` map is checked against an ESM-only + profile on every release. diff --git a/docs/drivers.md b/docs/drivers.md index 2cffb8d..22fb61f 100644 --- a/docs/drivers.md +++ b/docs/drivers.md @@ -1,168 +1,142 @@ # Drivers -Every transport in unemail is a driver — a small module conforming to -`EmailDriver`. You wire one into `createEmail({ driver })` and never -touch it again; swapping providers is a one-line change. - -## Built-in drivers - -| Sub-path | Runtime | Attachments | Batch | Scheduling | Idempotency | Templates | Tags | Streams | -| ----------------------------------------- | ------------------ | :---------: | :-----: | :--------: | :---------: | :-------: | :--: | :-----: | -| `unemail/driver/mock` | all | ✓ | ✓ | ✓ | ✓ | – | ✓ | – | -| `unemail/driver/smtp` | Node + Bun | ✓ | ✓ (seq) | – | – | – | – | – | -| `unemail/driver/mailcrab` | Node (local only) | ✓ | ✓ | – | – | – | – | – | -| `unemail/driver/resend` | all | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | – | -| `unemail/driver/postmark` | all | ✓ | ✓ | – | – | ✓ | ✓ | ✓ | -| `unemail/driver/ses` | all (Web-Crypto) | ✓ | ✓ (seq) | – | – | – | ✓ | – | -| `unemail/driver/sendgrid` | all | ✓ | – | ✓ | – | ✓ | ✓ | – | -| `unemail/driver/mailgun` | all | ✓ | – | ✓ | – | – | ✓ | – | -| `unemail/driver/mailtrap` | all | ✓ | ✓ | – | – | ✓ | ✓ | – | -| `unemail/driver/brevo` | all | ✓ | – | ✓ | – | ✓ | ✓ | – | -| `unemail/driver/mailersend` | all | ✓ | ✓ | ✓ | – | – | ✓ | – | -| `unemail/driver/loops` | all | – | – | – | – | ✓ | ✓ | – | -| `unemail/driver/zeptomail` | all | ✓ | – | – | – | – | – | – | -| `unemail/driver/mailchannels` | all (CF Workers) | ✓ | – | – | – | – | – | – | -| `unemail/driver/cloudflare-email` | CF Workers binding | ✓ | – | – | – | – | – | – | -| `unemail/driver/cloudflare-email-service` | CF Workers binding | ✓ | – | – | – | – | – | – | -| `unemail/driver/http` | all | (custom) | – | (custom) | – | – | – | – | - -### Mailtrap (Email API + Email Sandbox) - -The Mailtrap driver uses one API token for both environments. Email API sends -go to `send.api.mailtrap.io`; Email Sandbox (test inbox capture) uses -`sandbox.api.mailtrap.io` with your inbox ID in the path. +## Resend — `unemail/drivers/resend` ```ts -import { createEmail } from "unemail" -import mailtrap from "unemail/driver/mailtrap" - -const email = createEmail({ - driver: mailtrap({ - apiKey: process.env.MAILTRAP_API_KEY!, - inboxId: process.env.MAILTRAP_INBOX_ID, - sandbox: process.env.MAILTRAP_USE_SANDBOX === "true", - }), -}) +resend({ apiKey: process.env.RESEND_API_KEY!, endpoint?, fetch? }) +``` + +Native batch (`/emails/batch`), scheduling, `cancel()`, `retrieve()`, and +provider-side idempotency via the `Idempotency-Key` header when the message +carries an `idempotencyKey`. + +Resend has no metadata field, so `message.metadata` is sent as +`X-Metadata-*` headers — which is what comes back on its webhook events. + +The key is checked for its `re_` prefix at construction. + +## Postmark — `unemail/drivers/postmark` + +```ts +postmark({ token, messageStream?, endpoint?, fetch? }) +``` + +Pass the per-server token, not the account token. + +Postmark reports per-message failures inside a `200` batch response; those +become individual failed results rather than a failed batch. It accepts one +`Tag` per message, so extra tags carry as metadata instead of being dropped. + +Templated and plain messages use different endpoints and cannot be mixed in +one batch — a mixed batch fails with `INVALID_OPTIONS` before any request +is made. + +Route with `message.stream`, which overrides the driver's `messageStream`. + +## Amazon SES — `unemail/drivers/ses` + +```ts +ses({ region, accessKeyId?, secretAccessKey?, sessionToken?, + configurationSetName?, fromArn?, endpoint?, fetch? }) +``` -// Sandbox (captured in test inbox) -await email.send({ from: "a@b.com", to: "c@d.com", subject: "x", text: "hi", sandbox: true }) +No `@aws-sdk/*`: SigV4 is signed with Web Crypto and the message is posted +as raw MIME, so attachments and inline images work and the driver runs in a +Worker. Credentials fall back to `AWS_ACCESS_KEY_ID`, +`AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN`. -// Email API -await email.send({ from: "a@verified.com", to: "c@d.com", subject: "x", text: "hi" }) +SES reads recipients off the envelope, which is where bcc lives — blind +recipients reach the provider without appearing in the document. + +`__type` is read for classification, so an expired token is `AUTH` and +throttling is a retryable `RATE_LIMIT` rather than a generic 400. + +SES v2 has no raw-MIME bulk endpoint, so there is no `sendBatch`; the core +sends sequentially. + +## SMTP — `unemail/drivers/smtp` + +```ts +smtp({ + host, port?, secure?, requireTLS?, + user?, password?, authMethod?, getAccessToken?, + rejectUnauthorized?, tls?, localName?, + pool?, maxConnections?, maxMessagesPerConnection?, idleTimeoutMs?, + connectionTimeoutMs?, commandTimeoutMs?, disposeGraceMs?, + dkim?, +}) ``` -Set `msg.sandbox` per message to override the driver default. `sendBatch` -works in both modes; all messages in one batch must target the same environment. -Sandbox sends require `inboxId` (from `mailtrap.io/sandboxes/{id}`). +Its own protocol implementation — no `nodemailer`, no transitive +dependencies. `port` defaults to 465 when `secure`, 587 otherwise. +`rejectUnauthorized` defaults to `true`. -### Cloudflare: Email Routing vs Email Service +`AUTO` picks the strongest method the server advertises (`XOAUTH2`, +`CRAM-MD5`, `PLAIN`, `LOGIN`). -Cloudflare exposes two send APIs on the same `send_email` binding, so there are -two drivers: +With `pool: true` connections are reused; one that fails mid-transaction is +discarded rather than returned in an unknown protocol state. -- `unemail/driver/cloudflare-email` — **Email Routing**. Builds raw RFC 5322 and - hands it to `EmailMessage` from the virtual `cloudflare:email` module. Single - recipient per send, by construction. -- `unemail/driver/cloudflare-email-service` — **Email Service** (Email Sending). - Passes structured fields. No ambient global, no virtual module, no MIME - builder in your bundle; multiple recipients in one call. +DKIM signs the assembled document. Pass a function to select a key per +message for multi-tenant sending: ```ts -import { createEmail } from "unemail" -import cloudflareEmailService from "unemail/driver/cloudflare-email-service" - -export default { - async fetch(req: Request, env: Env) { - const email = createEmail({ driver: cloudflareEmailService({ binding: env.EMAIL }) }) - await email.send({ - from: { email: "welcome@yourdomain.com", name: "Acme" }, - to: ["a@example.com", "b@example.com"], - subject: "Welcome", - html: "

Welcome

", - text: "Welcome", - }) - return new Response("ok") - }, -} +smtp({ host, dkim: (msg) => keyFor(msg.from.email.split("@")[1]!) }) ``` -Needs `"send_email": [{ "name": "EMAIL" }]` in `wrangler.jsonc` and a sender -domain onboarded with `wrangler email sending enable `. Combined -`to` + `cc` + `bcc` is capped at 50 addresses per send by the provider; the -binding's `E_*` errors are mapped onto the [error taxonomy](#error-taxonomy), -so rate limits stay retryable while validation failures do not. +`message.raw` bypasses the MIME builder entirely; the envelope is still +taken from the message's addresses. + +Needs `node:net` and `node:tls` — this is the one driver that does not run +in a Worker. -### Meta drivers +## Mock — `unemail/drivers/mock` -These wrap other drivers: +```ts +mock({ fail?, failWhen?, latencyMs?, inbox? }) +``` -- `unemail/driver/fallback` — try a list of drivers in order -- `unemail/driver/round-robin` — cycle sends across drivers (with weights) +`getInstance()` returns the inbox: `messages`, `find(address)`, `last()`, +`clear()`. Messages are stored normalized, so assertions see what a real +driver would have seen. -## Authoring a custom driver +## Fallback — `unemail/drivers/fallback` ```ts -import { defineDriver, type EmailDriver } from "unemail" - -interface MyOptions { - apiKey: string - endpoint?: string -} - -export default defineDriver((opts) => ({ - name: "my-driver", - options: opts, - flags: { - attachments: true, - html: true, - text: true, - replyTo: true, - }, - async initialize() { - // Optional: open connections, refresh tokens, etc. - }, - async isAvailable() { - return Boolean(opts?.apiKey) - }, - async send(msg, _ctx) { - const res = await fetch(opts!.endpoint ?? "https://api.example.com/send", { - method: "POST", - headers: { authorization: `Bearer ${opts!.apiKey}` }, - body: JSON.stringify(msg), - }) - if (!res.ok) { - return { - data: null, - error: new Error(`HTTP ${res.status}`) as never, // use createError for code taxonomy - } - } - const body = (await res.json()) as { id: string } - return { - data: { id: body.id, driver: "my-driver", at: new Date() }, - error: null, - } - }, - async dispose() { - // Optional: close connections, flush queues. - }, -})) +fallback(drivers, { shouldFailover?, onFailover?, name? }) ``` -## Error taxonomy +Per message, not per batch: only the messages a leg failed reach the next +one. `INVALID_OPTIONS` and `UNSUPPORTED` do not fail over — the next +provider would reject them too. Legs initialize lazily as they are reached. -`EmailError` carries a `code` that's stable across drivers: +## Round-robin — `unemail/drivers/round-robin` -| Code | Meaning | Retryable? | -| ----------------- | ---------------------------------------------- | :--------: | -| `INVALID_OPTIONS` | user input is wrong (missing field, bad shape) | no | -| `NETWORK` | transient network or 5xx | yes | -| `AUTH` | bad credentials | no | -| `RATE_LIMIT` | 429 or provider rate-limit | yes | -| `TIMEOUT` | client-side timeout fired | yes | -| `PROVIDER` | the provider rejected the message | no | -| `UNSUPPORTED` | driver can't do this (e.g. SMTP on Workers) | no | -| `CANCELLED` | abort signal or pool disposed | no | +```ts +roundRobin(drivers, { weights?, name? }) +``` + +Spreads sends to stay under each provider's limit, or to warm a second +sending domain. A batch is partitioned and each partition goes to its +driver in one request, so native batching survives the split. It does not +fail over — put it behind `fallback` if you need that. + +## Capability matrix -Use `createError(driver, code, message, { status, retryable, cause })`. -The retry middleware honors `error.retryable` and Mailgun-style -`Retry-After` headers. +| | Resend | Postmark | SES | SMTP | Mock | +| -------------------- | :----: | :------: | :-: | :--: | :--: | +| attachments | ✅ | ✅ | ✅ | ✅ | ✅ | +| html / text | ✅ | ✅ | ✅ | ✅ | ✅ | +| native batch | ✅ | ✅ | — | — | ✅ | +| scheduling | ✅ | — | — | — | ✅ | +| provider idempotency | ✅ | — | — | — | ✅ | +| templates | — | ✅ | — | — | ✅ | +| tracking | — | ✅ | — | — | ✅ | +| tagging | ✅ | ✅ | ✅ | — | ✅ | +| cancel / retrieve | ✅ | — | — | — | — | + +Read it at runtime rather than hard-coding it: + +```ts +if (email.driver.features?.scheduling) await email.send({ ...msg, scheduledAt }) +``` diff --git a/docs/inbound.md b/docs/inbound.md deleted file mode 100644 index 841d5b5..0000000 --- a/docs/inbound.md +++ /dev/null @@ -1,61 +0,0 @@ -# Inbound email - -unemail ships two complementary pieces: - -- `unemail/parse` — parse raw MIME into a unified `ParsedEmail` -- `unemail/inbound` — handle provider webhook routes and give you the - same `ParsedEmail` regardless of which provider delivered the message - -## Low-level parsing - -```ts -import { parseEmail } from "unemail/parse" - -const mail = await parseEmail(rawMime) -// { subject, from, to, cc, bcc, text, html, headers, attachments, ... } -``` - -`parseEmail` accepts `string`, `Uint8Array`, `ArrayBuffer`, `Blob`, or -`ReadableStream`. It wraps [postal-mime](https://github.com/postalsys/postal-mime) -as an optional peer dep — the entry is Workers-parseable even without it -installed (loaded on first call). - -## Unified inbound handler - -```ts -import { defineInboundHandler } from "unemail/inbound" -import sendgridInbound from "unemail/inbound/sendgrid" -import mailgunInbound from "unemail/inbound/mailgun" -import postmarkInbound from "unemail/inbound/postmark" -import cloudflareInbound from "unemail/inbound/cloudflare" - -export default defineInboundHandler({ - providers: [ - sendgridInbound(), - mailgunInbound({ signingKey: process.env.MG_SIGNING_KEY! }), - postmarkInbound({ basicAuth: "user:pass" }), - cloudflareInbound({ secretHeader: "x-secret", secret: process.env.INBOUND_SECRET }), - ], - async onEmail(mail, ctx) { - console.log(`[${ctx.provider}]`, mail.subject, "from", mail.from?.email) - // mail: ParsedEmail — same shape regardless of provider - }, -}) -``` - -The returned handler is a standard `(req: Request) => Promise` -— drop it into Nitro, a Cloudflare Worker, Hono, Next.js route handlers, -or a raw `fetch` listener. - -### Signature verification - -Each adapter accepts provider-specific verification options (shared -secrets, HMAC keys, Basic auth). Failures return `401` by default; pass -`onVerificationFailure` to customize. - -### SES inbound - -AWS SES routes inbound mail through SNS, so it's handled by the SES -webhook verifier — see [webhooks](./webhooks.md). The SNS payload -includes the raw MIME when you set up the receipt rule to store the -message in S3 or pass it through SNS directly. diff --git a/docs/observability.md b/docs/observability.md deleted file mode 100644 index 56360a9..0000000 --- a/docs/observability.md +++ /dev/null @@ -1,62 +0,0 @@ -# Observability - -Two middlewares give you production-grade visibility without extra deps. - -## Structured logging - -```ts -import { withLogger } from "unemail" - -email.use(withLogger()) -// → `console.info(JSON.stringify(entry))` on each send.start / send.success -// → `console.error(JSON.stringify(entry))` on send.error -``` - -Pipe the output somewhere (Pino, Axiom, Logflare, Datadog) via a custom -sink: - -```ts -email.use( - withLogger({ - sink: (entry) => logger.info(entry), - redactLocalPart: true, // ada@acme.com → a***@acme.com - includeSubject: false, // subjects can contain PII; turn off if needed - }), -) -``` - -Entries include `driver`, `stream`, `attempt`, `messageId`, `recipient`, -`subject`, `durationMs`, `error.{code,message,retryable}`, and any -`ctx.meta` fields set by other middleware. - -## OpenTelemetry tracing - -```ts -import { trace } from "@opentelemetry/api" -import { withTelemetry } from "unemail" - -email.use(withTelemetry({ tracer: trace.getTracer("unemail") })) -``` - -Each send produces one span (`email.send`) with attributes: - -- `email.driver`, `email.stream`, `email.attempt` -- `email.to`, `email.subject.length` -- `email.message_id` (on success) -- `email.error.code` (on failure) - -When no tracer is passed, `withTelemetry()` is a no-op — cheap to leave -in place for environments that don't have OTel wired up. - -## Sampling - -```ts -email.use( - withTelemetry({ - tracer, - sample: (attrs) => attrs["email.stream"] !== "health-check", - }), -) -``` - -Return `false` to skip span creation for a given send. diff --git a/docs/queue.md b/docs/queue.md deleted file mode 100644 index 3bca074..0000000 --- a/docs/queue.md +++ /dev/null @@ -1,63 +0,0 @@ -# Queue - -Background sending is opt-in. Pick a queue driver, start a worker, and -call `queue.enqueue(msg)` from your app instead of `email.send(msg)`. - -## In-memory (single-process) - -```ts -import { createEmail } from "unemail" -import memoryQueue from "unemail/queue/memory" -import { startWorker } from "unemail/queue/worker" -import resend from "unemail/driver/resend" - -const email = createEmail({ driver: resend({ apiKey: process.env.RESEND_KEY! }) }) -const queue = memoryQueue() -const worker = startWorker(email, queue, { - concurrency: 5, - maxAttempts: 5, - backoff: (attempt) => 500 * 2 ** attempt, -}) -worker.start() - -await queue.enqueue({ from, to, subject, text }) -``` - -## Durable with unstorage - -```ts -import { createStorage } from "unstorage" -import redisDriver from "unstorage/drivers/redis" -import unstorageQueue from "unemail/queue/unstorage" - -const storage = createStorage({ driver: redisDriver({ url: process.env.REDIS_URL! }) }) -const queue = unstorageQueue({ storage, prefix: "unemail:queue:" }) -``` - -Any unstorage driver works — Upstash, Cloudflare KV, filesystem, MongoDB, -Vercel KV. Items survive restarts; restarted workers pick them up. - -## Anatomy of an item - -```ts -interface QueueItem { - id: string - msg: EmailMessage - attempts: number - nextAttemptAt: number // unix ms - createdAt: number - lastError?: string -} -``` - -The worker `pull`s items whose `nextAttemptAt` has passed, calls -`email.send`, and either `ack`s on success or `fail`s on error (updating -`nextAttemptAt` based on the `backoff` function). After `maxAttempts` -attempts the item is dropped. - -## Custom drivers - -Implement `EmailQueue` for any backend (SQS, QStash, Inngest, BullMQ). -The four methods you need are `enqueue`, `pull`, `ack`, `fail` (+ `size` -for metrics). The worker loop is intentionally portable — swap the loop -out entirely if your driver pushes (SQS long-polling, QStash webhooks). diff --git a/docs/recipes/typed-get-instance.md b/docs/recipes/typed-get-instance.md deleted file mode 100644 index b9d1ed6..0000000 --- a/docs/recipes/typed-get-instance.md +++ /dev/null @@ -1,54 +0,0 @@ -# Typed `getInstance()` — native provider SDK escape hatch - -`unemail` wraps what's portable across drivers, but sometimes you need -a provider-native API that nobody else has (Resend audiences, SES -templates, SendGrid IP warmup). The `EmailDriver.getInstance()` hook -is a typed escape hatch. - -## Usage - -```ts -import { defineDriver } from "unemail" -import { Resend } from "resend" - -export function resendWithInstance(opts: { apiKey: string }) { - const client = new Resend(opts.apiKey) - return defineDriver(() => ({ - name: "resend-native", - getInstance: () => client, - async send(msg) { - const { data, error } = await client.emails.send({ - from: String(msg.from), - to: String(msg.to), - subject: msg.subject, - text: msg.text ?? "", - }) - if (error) return { data: null, error: new Error(error.message) as never } - return { data: { id: data!.id, driver: "resend-native", at: new Date() }, error: null } - }, - }))(opts) -} - -// Consumer code: -const driver = resendWithInstance({ apiKey: "re_..." }) -const email = createEmail({ driver }) - -// Typed access to the native SDK for things unemail doesn't wrap: -const resend = driver.getInstance?.() -if (resend) { - await resend.audiences.create({ name: "allhands" }) -} -``` - -## When to reach for it - -- **Native audiences / broadcasts / contacts** — e.g. Resend's - `audiences`, SendGrid's `contactdb`. -- **Suppression / template CRUD** — SES `CreateTemplate`, Mailgun - routes. -- **Advanced auth flows** — IdP-signed requests outside the scope of - `unemail/middleware/oauth2`. - -When the feature is portable across 2+ providers it belongs in core -(file an issue). When it's a single-provider escape hatch, -`getInstance()` is the right answer. diff --git a/docs/rendering.md b/docs/rendering.md deleted file mode 100644 index 9135848..0000000 --- a/docs/rendering.md +++ /dev/null @@ -1,90 +0,0 @@ -# Rendering - -unemail doesn't have an opinion on templates — you render with whatever -you already use, and the `withRender` middleware drops the result into -`msg.html` before the driver sees it. - -## React Email - -```ts -import { createEmail, withRender } from "unemail" -import resend from "unemail/driver/resend" -import reactRender from "unemail/render/react" -import { Welcome } from "./emails/welcome.tsx" - -const email = createEmail({ driver: resend({ apiKey: process.env.RESEND_KEY! }) }) -email.use(withRender(reactRender())) - -await email.send({ - from: "Acme ", - to: "user@example.com", - subject: "Welcome", - react: , -}) -``` - -The `@react-email/render` peer is loaded lazily — the module parses on -Cloudflare Workers even without it installed. - -## jsx-email - -```ts -import jsxRender from "unemail/render/jsx-email" - -email.use(withRender(jsxRender({ inlineCss: true }))) -await email.send({ from, to, subject, jsx: }) -``` - -Peer: `jsx-email`. - -## MJML - -```ts -import mjmlRender from "unemail/render/mjml" - -email.use(withRender(mjmlRender())) -await email.send({ - from, - to, - subject, - mjml: ` - Hello Ada - `, -}) -``` - -Peer: `mjml` (or `mjml-browser` in the browser). - -## Combining adapters - -`withRender` accepts any number of adapters. The first whose `match(msg)` -returns true wins, so you can register all three safely: - -```ts -email.use(withRender(reactRender(), jsxRender(), mjmlRender())) -``` - -## Plain text fallback - -When a renderer resolves `msg.html` and you didn't set `msg.text`, the -middleware derives plain text via `htmlToText` automatically. Disable -with `withRender(...renderers).options.autoText = false` or set -`msg.text` yourself. - -## Type-safe templates - -```ts -import { defineTemplate } from "unemail" -import { Welcome } from "./emails/welcome.tsx" - -export const welcome = defineTemplate<{ name: string, activationUrl: string }>( - ({ name, activationUrl }) => ({ - subject: `Welcome, ${name}!`, - react: , - }), -) - -// Compile-time check on variables: -const rendered = welcome({ name: "Ada", activationUrl: "https://…" }) -await email.send({ from, to, subject: rendered.subject!, react: rendered.react }) -``` diff --git a/docs/rfcs/001-packaging.md b/docs/rfcs/001-packaging.md deleted file mode 100644 index f6d82d2..0000000 --- a/docs/rfcs/001-packaging.md +++ /dev/null @@ -1,83 +0,0 @@ -# RFC-001: Package Structure - -**Status:** Decision landed for v1.x. Revisit for v2. - -## Context - -Upyo (our direct peer) ships each transport as its own package -(`@upyo/resend`, `@upyo/sendgrid`, `@upyo/smtp`). `unemail` currently -ships one package with ~50 sub-path exports (e.g. -`unemail/driver/resend`, `unemail/render/mjml`, `unemail/queue/sqs`). - -Both models work. The question is which one minimizes: - -1. Install footprint — only paying for what you use. -2. Peer-dep footprint — `mjml`, `@react-email/render`, `juice`, - `handlebars`, `liquidjs`, `postal-mime` are all optional. -3. Discoverability — one import path vs 50 packages. -4. Maintenance cost — one CHANGELOG + one versioning dance vs 15+ - independent package versions. - -## Options - -### A. Keep the monorepo single-package model (status quo) - -- **Pros:** One `npm install unemail`. One CHANGELOG. Sub-path - imports already tree-shake well with obuild/ESM. Peer deps are - truly peer (not installed unless imported). -- **Cons:** Users must know the sub-path exists. `npm ls` looks - heavy even if you're only using `unemail/driver/mock`. - -### B. Split into ~15 packages under `@unemail/*` - -- **Pros:** Tree-shaking becomes obvious at the install level. Users - discover transports by browsing the `@unemail` scope on npm. -- **Cons:** 15× release coordination. Cross-package refactors need - matching version bumps. Contributors have a steeper learning - curve. - -### C. Hybrid — `unemail` stays the single install, but we publish - -`@unemail/smtp`, `@unemail/resend`, `@unemail/ses` as thin re-exports -for users who want the narrow install. - -- **Pros:** Zero behaviour change for existing users. New users can - opt into narrow installs. -- **Cons:** More publishing steps. Confusing if two import paths - reach the same code. - -## Decision for v1.x - -**Stay on Option A.** Reasons: - -1. Install size is already competitive (~240 kB dist total, ~5–30 kB - per sub-path). -2. Every transport we ship uses `fetch` and has no runtime deps, so - "install `unemail` and pay for Resend-only code" is already the - reality — tree-shaking does the rest. -3. Peer deps (`mjml`, `@react-email/render`, `juice`, `handlebars`, - `liquidjs`, `postal-mime`, `@opentelemetry/api`, `unstorage`, - `bullmq`, `pg-boss`, `@aws-sdk/client-sqs`) are all optional and - lazy-imported. Users who don't import the relevant sub-path never - see them in `node_modules`. -4. One version + one CHANGELOG keeps maintenance tractable for a - solo / small-team OSS project. - -## Revisit for v2 if - -- Users file more than 5 issues asking for narrow installs. -- Bundle-size budgets push us past 300 kB total dist. -- We add a transport with a large required runtime dep (unlikely — - every new driver should stick to `fetch`). - -## Implementation notes (status quo) - -- Every driver is a sub-path export: `unemail/driver/`. -- Render adapters live under `unemail/render/`. -- Observability + deliverability utilities are lightly grouped: - `unemail/events`, `unemail/dmarc`, `unemail/mta-sts`, - `unemail/verify/arc`, `unemail/parse/arf`, `unemail/ics`, - `unemail/compliance`, `unemail/suppression`, `unemail/preferences`, - `unemail/address`, `unemail/result`. -- Package entries are listed in `package.json#exports` and mirrored - in `jsr.json#exports`. diff --git a/docs/testing.md b/docs/testing.md deleted file mode 100644 index d3e5d65..0000000 --- a/docs/testing.md +++ /dev/null @@ -1,77 +0,0 @@ -# Testing - -`unemail/test` ships an `Email` instance backed by the mock driver so -you never have to stub providers by hand. - -## The test inbox - -```ts -import { createTestEmail } from "unemail/test" -import { it, expect } from "vitest" - -it("sends a welcome email", async () => { - const email = createTestEmail() - await signUpUser(email, { email: "ada@acme.com", name: "Ada" }) - - expect(email.inbox).toHaveLength(1) - expect(email.last?.subject).toMatch(/welcome/i) - expect(email.find((m) => m.to === "ada@acme.com")).toBeDefined() -}) -``` - -## Waiting for async sends - -When the send happens on a timer or a background task: - -```ts -const msg = await email.waitFor((m) => m.subject === "Reminder", { - timeout: 2000, - interval: 20, -}) -expect(msg.text).toContain("you left something in the cart") -``` - -## Vitest matchers - -```ts -import { expect } from "vitest" -import { createTestEmail, emailMatchers } from "unemail/test" - -expect.extend(emailMatchers) - -declare module "vitest" { - interface Matchers { - toHaveSent: (match: { - from?: string | RegExp - to?: string | RegExp - subject?: string | RegExp - html?: string | RegExp - text?: string | RegExp - stream?: string - }) => R - } -} - -const email = createTestEmail() -// …send something… -expect(email).toHaveSent({ to: "ada@acme.com", subject: /welcome/i }) -``` - -If you can't use the `expect.extend` augmentation, call -`matchesEmail(message, match)` directly — it returns -`{ pass, diff }`. - -## Integration tests with MailCrab - -When you need a real SMTP server to exercise the full pipeline: - -```ts -import { createEmail } from "unemail" -import mailcrab from "unemail/driver/mailcrab" - -const email = createEmail({ driver: mailcrab({ quiet: true }) }) -await email.send({ from, to, subject, text }) -// Open http://localhost:1080 to inspect -``` - -Run `pnpm dlx unemail-mailcrab` to spin up the server via Docker. diff --git a/docs/webhooks.md b/docs/webhooks.md deleted file mode 100644 index a107726..0000000 --- a/docs/webhooks.md +++ /dev/null @@ -1,77 +0,0 @@ -# Webhooks - -unemail normalizes every provider's webhook payload into one shape. - -```ts -type WebhookEvent = { - type: - | "sent" - | "delivered" - | "bounced" - | "complained" - | "opened" - | "clicked" - | "unsubscribed" - | "rejected" - | "failed" - | "other" - id: string - at: Date - recipient: string - provider: string - raw: unknown // original payload preserved - url?: string // for "clicked" - bounce?: "hard" | "soft" | "unknown" -} -``` - -## Wiring it up - -```ts -import { defineWebhookHandler } from "unemail/webhook" -import resendWebhook from "unemail/webhook/resend" -import postmarkWebhook from "unemail/webhook/postmark" -import mailgunWebhook from "unemail/webhook/mailgun" -import sendgridWebhook from "unemail/webhook/sendgrid" -import sesWebhook from "unemail/webhook/ses" - -export default defineWebhookHandler({ - providers: [ - resendWebhook({ secret: process.env.RESEND_WEBHOOK_SECRET! }), - postmarkWebhook({ basicAuth: "user:pass" }), - mailgunWebhook({ signingKey: process.env.MG_SIGNING_KEY! }), - sendgridWebhook({ publicKey: process.env.SG_PUBLIC_KEY! }), - sesWebhook({ topicArns: [process.env.SES_TOPIC_ARN!] }), - ], - async onEvent(event) { - console.log(event.type, event.recipient, event.id) - if (event.type === "bounced" && event.bounce === "hard") { - await suppressionList.add(event.recipient) - } - }, -}) -``` - -Every verifier runs on Web Crypto — no `node:crypto`, no vendor SDK, -Cloudflare Workers ready. - -## Signature formats - -| Provider | Header(s) | Scheme | -| --------- | ---------------------------------------------------- | -------------------------------------------------- | -| Resend | `svix-id`, `svix-timestamp`, `svix-signature` | HMAC-SHA256 (base64 secret) | -| Postmark | `authorization: Basic ...` | HTTP Basic | -| Mailgun | payload `signature.{timestamp,token,signature}` | HMAC-SHA256 of `ts+token` | -| SendGrid | `x-twilio-email-event-webhook-{timestamp,signature}` | ECDSA P-256 / SHA-256 | -| SES (SNS) | `x-amz-sns-message-type` | TopicArn allow-list + optional cert-fetch callback | - -## Timestamp windows - -Each verifier accepts a `toleranceSeconds` option (default `300`) to -reject replayed payloads. Missing timestamps fail closed. - -## Failure behavior - -Returning `[]` from a verifier means "this looks like my payload but the -signature is bad" — the handler responds `401`. Returning `null` means -"not my payload" — the handler tries the next provider. diff --git a/jsr.json b/jsr.json index c1a1f1c..b73feed 100644 --- a/jsr.json +++ b/jsr.json @@ -1,76 +1,20 @@ { "name": "@productdevbook/unemail", - "version": "0.5.0", + "version": "1.0.0", "exports": { ".": "./src/index.ts", - "./driver/mock": "./src/driver/mock.ts", - "./driver/smtp": "./src/driver/smtp.ts", - "./driver/postmark": "./src/driver/postmark.ts", - "./driver/ses": "./src/driver/ses.ts", - "./driver/http": "./src/driver/http.ts", - "./driver/zeptomail": "./src/driver/zeptomail.ts", - "./driver/sendgrid": "./src/driver/sendgrid.ts", - "./driver/mailgun": "./src/driver/mailgun.ts", - "./driver/mailtrap": "./src/driver/mailtrap.ts", - "./driver/brevo": "./src/driver/brevo.ts", - "./driver/mailersend": "./src/driver/mailersend.ts", - "./driver/loops": "./src/driver/loops.ts", - "./driver/mailchannels": "./src/driver/mailchannels.ts", - "./driver/cloudflare-email": "./src/driver/cloudflare-email.ts", - "./driver/cloudflare-email-service": "./src/driver/cloudflare-email-service.ts", - "./driver/mailcrab": "./src/driver/mailcrab.ts", - "./driver/resend": "./src/driver/resend.ts", - "./driver/fallback": "./src/driver/fallback.ts", - "./driver/round-robin": "./src/driver/round-robin.ts", - "./driver/tee": "./src/driver/tee.ts", + "./drivers/mock": "./src/drivers/mock.ts", + "./drivers/smtp": "./src/drivers/smtp.ts", + "./drivers/resend": "./src/drivers/resend.ts", + "./drivers/postmark": "./src/drivers/postmark.ts", + "./drivers/ses": "./src/drivers/ses.ts", + "./drivers/fallback": "./src/drivers/fallback.ts", + "./drivers/round-robin": "./src/drivers/round-robin.ts", "./middleware": "./src/middleware/index.ts", "./render": "./src/render/index.ts", - "./render/react": "./src/render/react.ts", - "./render/jsx-email": "./src/render/jsx-email.ts", - "./render/mjml": "./src/render/mjml.ts", - "./render/pipeline": "./src/render/pipeline.ts", - "./render/handlebars": "./src/render/handlebars.ts", - "./render/liquid": "./src/render/liquid.ts", - "./render/i18n": "./src/render/i18n.ts", - "./test": "./src/test/index.ts", - "./parse": "./src/parse/index.ts", - "./inbound": "./src/inbound/index.ts", - "./inbound/cloudflare": "./src/inbound/cloudflare.ts", - "./inbound/postmark": "./src/inbound/postmark.ts", - "./inbound/sendgrid": "./src/inbound/sendgrid.ts", - "./inbound/mailgun": "./src/inbound/mailgun.ts", - "./webhook": "./src/webhook/index.ts", - "./webhook/resend": "./src/webhook/resend.ts", - "./webhook/postmark": "./src/webhook/postmark.ts", - "./webhook/mailgun": "./src/webhook/mailgun.ts", - "./webhook/sendgrid": "./src/webhook/sendgrid.ts", - "./webhook/ses": "./src/webhook/ses.ts", - "./webhook/standard": "./src/webhook/standard.ts", - "./verify": "./src/verify/index.ts", - "./queue": "./src/queue/index.ts", - "./queue/memory": "./src/queue/memory.ts", - "./queue/unstorage": "./src/queue/unstorage.ts", - "./queue/worker": "./src/queue/worker.ts", - "./suppression": "./src/suppression/index.ts", - "./compliance": "./src/compliance/index.ts", - "./result": "./src/result/index.ts", - "./ics": "./src/ics/index.ts", - "./inbound/reply": "./src/inbound/reply.ts", - "./inbound/thread": "./src/inbound/thread.ts", - "./address": "./src/address.ts", - "./preferences": "./src/preferences/index.ts", - "./dmarc": "./src/dmarc/index.ts", - "./verify/arc": "./src/verify/arc.ts", - "./mta-sts": "./src/mta-sts/index.ts", - "./parse/arf": "./src/parse/arf.ts", - "./events": "./src/events/index.ts", - "./inbound/ses": "./src/inbound/ses.ts", - "./queue/bullmq": "./src/queue/bullmq.ts", - "./queue/pg-boss": "./src/queue/pg-boss.ts", - "./queue/sqs": "./src/queue/sqs.ts" + "./render/react": "./src/render/react.ts" }, "publish": { - "include": ["src/**/*.ts", "README.md", "LICENSE"], - "exclude": ["src/**/*.test.ts"] + "include": ["src", "README.md", "LICENSE", "jsr.json"] } } diff --git a/package.json b/package.json index 78dbb72..09de4c2 100644 --- a/package.json +++ b/package.json @@ -1,44 +1,25 @@ { "name": "unemail", - "version": "0.5.0", + "version": "1.0.0", "private": false, - "description": "Driver-based TypeScript email library — send, parse, render, verify. Zero-deps core; works on Node, Bun, Deno, Cloudflare Workers, and the browser.", + "description": "Driver-based TypeScript email library. Batch-native middleware pipeline, zero dependencies, ESM-only — runs on Node, Bun, Deno, Cloudflare Workers and the browser.", "keywords": [ "aws-ses", "batch-email", - "brevo", - "cloudflare-email", - "cloudflare-email-service", - "cloudflare-workers", "dkim", - "dmarc", "driver", "driver-pattern", "email", - "email-parser", "email-service", - "email-templates", "esm", "idempotency", - "jsx-email", - "mailchannels", - "mailcrab", - "mailersend", - "mailgun", - "mailtrap", - "mjml", - "postal-mime", + "middleware", "postmark", "react-email", "resend", - "scheduled-email", - "sendgrid", "smtp", - "spf", "transactional-email", "typescript", - "webhooks", - "zeptomail", "zero-dependencies" ], "homepage": "https://github.com/productdevbook/unemail#readme", @@ -62,85 +43,33 @@ "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, - "./driver/mock": { - "types": "./dist/driver/mock.d.mts", - "default": "./dist/driver/mock.mjs" + "./drivers/mock": { + "types": "./dist/drivers/mock.d.mts", + "default": "./dist/drivers/mock.mjs" }, - "./driver/smtp": { - "types": "./dist/driver/smtp.d.mts", - "default": "./dist/driver/smtp.mjs" + "./drivers/smtp": { + "types": "./dist/drivers/smtp.d.mts", + "default": "./dist/drivers/smtp.mjs" }, - "./driver/postmark": { - "types": "./dist/driver/postmark.d.mts", - "default": "./dist/driver/postmark.mjs" + "./drivers/resend": { + "types": "./dist/drivers/resend.d.mts", + "default": "./dist/drivers/resend.mjs" }, - "./driver/ses": { - "types": "./dist/driver/ses.d.mts", - "default": "./dist/driver/ses.mjs" + "./drivers/postmark": { + "types": "./dist/drivers/postmark.d.mts", + "default": "./dist/drivers/postmark.mjs" }, - "./driver/http": { - "types": "./dist/driver/http.d.mts", - "default": "./dist/driver/http.mjs" + "./drivers/ses": { + "types": "./dist/drivers/ses.d.mts", + "default": "./dist/drivers/ses.mjs" }, - "./driver/zeptomail": { - "types": "./dist/driver/zeptomail.d.mts", - "default": "./dist/driver/zeptomail.mjs" + "./drivers/fallback": { + "types": "./dist/drivers/fallback.d.mts", + "default": "./dist/drivers/fallback.mjs" }, - "./driver/sendgrid": { - "types": "./dist/driver/sendgrid.d.mts", - "default": "./dist/driver/sendgrid.mjs" - }, - "./driver/mailgun": { - "types": "./dist/driver/mailgun.d.mts", - "default": "./dist/driver/mailgun.mjs" - }, - "./driver/mailtrap": { - "types": "./dist/driver/mailtrap.d.mts", - "default": "./dist/driver/mailtrap.mjs" - }, - "./driver/brevo": { - "types": "./dist/driver/brevo.d.mts", - "default": "./dist/driver/brevo.mjs" - }, - "./driver/mailersend": { - "types": "./dist/driver/mailersend.d.mts", - "default": "./dist/driver/mailersend.mjs" - }, - "./driver/loops": { - "types": "./dist/driver/loops.d.mts", - "default": "./dist/driver/loops.mjs" - }, - "./driver/mailchannels": { - "types": "./dist/driver/mailchannels.d.mts", - "default": "./dist/driver/mailchannels.mjs" - }, - "./driver/cloudflare-email": { - "types": "./dist/driver/cloudflare-email.d.mts", - "default": "./dist/driver/cloudflare-email.mjs" - }, - "./driver/cloudflare-email-service": { - "types": "./dist/driver/cloudflare-email-service.d.mts", - "default": "./dist/driver/cloudflare-email-service.mjs" - }, - "./driver/mailcrab": { - "types": "./dist/driver/mailcrab.d.mts", - "default": "./dist/driver/mailcrab.mjs" - }, - "./driver/resend": { - "types": "./dist/driver/resend.d.mts", - "default": "./dist/driver/resend.mjs" - }, - "./driver/fallback": { - "types": "./dist/driver/fallback.d.mts", - "default": "./dist/driver/fallback.mjs" - }, - "./driver/round-robin": { - "types": "./dist/driver/round-robin.d.mts", - "default": "./dist/driver/round-robin.mjs" - }, - "./driver/tee": { - "types": "./dist/driver/tee.d.mts", - "default": "./dist/driver/tee.mjs" + "./drivers/round-robin": { + "types": "./dist/drivers/round-robin.d.mts", + "default": "./dist/drivers/round-robin.mjs" }, "./middleware": { "types": "./dist/middleware/index.d.mts", @@ -154,174 +83,7 @@ "types": "./dist/render/react.d.mts", "default": "./dist/render/react.mjs" }, - "./render/jsx-email": { - "types": "./dist/render/jsx-email.d.mts", - "default": "./dist/render/jsx-email.mjs" - }, - "./render/mjml": { - "types": "./dist/render/mjml.d.mts", - "default": "./dist/render/mjml.mjs" - }, - "./render/pipeline": { - "types": "./dist/render/pipeline.d.mts", - "default": "./dist/render/pipeline.mjs" - }, - "./render/handlebars": { - "types": "./dist/render/handlebars.d.mts", - "default": "./dist/render/handlebars.mjs" - }, - "./render/liquid": { - "types": "./dist/render/liquid.d.mts", - "default": "./dist/render/liquid.mjs" - }, - "./render/i18n": { - "types": "./dist/render/i18n.d.mts", - "default": "./dist/render/i18n.mjs" - }, - "./test": { - "types": "./dist/test/index.d.mts", - "default": "./dist/test/index.mjs" - }, - "./parse": { - "types": "./dist/parse/index.d.mts", - "default": "./dist/parse/index.mjs" - }, - "./inbound": { - "types": "./dist/inbound/index.d.mts", - "default": "./dist/inbound/index.mjs" - }, - "./inbound/cloudflare": { - "types": "./dist/inbound/cloudflare.d.mts", - "default": "./dist/inbound/cloudflare.mjs" - }, - "./inbound/postmark": { - "types": "./dist/inbound/postmark.d.mts", - "default": "./dist/inbound/postmark.mjs" - }, - "./inbound/sendgrid": { - "types": "./dist/inbound/sendgrid.d.mts", - "default": "./dist/inbound/sendgrid.mjs" - }, - "./inbound/mailgun": { - "types": "./dist/inbound/mailgun.d.mts", - "default": "./dist/inbound/mailgun.mjs" - }, - "./inbound/reply": { - "types": "./dist/inbound/reply.d.mts", - "default": "./dist/inbound/reply.mjs" - }, - "./inbound/thread": { - "types": "./dist/inbound/thread.d.mts", - "default": "./dist/inbound/thread.mjs" - }, - "./webhook": { - "types": "./dist/webhook/index.d.mts", - "default": "./dist/webhook/index.mjs" - }, - "./webhook/resend": { - "types": "./dist/webhook/resend.d.mts", - "default": "./dist/webhook/resend.mjs" - }, - "./webhook/postmark": { - "types": "./dist/webhook/postmark.d.mts", - "default": "./dist/webhook/postmark.mjs" - }, - "./webhook/mailgun": { - "types": "./dist/webhook/mailgun.d.mts", - "default": "./dist/webhook/mailgun.mjs" - }, - "./webhook/sendgrid": { - "types": "./dist/webhook/sendgrid.d.mts", - "default": "./dist/webhook/sendgrid.mjs" - }, - "./webhook/ses": { - "types": "./dist/webhook/ses.d.mts", - "default": "./dist/webhook/ses.mjs" - }, - "./webhook/standard": { - "types": "./dist/webhook/standard.d.mts", - "default": "./dist/webhook/standard.mjs" - }, - "./verify": { - "types": "./dist/verify/index.d.mts", - "default": "./dist/verify/index.mjs" - }, - "./queue": { - "types": "./dist/queue/index.d.mts", - "default": "./dist/queue/index.mjs" - }, - "./queue/memory": { - "types": "./dist/queue/memory.d.mts", - "default": "./dist/queue/memory.mjs" - }, - "./queue/unstorage": { - "types": "./dist/queue/unstorage.d.mts", - "default": "./dist/queue/unstorage.mjs" - }, - "./queue/worker": { - "types": "./dist/queue/worker.d.mts", - "default": "./dist/queue/worker.mjs" - }, - "./suppression": { - "types": "./dist/suppression/index.d.mts", - "default": "./dist/suppression/index.mjs" - }, - "./compliance": { - "types": "./dist/compliance/index.d.mts", - "default": "./dist/compliance/index.mjs" - }, - "./result": { - "types": "./dist/result/index.d.mts", - "default": "./dist/result/index.mjs" - }, - "./ics": { - "types": "./dist/ics/index.d.mts", - "default": "./dist/ics/index.mjs" - }, - "./address": { - "types": "./dist/address.d.mts", - "default": "./dist/address.mjs" - }, - "./preferences": { - "types": "./dist/preferences/index.d.mts", - "default": "./dist/preferences/index.mjs" - }, - "./dmarc": { - "types": "./dist/dmarc/index.d.mts", - "default": "./dist/dmarc/index.mjs" - }, - "./verify/arc": { - "types": "./dist/verify/arc.d.mts", - "default": "./dist/verify/arc.mjs" - }, - "./mta-sts": { - "types": "./dist/mta-sts/index.d.mts", - "default": "./dist/mta-sts/index.mjs" - }, - "./parse/arf": { - "types": "./dist/parse/arf.d.mts", - "default": "./dist/parse/arf.mjs" - }, - "./events": { - "types": "./dist/events/index.d.mts", - "default": "./dist/events/index.mjs" - }, - "./inbound/ses": { - "types": "./dist/inbound/ses.d.mts", - "default": "./dist/inbound/ses.mjs" - }, - "./queue/bullmq": { - "types": "./dist/queue/bullmq.d.mts", - "default": "./dist/queue/bullmq.mjs" - }, - "./queue/pg-boss": { - "types": "./dist/queue/pg-boss.d.mts", - "default": "./dist/queue/pg-boss.mjs" - }, - "./queue/sqs": { - "types": "./dist/queue/sqs.d.mts", - "default": "./dist/queue/sqs.mjs" - } + "./package.json": "./package.json" }, "scripts": { "build": "obuild", @@ -329,28 +91,36 @@ "lint": "oxlint . && oxfmt --check .", "lint:fix": "oxlint . --fix && oxfmt .", "fmt": "oxfmt .", - "test": "pnpm lint && pnpm typecheck && vitest run", "typecheck": "tsgo --noEmit", + "test": "vitest run", + "check": "bun run lint && bun run typecheck && bun run test && bun run check:version", + "check:version": "node scripts/check-version.mjs", "bundle-budget": "node scripts/bundle-budget.mjs", - "playground": "cd playground && pnpm install && pnpm dev", - "attw": "pnpm dlx @arethetypeswrong/cli --pack . --profile esm-only", - "jsr:check": "pnpm dlx jsr publish --dry-run --allow-dirty", - "release": "pnpm test && pnpm build && pnpm bundle-budget && pnpm attw && pnpm jsr:check && bumpp --commit --tag --push --all", - "prepack": "pnpm build" + "attw": "bunx --bun @arethetypeswrong/cli --pack . --profile esm-only", + "jsr:check": "bunx jsr publish --dry-run --allow-dirty", + "release": "bun run check && bun run build && bun run bundle-budget && bun run attw && bun run jsr:check && bumpp --commit --tag --push --all", + "prepack": "bun run build" }, "devDependencies": { "@types/node": "^26.0.0", - "@typescript/native-preview": "7.0.0-dev.20260619.1", + "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vitest/coverage-v8": "^4.1.9", - "bumpp": "^11.1.0", + "bumpp": "^12.2.2", "obuild": "^0.4.36", - "oxfmt": "^0.55.0", + "oxfmt": "^0.66.0", "oxlint": "^1.70.0", - "typescript": "^6.0.3", + "typescript": "^7.0.2", "vitest": "^4.1.9" }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + }, "engines": { "node": ">=20.11.1" - }, - "packageManager": "pnpm@11.8.0" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 0e5dc19..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,2016 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - '@types/node': - specifier: ^26.0.0 - version: 26.0.0 - '@typescript/native-preview': - specifier: 7.0.0-dev.20260619.1 - version: 7.0.0-dev.20260619.1 - '@vitest/coverage-v8': - specifier: ^4.1.9 - version: 4.1.9(vitest@4.1.9) - bumpp: - specifier: ^11.1.0 - version: 11.1.0 - obuild: - specifier: ^0.4.36 - version: 0.4.36(@typescript/native-preview@7.0.0-dev.20260619.1)(jiti@2.7.0)(magicast@0.5.3)(typescript@6.0.3) - oxfmt: - specifier: ^0.55.0 - version: 0.55.0 - oxlint: - specifier: ^1.70.0 - version: 1.70.0 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - vitest: - specifier: ^4.1.9 - version: 4.1.9(@types/node@26.0.0)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0)) - -packages: - - '@babel/generator@8.0.0-rc.6': - resolution: {integrity: sha512-6mIzgVK8DgEzvIapoQwhXTMnnkuE4STQmVv9H03i/tZ2ml8oev3TRvZJgTenK2Bsq0YWNtzOrFdTyNzCMFtjJQ==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@8.0.0': - resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@8.0.0-rc.6': - resolution: {integrity: sha512-nVJ+1JcCgntv8d78rRo++o2wuODT0Irknx2BF8Np4Ft2CRgjLqIs4qzSZ8b66yGbBdMWGmZBO9WEZv1hhNiSpg==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/helper-validator-identifier@8.0.2': - resolution: {integrity: sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/parser@8.0.0': - resolution: {integrity: sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==} - engines: {node: ^22.18.0 || >=24.11.0} - hasBin: true - - '@babel/parser@8.0.0-rc.6': - resolution: {integrity: sha512-rOS8IpdO7mQELkTPlCsTgPejO0bFuZdEDCGQJouYbYf9e1FLTym7Fei2pEjq8q7MWbX0ravcd7QQYKs1TxOuog==} - engines: {node: ^22.18.0 || >=24.11.0} - hasBin: true - - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} - - '@babel/types@8.0.0': - resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==} - engines: {node: ^22.18.0 || >=24.11.0} - - '@bcoe/v8-coverage@1.0.2': - resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} - engines: {node: '>=18'} - - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@oxc-project/types@0.133.0': - resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - - '@oxc-project/types@0.137.0': - resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} - - '@oxfmt/binding-android-arm-eabi@0.55.0': - resolution: {integrity: sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxfmt/binding-android-arm64@0.55.0': - resolution: {integrity: sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxfmt/binding-darwin-arm64@0.55.0': - resolution: {integrity: sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxfmt/binding-darwin-x64@0.55.0': - resolution: {integrity: sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxfmt/binding-freebsd-x64@0.55.0': - resolution: {integrity: sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxfmt/binding-linux-arm-gnueabihf@0.55.0': - resolution: {integrity: sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm-musleabihf@0.55.0': - resolution: {integrity: sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm64-gnu@0.55.0': - resolution: {integrity: sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-arm64-musl@0.55.0': - resolution: {integrity: sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-ppc64-gnu@0.55.0': - resolution: {integrity: sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-riscv64-gnu@0.55.0': - resolution: {integrity: sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-riscv64-musl@0.55.0': - resolution: {integrity: sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-s390x-gnu@0.55.0': - resolution: {integrity: sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-x64-gnu@0.55.0': - resolution: {integrity: sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-x64-musl@0.55.0': - resolution: {integrity: sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-openharmony-arm64@0.55.0': - resolution: {integrity: sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxfmt/binding-win32-arm64-msvc@0.55.0': - resolution: {integrity: sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxfmt/binding-win32-ia32-msvc@0.55.0': - resolution: {integrity: sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxfmt/binding-win32-x64-msvc@0.55.0': - resolution: {integrity: sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxlint/binding-android-arm-eabi@1.70.0': - resolution: {integrity: sha512-zFh0P4cswmRvw6nkyb89dr18rRanuaCPAsEXsFDoQY8WdaquI8Pt4NWFjaMJg6L23cy5NeN8J9cBnREbWzZhaw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxlint/binding-android-arm64@1.70.0': - resolution: {integrity: sha512-qI8o4HZjeGiBrWv+pJv4lH0Yi2Gl/JSp/EumBUApezJprIKa5PS4nU0lQsQngtky8k+SplQIOjv6hwu0SSxeyg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxlint/binding-darwin-arm64@1.70.0': - resolution: {integrity: sha512-8KjgVVHI5F9nVwHCRwwA78Ty7zNKP4Wd9OeN5PSv3iu/F/u1RVXoOCgLhWqust6HmwQG6xc8c+RCyaWENy24+w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxlint/binding-darwin-x64@1.70.0': - resolution: {integrity: sha512-WVydssv5PSUBXFJTdNBWlmGkbNmvPGaFt/2SUT/EZRB6bq6bEOHmMlbnupZD5jmlEvi9+mZJHi8TCw15lyfSfQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxlint/binding-freebsd-x64@1.70.0': - resolution: {integrity: sha512-hJucmUf8OlinHNb1R7fI4Fw6WsAstOz7i8nmkWQfiHoZXtbufNm+MxiDTIMk1ggh2Ro4vLzgQ+bKvRY54MZoRA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxlint/binding-linux-arm-gnueabihf@1.70.0': - resolution: {integrity: sha512-1BnS7wbCYDSXwWzJJ+mc3NURoha6m6m6RT5c6vgAY3oz7C3OVXP+S0awo2mRq97arrJkVvO3qRQfyAHL+76xtQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm-musleabihf@1.70.0': - resolution: {integrity: sha512-yKy/UdbR55+M2yEcuiV5DCNC/gdQAjr/GioUy50QwBzSrKm8ueWADqyRLS9Xk+qjNeCYGg6A8FvUBds56ttfqg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm64-gnu@1.70.0': - resolution: {integrity: sha512-0A5XJ4alvmqFUFP/4oYSyaO+qLto/HrKEWTSaegiVl+HOufFngK2BjYw9x4RbwBt/du5QG6l5q1zeWiJYYG5yg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-arm64-musl@1.70.0': - resolution: {integrity: sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-ppc64-gnu@1.70.0': - resolution: {integrity: sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-gnu@1.70.0': - resolution: {integrity: sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-musl@1.70.0': - resolution: {integrity: sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-s390x-gnu@1.70.0': - resolution: {integrity: sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-gnu@1.70.0': - resolution: {integrity: sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-musl@1.70.0': - resolution: {integrity: sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxlint/binding-openharmony-arm64@1.70.0': - resolution: {integrity: sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxlint/binding-win32-arm64-msvc@1.70.0': - resolution: {integrity: sha512-icAEsUI7JbW1TMRdEXV83mVAInhRVQYuuAlPpxdGwJ95chNdnCzjloRW8GglT0WvzOEZSio6fnYSk2DJ2Hv7LQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxlint/binding-win32-ia32-msvc@1.70.0': - resolution: {integrity: sha512-FHMSWbVsPVs/f+Jcl04ws4JJ2wUnauyTzlpxWRG/lSO/8GpX08Fo2gQZqdA6CrRFI+zvkxl+N/KwJGWfUwYVZA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxlint/binding-win32-x64-msvc@1.70.0': - resolution: {integrity: sha512-ptOlKwCz7n4AKs5VweMqG6DAg677FmKOK+vBkkL9DMNgFATIQ+upqUYBTOEwRQyRAx1ncGlPlXleV2hIcm3z4g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@quansync/fs@1.0.0': - resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - - '@rolldown/binding-android-arm64@1.0.3': - resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-android-arm64@1.1.2': - resolution: {integrity: sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.3': - resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-arm64@1.1.2': - resolution: {integrity: sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.3': - resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.1.2': - resolution: {integrity: sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.3': - resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-freebsd-x64@1.1.2': - resolution: {integrity: sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': - resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm-gnueabihf@1.1.2': - resolution: {integrity: sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.3': - resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-gnu@1.1.2': - resolution: {integrity: sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.0.3': - resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-arm64-musl@1.1.2': - resolution: {integrity: sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.0.3': - resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-ppc64-gnu@1.1.2': - resolution: {integrity: sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.0.3': - resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.1.2': - resolution: {integrity: sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.0.3': - resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.1.2': - resolution: {integrity: sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.0.3': - resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-x64-musl@1.1.2': - resolution: {integrity: sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.0.3': - resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-openharmony-arm64@1.1.2': - resolution: {integrity: sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.3': - resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-wasm32-wasi@1.1.2': - resolution: {integrity: sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.3': - resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-arm64-msvc@1.1.2': - resolution: {integrity: sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.3': - resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.1.2': - resolution: {integrity: sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/jsesc@2.5.1': - resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} - - '@types/node@26.0.0': - resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} - - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260619.1': - resolution: {integrity: sha512-i07qdTFQEBYotjg/Iy0GAfARSFyKAjXmP5BNPo6+QXGOp5hJNnmXSxGyhIlooqbDXluTQRhESEmzVAuvYryKCA==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [darwin] - - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260619.1': - resolution: {integrity: sha512-WVetqm9ypzLv+b/8+SRYuO6bJDC4AaQmT/1EcYh8iP6y5vZl3h13iscJ+45eHc4Y3yMyoDHOivfOTyTIMH2Vrw==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [darwin] - - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260619.1': - resolution: {integrity: sha512-peEnXecjZsnsW24GutRasmZlIgRLbyXPoR0inMkzXT1h7/+Ns5tM8RPnuxelXoC10dg4Ajo8I9BlUAgX45LwEQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [linux] - - '@typescript/native-preview-linux-arm@7.0.0-dev.20260619.1': - resolution: {integrity: sha512-mqUzZ9htNWH0su9+A5QqnHJ25hQ+E8Cq5qOOmfdQjiCQnPT69HQZ9MiUozQc1qk0i9/UaXouYDK48Pe0oUvUOw==} - engines: {node: '>=16.20.0'} - cpu: [arm] - os: [linux] - - '@typescript/native-preview-linux-x64@7.0.0-dev.20260619.1': - resolution: {integrity: sha512-vZhIHvtpQE4ZICMIzIrOVOl/YYOLJjA91CsJMyetNNzvuQCCtll7CWUsS6Sp1IdRqJdwM6seJrdDEAtWkEMFkA==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [linux] - - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260619.1': - resolution: {integrity: sha512-pRdZDV9Q3IzBKOi5WrIaEnlkVLB0w3zVj2/BliPV/oQ8r8N+4LmSCuyy8PbL5bMw8SznTlmiIfv5gvoEx2SpEw==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [win32] - - '@typescript/native-preview-win32-x64@7.0.0-dev.20260619.1': - resolution: {integrity: sha512-sOvPE+lA/+SU/cnva+MXA1oq70HO0lLajsj9ZA10T1CN1PUGgaC1Zw7V0objTJsZ9ri6I3Ldp+Fh4Onz3mLDXw==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [win32] - - '@typescript/native-preview@7.0.0-dev.20260619.1': - resolution: {integrity: sha512-YDmSiD6l+nikPoqrwDKn9wkCFUDFJCfkKcOKrYVycX5plwu6H03JWKyW1h1HjEfrMVw4Mq/Qk12VTsXJWP4TDQ==} - engines: {node: '>=16.20.0'} - hasBin: true - - '@vitest/coverage-v8@4.1.9': - resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} - peerDependencies: - '@vitest/browser': 4.1.9 - vitest: 4.1.9 - peerDependenciesMeta: - '@vitest/browser': - optional: true - - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} - - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} - - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} - - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} - - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} - - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} - - args-tokenizer@0.3.0: - resolution: {integrity: sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - ast-kit@3.0.0: - resolution: {integrity: sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==} - engines: {node: ^22.18.0 || >=24.11.0} - - ast-v8-to-istanbul@1.0.4: - resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} - - birpc@4.0.0: - resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} - - bumpp@11.1.0: - resolution: {integrity: sha512-jdwOGMyX8JIqpQ0N2RMRR87DHZaoJnUtui5lU9LqFfFK5JC0H8qY9uWqXoa+dEWt/K7rOmmsoyiZB8RBM7RPBQ==} - engines: {node: '>=20.19.0'} - hasBin: true - - c12@4.0.0-beta.5: - resolution: {integrity: sha512-yWGCPCQGJeFq4R0mFg5HOhC3Rg+B0PCdM+ldXWUhughoGgeeq8/tjRmXh4/lmhKWyhf+KOFxB/JMXf0Yv1Fd5A==} - peerDependencies: - chokidar: ^5 - dotenv: '*' - giget: '*' - jiti: '*' - magicast: '*' - peerDependenciesMeta: - chokidar: - optional: true - dotenv: - optional: true - giget: - optional: true - jiti: - optional: true - magicast: - optional: true - - cac@7.0.0: - resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} - engines: {node: '>=20.19.0'} - - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - confbox@0.2.4: - resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - defu@6.1.7: - resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - dts-resolver@3.0.0: - resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} - engines: {node: ^22.18.0 || >=24.0.0} - peerDependencies: - oxc-resolver: '>=11.0.0' - peerDependenciesMeta: - oxc-resolver: - optional: true - - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - exsolve@1.0.8: - resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - get-tsconfig@5.0.0-beta.5: - resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} - engines: {node: '>=20.20.0'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - jiti@2.7.0: - resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} - hasBin: true - - js-tokens@10.0.0: - resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - jsonc-parser@3.3.1: - resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - magicast@0.5.3: - resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - nanoid@3.3.13: - resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - obug@2.1.3: - resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} - engines: {node: '>=12.20.0'} - - obuild@0.4.36: - resolution: {integrity: sha512-1kenNV3u2Xo+hsP6RSugfmQWG9sspawGJMQ3zRG1ZcE+vaDCfiWYCR5J9O7ZRRu6t/b82nyDxbHo817ZI8OeJg==} - hasBin: true - - oxfmt@0.55.0: - resolution: {integrity: sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - svelte: ^5.0.0 - vite-plus: '*' - peerDependenciesMeta: - svelte: - optional: true - vite-plus: - optional: true - - oxlint@1.70.0: - resolution: {integrity: sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - oxlint-tsgolint: '>=0.22.1' - vite-plus: '*' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - vite-plus: - optional: true - - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - pkg-types@2.3.1: - resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - - quansync@1.0.0: - resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} - - rc9@3.0.1: - resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - rolldown-plugin-dts@0.25.2: - resolution: {integrity: sha512-nMhN/R+vmR8GM45ZW1FWMSjRTSDDn/6w4GTf8RNrEFCBdl8B1kySWrU1ixPtbwzXoRlcO+R/S88VgXuJQwfdDg==} - engines: {node: ^22.18.0 || >=24.0.0} - peerDependencies: - '@ts-macro/tsc': ^0.3.6 - '@typescript/native-preview': '>=7.0.0-dev.20260325.1' - rolldown: ^1.0.0 - typescript: ^5.0.0 || ^6.0.0 - vue-tsc: ~3.2.0 - peerDependenciesMeta: - '@ts-macro/tsc': - optional: true - '@typescript/native-preview': - optional: true - typescript: - optional: true - vue-tsc: - optional: true - - rolldown@1.0.3: - resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - rolldown@1.1.2: - resolution: {integrity: sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} - engines: {node: '>=10'} - hasBin: true - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} - engines: {node: '>=18'} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} - engines: {node: ^20.0.0 || >=22.0.0} - - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - - unconfig-core@7.5.0: - resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} - - unconfig@7.5.0: - resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} - - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - - vite@8.0.16: - resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.18 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.1.9: - resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-preview': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 - '@vitest/coverage-istanbul': 4.1.9 - '@vitest/coverage-v8': 4.1.9 - '@vitest/ui': 4.1.9 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} - engines: {node: '>= 14.6'} - hasBin: true - -snapshots: - - '@babel/generator@8.0.0-rc.6': - dependencies: - '@babel/parser': 8.0.0-rc.6 - '@babel/types': 8.0.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@types/jsesc': 2.5.1 - jsesc: 3.1.0 - - '@babel/helper-string-parser@7.29.7': {} - - '@babel/helper-string-parser@8.0.0': {} - - '@babel/helper-validator-identifier@7.29.7': {} - - '@babel/helper-validator-identifier@8.0.0-rc.6': {} - - '@babel/helper-validator-identifier@8.0.2': {} - - '@babel/parser@7.29.7': - dependencies: - '@babel/types': 7.29.7 - - '@babel/parser@8.0.0': - dependencies: - '@babel/types': 8.0.0 - - '@babel/parser@8.0.0-rc.6': - dependencies: - '@babel/types': 8.0.0 - - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - - '@babel/types@8.0.0': - dependencies: - '@babel/helper-string-parser': 8.0.0 - '@babel/helper-validator-identifier': 8.0.2 - - '@bcoe/v8-coverage@1.0.2': {} - - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true - - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.2 - optional: true - - '@oxc-project/types@0.133.0': {} - - '@oxc-project/types@0.137.0': {} - - '@oxfmt/binding-android-arm-eabi@0.55.0': - optional: true - - '@oxfmt/binding-android-arm64@0.55.0': - optional: true - - '@oxfmt/binding-darwin-arm64@0.55.0': - optional: true - - '@oxfmt/binding-darwin-x64@0.55.0': - optional: true - - '@oxfmt/binding-freebsd-x64@0.55.0': - optional: true - - '@oxfmt/binding-linux-arm-gnueabihf@0.55.0': - optional: true - - '@oxfmt/binding-linux-arm-musleabihf@0.55.0': - optional: true - - '@oxfmt/binding-linux-arm64-gnu@0.55.0': - optional: true - - '@oxfmt/binding-linux-arm64-musl@0.55.0': - optional: true - - '@oxfmt/binding-linux-ppc64-gnu@0.55.0': - optional: true - - '@oxfmt/binding-linux-riscv64-gnu@0.55.0': - optional: true - - '@oxfmt/binding-linux-riscv64-musl@0.55.0': - optional: true - - '@oxfmt/binding-linux-s390x-gnu@0.55.0': - optional: true - - '@oxfmt/binding-linux-x64-gnu@0.55.0': - optional: true - - '@oxfmt/binding-linux-x64-musl@0.55.0': - optional: true - - '@oxfmt/binding-openharmony-arm64@0.55.0': - optional: true - - '@oxfmt/binding-win32-arm64-msvc@0.55.0': - optional: true - - '@oxfmt/binding-win32-ia32-msvc@0.55.0': - optional: true - - '@oxfmt/binding-win32-x64-msvc@0.55.0': - optional: true - - '@oxlint/binding-android-arm-eabi@1.70.0': - optional: true - - '@oxlint/binding-android-arm64@1.70.0': - optional: true - - '@oxlint/binding-darwin-arm64@1.70.0': - optional: true - - '@oxlint/binding-darwin-x64@1.70.0': - optional: true - - '@oxlint/binding-freebsd-x64@1.70.0': - optional: true - - '@oxlint/binding-linux-arm-gnueabihf@1.70.0': - optional: true - - '@oxlint/binding-linux-arm-musleabihf@1.70.0': - optional: true - - '@oxlint/binding-linux-arm64-gnu@1.70.0': - optional: true - - '@oxlint/binding-linux-arm64-musl@1.70.0': - optional: true - - '@oxlint/binding-linux-ppc64-gnu@1.70.0': - optional: true - - '@oxlint/binding-linux-riscv64-gnu@1.70.0': - optional: true - - '@oxlint/binding-linux-riscv64-musl@1.70.0': - optional: true - - '@oxlint/binding-linux-s390x-gnu@1.70.0': - optional: true - - '@oxlint/binding-linux-x64-gnu@1.70.0': - optional: true - - '@oxlint/binding-linux-x64-musl@1.70.0': - optional: true - - '@oxlint/binding-openharmony-arm64@1.70.0': - optional: true - - '@oxlint/binding-win32-arm64-msvc@1.70.0': - optional: true - - '@oxlint/binding-win32-ia32-msvc@1.70.0': - optional: true - - '@oxlint/binding-win32-x64-msvc@1.70.0': - optional: true - - '@quansync/fs@1.0.0': - dependencies: - quansync: 1.0.0 - - '@rolldown/binding-android-arm64@1.0.3': - optional: true - - '@rolldown/binding-android-arm64@1.1.2': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.3': - optional: true - - '@rolldown/binding-darwin-arm64@1.1.2': - optional: true - - '@rolldown/binding-darwin-x64@1.0.3': - optional: true - - '@rolldown/binding-darwin-x64@1.1.2': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.3': - optional: true - - '@rolldown/binding-freebsd-x64@1.1.2': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.1.2': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.1.2': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.3': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.1.2': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.1.2': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.1.2': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.3': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.1.2': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.3': - optional: true - - '@rolldown/binding-linux-x64-musl@1.1.2': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.3': - optional: true - - '@rolldown/binding-openharmony-arm64@1.1.2': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.3': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - - '@rolldown/binding-wasm32-wasi@1.1.2': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.3': - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.1.2': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.3': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.1.2': - optional: true - - '@rolldown/pluginutils@1.0.1': {} - - '@standard-schema/spec@1.1.0': {} - - '@tybys/wasm-util@0.10.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/estree@1.0.9': {} - - '@types/jsesc@2.5.1': {} - - '@types/node@26.0.0': - dependencies: - undici-types: 8.3.0 - - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260619.1': - optional: true - - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260619.1': - optional: true - - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260619.1': - optional: true - - '@typescript/native-preview-linux-arm@7.0.0-dev.20260619.1': - optional: true - - '@typescript/native-preview-linux-x64@7.0.0-dev.20260619.1': - optional: true - - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260619.1': - optional: true - - '@typescript/native-preview-win32-x64@7.0.0-dev.20260619.1': - optional: true - - '@typescript/native-preview@7.0.0-dev.20260619.1': - optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260619.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260619.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260619.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260619.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260619.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260619.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260619.1 - - '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.9 - ast-v8-to-istanbul: 1.0.4 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.3 - obug: 2.1.3 - std-env: 4.1.0 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@26.0.0)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0)) - - '@vitest/expect@4.1.9': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.16(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0) - - '@vitest/pretty-format@4.1.9': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.9': - dependencies: - '@vitest/utils': 4.1.9 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.9': - dependencies: - '@vitest/pretty-format': 4.1.9 - '@vitest/utils': 4.1.9 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.9': {} - - '@vitest/utils@4.1.9': - dependencies: - '@vitest/pretty-format': 4.1.9 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - - args-tokenizer@0.3.0: {} - - assertion-error@2.0.1: {} - - ast-kit@3.0.0: - dependencies: - '@babel/parser': 8.0.0 - estree-walker: 3.0.3 - pathe: 2.0.3 - - ast-v8-to-istanbul@1.0.4: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - estree-walker: 3.0.3 - js-tokens: 10.0.0 - - birpc@4.0.0: {} - - bumpp@11.1.0: - dependencies: - args-tokenizer: 0.3.0 - cac: 7.0.0 - jsonc-parser: 3.3.1 - package-manager-detector: 1.6.0 - semver: 7.8.4 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - unconfig: 7.5.0 - yaml: 2.9.0 - - c12@4.0.0-beta.5(jiti@2.7.0)(magicast@0.5.3): - dependencies: - confbox: 0.2.4 - defu: 6.1.7 - exsolve: 1.0.8 - pathe: 2.0.3 - pkg-types: 2.3.1 - rc9: 3.0.1 - optionalDependencies: - jiti: 2.7.0 - magicast: 0.5.3 - - cac@7.0.0: {} - - chai@6.2.2: {} - - confbox@0.2.4: {} - - consola@3.4.2: {} - - convert-source-map@2.0.0: {} - - defu@6.1.7: {} - - destr@2.0.5: {} - - detect-libc@2.1.2: {} - - dts-resolver@3.0.0: {} - - es-module-lexer@2.1.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - - expect-type@1.3.0: {} - - exsolve@1.0.8: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - fsevents@2.3.3: - optional: true - - get-tsconfig@5.0.0-beta.5: - dependencies: - resolve-pkg-maps: 1.0.0 - - has-flag@4.0.0: {} - - html-escaper@2.0.2: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - jiti@2.7.0: {} - - js-tokens@10.0.0: {} - - jsesc@3.1.0: {} - - jsonc-parser@3.3.1: {} - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - magicast@0.5.3: - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - source-map-js: 1.2.1 - - make-dir@4.0.0: - dependencies: - semver: 7.8.4 - - nanoid@3.3.13: {} - - obug@2.1.3: {} - - obuild@0.4.36(@typescript/native-preview@7.0.0-dev.20260619.1)(jiti@2.7.0)(magicast@0.5.3)(typescript@6.0.3): - dependencies: - c12: 4.0.0-beta.5(jiti@2.7.0)(magicast@0.5.3) - consola: 3.4.2 - defu: 6.1.7 - exsolve: 1.0.8 - magic-string: 0.30.21 - pathe: 2.0.3 - rolldown: 1.1.2 - rolldown-plugin-dts: 0.25.2(@typescript/native-preview@7.0.0-dev.20260619.1)(rolldown@1.1.2)(typescript@6.0.3) - tinyglobby: 0.2.17 - transitivePeerDependencies: - - '@ts-macro/tsc' - - '@typescript/native-preview' - - chokidar - - dotenv - - giget - - jiti - - magicast - - oxc-resolver - - typescript - - vue-tsc - - oxfmt@0.55.0: - dependencies: - tinypool: 2.1.0 - optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.55.0 - '@oxfmt/binding-android-arm64': 0.55.0 - '@oxfmt/binding-darwin-arm64': 0.55.0 - '@oxfmt/binding-darwin-x64': 0.55.0 - '@oxfmt/binding-freebsd-x64': 0.55.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.55.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.55.0 - '@oxfmt/binding-linux-arm64-gnu': 0.55.0 - '@oxfmt/binding-linux-arm64-musl': 0.55.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.55.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.55.0 - '@oxfmt/binding-linux-riscv64-musl': 0.55.0 - '@oxfmt/binding-linux-s390x-gnu': 0.55.0 - '@oxfmt/binding-linux-x64-gnu': 0.55.0 - '@oxfmt/binding-linux-x64-musl': 0.55.0 - '@oxfmt/binding-openharmony-arm64': 0.55.0 - '@oxfmt/binding-win32-arm64-msvc': 0.55.0 - '@oxfmt/binding-win32-ia32-msvc': 0.55.0 - '@oxfmt/binding-win32-x64-msvc': 0.55.0 - - oxlint@1.70.0: - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.70.0 - '@oxlint/binding-android-arm64': 1.70.0 - '@oxlint/binding-darwin-arm64': 1.70.0 - '@oxlint/binding-darwin-x64': 1.70.0 - '@oxlint/binding-freebsd-x64': 1.70.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.70.0 - '@oxlint/binding-linux-arm-musleabihf': 1.70.0 - '@oxlint/binding-linux-arm64-gnu': 1.70.0 - '@oxlint/binding-linux-arm64-musl': 1.70.0 - '@oxlint/binding-linux-ppc64-gnu': 1.70.0 - '@oxlint/binding-linux-riscv64-gnu': 1.70.0 - '@oxlint/binding-linux-riscv64-musl': 1.70.0 - '@oxlint/binding-linux-s390x-gnu': 1.70.0 - '@oxlint/binding-linux-x64-gnu': 1.70.0 - '@oxlint/binding-linux-x64-musl': 1.70.0 - '@oxlint/binding-openharmony-arm64': 1.70.0 - '@oxlint/binding-win32-arm64-msvc': 1.70.0 - '@oxlint/binding-win32-ia32-msvc': 1.70.0 - '@oxlint/binding-win32-x64-msvc': 1.70.0 - - package-manager-detector@1.6.0: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - pkg-types@2.3.1: - dependencies: - confbox: 0.2.4 - exsolve: 1.0.8 - pathe: 2.0.3 - - postcss@8.5.15: - dependencies: - nanoid: 3.3.13 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - quansync@1.0.0: {} - - rc9@3.0.1: - dependencies: - defu: 6.1.7 - destr: 2.0.5 - - resolve-pkg-maps@1.0.0: {} - - rolldown-plugin-dts@0.25.2(@typescript/native-preview@7.0.0-dev.20260619.1)(rolldown@1.1.2)(typescript@6.0.3): - dependencies: - '@babel/generator': 8.0.0-rc.6 - '@babel/helper-validator-identifier': 8.0.0-rc.6 - '@babel/parser': 8.0.0-rc.6 - ast-kit: 3.0.0 - birpc: 4.0.0 - dts-resolver: 3.0.0 - get-tsconfig: 5.0.0-beta.5 - obug: 2.1.3 - rolldown: 1.1.2 - optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260619.1 - typescript: 6.0.3 - transitivePeerDependencies: - - oxc-resolver - - rolldown@1.0.3: - dependencies: - '@oxc-project/types': 0.133.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.3 - '@rolldown/binding-darwin-arm64': 1.0.3 - '@rolldown/binding-darwin-x64': 1.0.3 - '@rolldown/binding-freebsd-x64': 1.0.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 - '@rolldown/binding-linux-arm64-gnu': 1.0.3 - '@rolldown/binding-linux-arm64-musl': 1.0.3 - '@rolldown/binding-linux-ppc64-gnu': 1.0.3 - '@rolldown/binding-linux-s390x-gnu': 1.0.3 - '@rolldown/binding-linux-x64-gnu': 1.0.3 - '@rolldown/binding-linux-x64-musl': 1.0.3 - '@rolldown/binding-openharmony-arm64': 1.0.3 - '@rolldown/binding-wasm32-wasi': 1.0.3 - '@rolldown/binding-win32-arm64-msvc': 1.0.3 - '@rolldown/binding-win32-x64-msvc': 1.0.3 - - rolldown@1.1.2: - dependencies: - '@oxc-project/types': 0.137.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.2 - '@rolldown/binding-darwin-arm64': 1.1.2 - '@rolldown/binding-darwin-x64': 1.1.2 - '@rolldown/binding-freebsd-x64': 1.1.2 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.2 - '@rolldown/binding-linux-arm64-gnu': 1.1.2 - '@rolldown/binding-linux-arm64-musl': 1.1.2 - '@rolldown/binding-linux-ppc64-gnu': 1.1.2 - '@rolldown/binding-linux-s390x-gnu': 1.1.2 - '@rolldown/binding-linux-x64-gnu': 1.1.2 - '@rolldown/binding-linux-x64-musl': 1.1.2 - '@rolldown/binding-openharmony-arm64': 1.1.2 - '@rolldown/binding-wasm32-wasi': 1.1.2 - '@rolldown/binding-win32-arm64-msvc': 1.1.2 - '@rolldown/binding-win32-x64-msvc': 1.1.2 - - semver@7.8.4: {} - - siginfo@2.0.0: {} - - source-map-js@1.2.1: {} - - stackback@0.0.2: {} - - std-env@4.1.0: {} - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - tinybench@2.9.0: {} - - tinyexec@1.2.4: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tinypool@2.1.0: {} - - tinyrainbow@3.1.0: {} - - tslib@2.8.1: - optional: true - - typescript@6.0.3: {} - - unconfig-core@7.5.0: - dependencies: - '@quansync/fs': 1.0.0 - quansync: 1.0.0 - - unconfig@7.5.0: - dependencies: - '@quansync/fs': 1.0.0 - defu: 6.1.7 - jiti: 2.7.0 - quansync: 1.0.0 - unconfig-core: 7.5.0 - - undici-types@8.3.0: {} - - vite@8.0.16(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.3 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 26.0.0 - fsevents: 2.3.3 - jiti: 2.7.0 - yaml: 2.9.0 - - vitest@4.1.9(@types/node@26.0.0)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.3 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 26.0.0 - '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) - transitivePeerDependencies: - - msw - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - yaml@2.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index a7580cb..0000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,10 +0,0 @@ -minimumReleaseAgeExclude: - - "@types/node@26.0.0" - - "@typescript/native-preview-darwin-arm64@7.0.0-dev.20260619.1" - - "@typescript/native-preview-darwin-x64@7.0.0-dev.20260619.1" - - "@typescript/native-preview-linux-arm64@7.0.0-dev.20260619.1" - - "@typescript/native-preview-linux-arm@7.0.0-dev.20260619.1" - - "@typescript/native-preview-linux-x64@7.0.0-dev.20260619.1" - - "@typescript/native-preview-win32-arm64@7.0.0-dev.20260619.1" - - "@typescript/native-preview-win32-x64@7.0.0-dev.20260619.1" - - "@typescript/native-preview@7.0.0-dev.20260619.1" diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index bf319cd..f0c5310 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -8,8 +8,11 @@ import { resolve } from "node:path" const DIST = resolve(process.cwd(), "dist") const BUDGETS = [ - { glob: /^index\.mjs$/, max: 8192, label: "core" }, - { glob: /^driver\/.+\.mjs$/, max: 16384, label: "driver" }, + { glob: /^index\.mjs$/, max: 4096, label: "core entry" }, + { glob: /^core\/.+\.mjs$/, max: 8192, label: "core" }, + { glob: /^drivers\/[^_].*\.mjs$/, max: 12288, label: "driver" }, + { glob: /^middleware\/.+\.mjs$/, max: 8192, label: "middleware" }, + { glob: /^render\/.+\.mjs$/, max: 8192, label: "render" }, ] async function* walk(dir, prefix = "") { diff --git a/scripts/check-version.mjs b/scripts/check-version.mjs new file mode 100644 index 0000000..7968ac6 --- /dev/null +++ b/scripts/check-version.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// The version lives in three places that must agree: npm's manifest, JSR's, +// and the `version` constant the library reports at runtime. Drift here is +// silent and only shows up in a user's bug report, so CI checks it. + +import { readFile } from "node:fs/promises" + +const read = async (path) => JSON.parse(await readFile(new URL(path, import.meta.url), "utf8")) + +const pkg = await read("../package.json") +const jsr = await read("../jsr.json") +const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8") + +const declared = /export const version = "([^"]+)"/.exec(source)?.[1] + +const found = { + "package.json": pkg.version, + "jsr.json": jsr.version, + "src/index.ts": declared, +} + +const distinct = new Set(Object.values(found)) +if (distinct.size === 1 && !distinct.has(undefined)) { + console.log( + `✅ version ${pkg.version} is consistent across package.json, jsr.json and src/index.ts`, + ) + process.exit(0) +} + +console.error("❌ version mismatch:") +for (const [file, version] of Object.entries(found)) { + console.error(` ${file.padEnd(16)} ${version ?? "(not found)"}`) +} +process.exit(1) diff --git a/scripts/setup-mailcrab.mjs b/scripts/setup-mailcrab.mjs deleted file mode 100755 index a6f46ac..0000000 --- a/scripts/setup-mailcrab.mjs +++ /dev/null @@ -1,360 +0,0 @@ -#!/usr/bin/env node - -/** - * This script helps set up and start MailCrab for local development - * It checks if Docker is available and then starts MailCrab - */ - -import { exec, spawn } from "node:child_process" -import * as readline from "node:readline" - -const MAILCRAB_PORT_SMTP = 1025 -const MAILCRAB_PORT_UI = 1080 -const DOCKER_IMAGE = "marlonb/mailcrab" - -// Colors for console output -const colors = { - reset: "\x1B[0m", - bright: "\x1B[1m", - red: "\x1B[31m", - green: "\x1B[32m", - yellow: "\x1B[33m", - blue: "\x1B[34m", - cyan: "\x1B[36m", -} - -// Print banner -console.log(`${colors.bright}${colors.blue} - _ _ _ _ -| | | | (_) | -| | | |_ __ ___ _ __ ___ __ _| | -| | | | '_ \\ / _ \\ '_ \` _ \\/ _\` | | -| |_| | | | | __/ | | | | | (_| | | - \\___/|_| |_|\\___|_| |_| |_|\\__,_|_| - -${colors.cyan}MailCrab Setup Tool${colors.reset} -`) - -// Check if Docker is installed -function checkDocker() { - return new Promise((resolve, reject) => { - console.log(`${colors.yellow}Checking if Docker is installed...${colors.reset}`) - - exec("docker --version", (error, stdout) => { - if (error) { - console.log(`${colors.red}❌ Docker is not installed or not in PATH${colors.reset}`) - console.log( - `${colors.yellow}Please install Docker from https://www.docker.com/get-started${colors.reset}`, - ) - reject(new Error("Docker not found")) - return - } - - console.log(`${colors.green}✅ Docker is installed: ${stdout.trim()}${colors.reset}`) - resolve() - }) - }) -} - -// Check if ports are available -function checkPorts() { - return new Promise((resolve, reject) => { - console.log( - `${colors.yellow}Checking if ports ${MAILCRAB_PORT_SMTP} and ${MAILCRAB_PORT_UI} are available...${colors.reset}`, - ) - - const netstat = process.platform === "win32" ? "netstat -ano | findstr" : "lsof -i" - - exec(`${netstat} :${MAILCRAB_PORT_SMTP}`, (error, stdout) => { - const smtpInUse = !error && stdout.trim() !== "" - - exec(`${netstat} :${MAILCRAB_PORT_UI}`, (error, stdout) => { - const uiInUse = !error && stdout.trim() !== "" - - if (smtpInUse || uiInUse) { - const portsInUse = [] - if (smtpInUse) portsInUse.push(MAILCRAB_PORT_SMTP) - if (uiInUse) portsInUse.push(MAILCRAB_PORT_UI) - - console.log( - `${colors.red}❌ Port(s) ${portsInUse.join(", ")} already in use${colors.reset}`, - ) - - const confirmChoice = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - confirmChoice.question( - `${colors.yellow}Do you want to continue anyway? (y/N): ${colors.reset}`, - (answer) => { - confirmChoice.close() - - if (answer.toLowerCase() === "y") { - console.log(`${colors.yellow}Continuing despite port conflicts...${colors.reset}`) - resolve() - } else { - reject(new Error("Ports in use")) - } - }, - ) - } else { - console.log(`${colors.green}✅ Ports are available${colors.reset}`) - resolve() - } - }) - }) - }) -} - -// Check if MailCrab image is pulled -function checkMailCrabImage() { - return new Promise((resolve, reject) => { - console.log(`${colors.yellow}Checking if MailCrab image is available...${colors.reset}`) - - exec(`docker images ${DOCKER_IMAGE} --format "{{.Repository}}"`, (error, stdout) => { - if (error || stdout.trim() === "") { - console.log(`${colors.yellow}MailCrab image not found, pulling now...${colors.reset}`) - - const pull = spawn("docker", ["pull", DOCKER_IMAGE], { stdio: "inherit" }) - - pull.on("close", (code) => { - if (code === 0) { - console.log(`${colors.green}✅ MailCrab image pulled successfully${colors.reset}`) - resolve() - } else { - console.log(`${colors.red}❌ Failed to pull MailCrab image${colors.reset}`) - reject(new Error("Failed to pull image")) - } - }) - } else { - console.log(`${colors.green}✅ MailCrab image found${colors.reset}`) - resolve() - } - }) - }) -} - -// Check if MailCrab is already running or exists -function checkExistingContainers() { - return new Promise((resolve, reject) => { - console.log(`${colors.yellow}Checking if MailCrab container exists...${colors.reset}`) - - // First check for running containers with the MailCrab image - exec(`docker ps --filter ancestor=${DOCKER_IMAGE} --format "{{.ID}}"`, (error, stdout) => { - if (error) { - reject(new Error("Failed to check running containers")) - return - } - - const runningContainerId = stdout.trim() - - if (runningContainerId) { - console.log( - `${colors.yellow}MailCrab is already running with container ID: ${runningContainerId}${colors.reset}`, - ) - - const confirmChoice = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - confirmChoice.question( - `${colors.yellow}Do you want to stop it and start a new instance? (y/N): ${colors.reset}`, - (answer) => { - confirmChoice.close() - - if (answer.toLowerCase() === "y") { - exec(`docker stop ${runningContainerId}`, (error) => { - if (error) { - console.log( - `${colors.red}❌ Failed to stop MailCrab container: ${error.message}${colors.reset}`, - ) - reject(error) - return - } - - console.log(`${colors.green}✅ Stopped existing MailCrab container${colors.reset}`) - resolve({ action: "create-new" }) - }) - } else { - console.log(`${colors.green}✅ Using existing MailCrab container${colors.reset}`) - resolve({ action: "use-existing" }) - } - }, - ) - } else { - // No running container, check for stopped container with the name - exec('docker ps -a --filter name=unemail-mailcrab --format "{{.ID}}"', (error, stdout) => { - if (error) { - reject(new Error("Failed to check for stopped containers")) - return - } - - const stoppedContainerId = stdout.trim() - - if (stoppedContainerId) { - console.log( - `${colors.yellow}Found stopped MailCrab container with ID: ${stoppedContainerId}${colors.reset}`, - ) - - const confirmChoice = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - confirmChoice.question( - `${colors.yellow}Do you want to (s)tart the existing container, (r)emove it and create a new one, or (c)ancel? (s/r/c): ${colors.reset}`, - (answer) => { - confirmChoice.close() - - if (answer.toLowerCase() === "s") { - console.log( - `${colors.yellow}Starting existing MailCrab container...${colors.reset}`, - ) - - exec(`docker start ${stoppedContainerId}`, (error) => { - if (error) { - console.log( - `${colors.red}❌ Failed to start existing MailCrab container: ${error.message}${colors.reset}`, - ) - reject(error) - return - } - - console.log( - `${colors.green}✅ Started existing MailCrab container${colors.reset}`, - ) - resolve({ action: "use-existing" }) - }) - } else if (answer.toLowerCase() === "r") { - console.log( - `${colors.yellow}Removing existing MailCrab container...${colors.reset}`, - ) - - exec(`docker rm ${stoppedContainerId}`, (error) => { - if (error) { - console.log( - `${colors.red}❌ Failed to remove existing MailCrab container: ${error.message}${colors.reset}`, - ) - reject(error) - return - } - - console.log( - `${colors.green}✅ Removed existing MailCrab container${colors.reset}`, - ) - resolve({ action: "create-new" }) - }) - } else { - console.log(`${colors.yellow}Operation cancelled${colors.reset}`) - reject(new Error("Operation cancelled")) - } - }, - ) - } else { - console.log(`${colors.green}✅ No MailCrab containers found${colors.reset}`) - resolve({ action: "create-new" }) - } - }) - } - }) - }) -} - -// Start MailCrab container -function startMailCrab() { - return new Promise((resolve, reject) => { - console.log(`${colors.yellow}Starting MailCrab container...${colors.reset}`) - - const docker = spawn("docker", [ - "run", - "-d", // Run in detached mode - "--name", - "unemail-mailcrab", - "-p", - `${MAILCRAB_PORT_SMTP}:1025`, - "-p", - `${MAILCRAB_PORT_UI}:1080`, - DOCKER_IMAGE, - ]) - - let output = "" - - docker.stdout.on("data", (data) => { - output += data.toString() - }) - - docker.on("data", (data) => { - console.log(`${colors.red}${data.toString()}${colors.reset}`) - }) - - docker.on("close", (code) => { - if (code === 0) { - console.log(`${colors.green}✅ MailCrab started successfully${colors.reset}`) - console.log(`${colors.green}✅ Container ID: ${output.trim()}${colors.reset}`) - resolve() - } else { - console.log(`${colors.red}❌ Failed to start MailCrab container${colors.reset}`) - reject(new Error("Failed to start container")) - } - }) - }) -} - -// Show usage instructions -function showInstructions() { - console.log(` -${colors.bright}${colors.green}MailCrab is ready for use!${colors.reset} - -${colors.bright}SMTP Server:${colors.reset} localhost:${MAILCRAB_PORT_SMTP} -${colors.bright}Web Interface:${colors.reset} http://localhost:${MAILCRAB_PORT_UI} - -${colors.bright}${colors.blue}Usage with unemail:${colors.reset} - -${colors.cyan}import { createEmailService } from 'unemail'; -import smtpProvider from 'unemail/providers/smtp'; - -const emailService = createEmailService({ - provider: smtpProvider({ - host: 'localhost', - port: ${MAILCRAB_PORT_SMTP} - }) -}); - -// Send a test email -emailService.sendEmail({ - from: { email: 'sender@example.com', name: 'Sender' }, - to: { email: 'recipient@example.com', name: 'Recipient' }, - subject: 'Test Email', - text: 'This is a test email sent via unemail using MailCrab' -});${colors.reset} - -${colors.yellow}View sent emails at:${colors.reset} http://localhost:${MAILCRAB_PORT_UI} - -${colors.yellow}To stop MailCrab:${colors.reset} docker stop unemail-mailcrab -${colors.yellow}To restart MailCrab:${colors.reset} docker start unemail-mailcrab -`) -} - -// Main function -async function main() { - try { - await checkDocker() - await checkPorts() - await checkMailCrabImage() - - const { action } = await checkExistingContainers() - if (action === "create-new") { - await startMailCrab() - } - - showInstructions() - } catch (error) { - console.log(`${colors.red}❌ Setup failed: ${error.message}${colors.reset}`) - process.exit(1) - } -} - -// Run the script -main() diff --git a/src/_define.ts b/src/_define.ts deleted file mode 100644 index 9da9185..0000000 --- a/src/_define.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { DriverFactory, EmailDriver } from "./types.ts" - -/** Identity helper used to declare a driver factory with full type - * inference. Exists purely for TypeScript — there is no runtime effect. - * - * ```ts - * export default defineDriver((opts) => ({ - * name: "my-driver", - * send(msg, ctx) { ... } - * })) - * ``` - */ -export function defineDriver( - factory: (options?: TOpts) => EmailDriver, -): DriverFactory { - return factory -} diff --git a/src/_idempotency.ts b/src/_idempotency.ts deleted file mode 100644 index 96992ee..0000000 --- a/src/_idempotency.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { EmailResult, IdempotencyStore } from "./types.ts" - -/** Default in-memory idempotency store with TTL eviction. - * - * Fine for single-instance servers and tests. For multi-process or - * serverless deployments, plug in an `unstorage`-backed store or a - * custom `IdempotencyStore` implementation. */ -export function memoryIdempotencyStore(defaultTtlSeconds = 3600): IdempotencyStore { - const store = new Map() - return { - get(key) { - const entry = store.get(key) - if (!entry) return null - if (entry.expiresAt <= Date.now()) { - store.delete(key) - return null - } - return entry.value - }, - set(key, value, ttlSeconds) { - const ttl = (ttlSeconds ?? defaultTtlSeconds) * 1000 - store.set(key, { value, expiresAt: Date.now() + ttl }) - }, - } -} diff --git a/src/_normalize.ts b/src/_normalize.ts deleted file mode 100644 index 8d13498..0000000 --- a/src/_normalize.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { EmailAddress, EmailAddressInput } from "./types.ts" - -/** - * Normalize any accepted address input to an array of `EmailAddress` — - * drivers should not re-implement this parsing. - * - * Accepts: - * - `"ada@acme.com"` - * - `"Ada Lovelace "` - * - `{ email, name? }` - * - arrays of the above (mixed) - */ -export function normalizeAddresses(input: EmailAddressInput | undefined): EmailAddress[] { - if (input == null) return [] - const list = Array.isArray(input) ? input : [input] - const out: EmailAddress[] = [] - for (const item of list) { - if (typeof item === "string") { - out.push(parseAddress(item)) - } else if (item && typeof item === "object" && "email" in item) { - out.push({ email: String(item.email), name: item.name }) - } - } - return out -} - -/** Parse `"Name "` or a bare `"email@x"` into an `EmailAddress`. */ -export function parseAddress(value: string): EmailAddress { - const match = /^\s*(.*?)\s*<([^>]+)>\s*$/.exec(value) - if (match) { - const name = match[1]?.replace(/^"|"$/g, "").trim() || undefined - return { email: match[2]!.trim(), name } - } - return { email: value.trim() } -} - -/** Format an `EmailAddress` back into its canonical header form. */ -export function formatAddress(addr: EmailAddress): string { - if (!addr.name) return addr.email - const needsQuote = /["(),:;<>@[\\\]]/.test(addr.name) - const name = needsQuote ? `"${addr.name.replace(/"/g, '\\"')}"` : addr.name - return `${name} <${addr.email}>` -} - -/** Basic RFC-5322-ish address syntax validator. Strict enough to catch - * typos but not so strict that it rejects RFC-valid edge cases. */ -export function isValidEmail(value: string): boolean { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) -} diff --git a/src/address.ts b/src/address.ts deleted file mode 100644 index b12af84..0000000 --- a/src/address.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Strictly-validated RFC 5322 / RFC 6532 (SMTPUTF8) address primitive. - * Use at system boundaries (user input, API payloads) to reject - * malformed addresses before they reach a driver. - * - * @module - */ - -import type { EmailAddress, EmailAddressInput, Result } from "./types.ts" -import { createError } from "./errors.ts" - -/** Opaque tag — callers can rely on `Address.parse` returning a - * validated instance instead of re-validating. */ -const VALIDATED: unique symbol = Symbol("unemail.Address") - -export interface Address extends EmailAddress { - readonly [VALIDATED]: true - readonly local: string - readonly domain: string - toString: () => string -} - -/** Validate + parse an input string or `EmailAddress`. Returns a - * `Result
` so callers can pattern-match instead of - * try/catch. SMTPUTF8 allowed when `smtpUtf8` is true (default). */ -export function parseAddress( - input: string | EmailAddress, - options: { smtpUtf8?: boolean } = {}, -): Result
{ - const smtpUtf8 = options.smtpUtf8 ?? true - let email: string - let name: string | undefined - if (typeof input === "string") { - const parsed = splitNameAddr(input) - email = parsed.email - name = parsed.name - } else { - email = input.email - name = input.name - } - email = email.trim() - if (!email) return err("empty address") - const at = email.lastIndexOf("@") - if (at <= 0 || at === email.length - 1) return err(`no local/domain: "${email}"`) - const local = email.slice(0, at) - const domain = email.slice(at + 1) - if (!isValidLocal(local, smtpUtf8)) return err(`invalid local-part: "${local}"`) - if (!isValidDomain(domain)) return err(`invalid domain: "${domain}"`) - const addr: Address = { - email, - name, - local, - domain, - [VALIDATED]: true, - toString: () => (name ? `"${escapeName(name)}" <${email}>` : email), - } - return { data: addr, error: null } -} - -/** Convenience: throw on failure. Use only when you're certain the - * input is validated elsewhere. */ -export function mustParseAddress( - input: string | EmailAddress, - options: { smtpUtf8?: boolean } = {}, -): Address { - const result = parseAddress(input, options) - if (result.error) throw result.error - return result.data -} - -/** Parse any of the shapes accepted by `EmailAddressInput` into an - * array of validated `Address`es, short-circuiting on the first - * failure. */ -export function parseAddresses( - input: EmailAddressInput, - options: { smtpUtf8?: boolean } = {}, -): Result { - const list = Array.isArray(input) ? input : [input] - const out: Address[] = [] - for (const item of list) { - const r = parseAddress(item as string | EmailAddress, options) - if (r.error) return r as Result - out.push(r.data) - } - return { data: out, error: null } -} - -function splitNameAddr(value: string): { email: string; name?: string } { - const match = /^\s*(?:"((?:[^"\\]|\\.)*)"|([^<]*?))\s*<([^>]+)>\s*$/.exec(value) - if (match) { - const name = (match[1] ?? match[2] ?? "").trim() - return { email: match[3]!.trim(), name: name || undefined } - } - return { email: value.trim() } -} - -function isValidLocal(local: string, smtpUtf8: boolean): boolean { - if (local.length === 0 || local.length > 64) return false - // Allow quoted-string form - if (local.startsWith('"') && local.endsWith('"')) return local.length >= 2 - const atom = smtpUtf8 - ? /^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~.\u0080-\u{10FFFF}]+$/u - : /^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~.]+$/ - if (!atom.test(local)) return false - if (local.startsWith(".") || local.endsWith(".") || local.includes("..")) return false - return true -} - -function isValidDomain(domain: string): boolean { - if (domain.length === 0 || domain.length > 253) return false - if (domain.startsWith("[") && domain.endsWith("]")) return domain.length > 2 - if (domain.includes("..") || domain.startsWith(".") || domain.endsWith(".")) return false - for (const label of domain.split(".")) { - if (label.length === 0 || label.length > 63) return false - if ( - !/^[A-Za-z0-9\u0080-\u{10FFFF}]([A-Za-z0-9\-\u0080-\u{10FFFF}]*[A-Za-z0-9\u0080-\u{10FFFF}])?$/u.test( - label, - ) - ) - return false - } - return true -} - -function escapeName(name: string): string { - return name.replace(/\\/g, "\\\\").replace(/"/g, '\\"') -} - -function err(message: string): Result { - return { data: null, error: createError("unemail", "INVALID_OPTIONS", message) } -} diff --git a/src/compliance/index.ts b/src/compliance/index.ts deleted file mode 100644 index fb1053b..0000000 --- a/src/compliance/index.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Deliverability and compliance helpers. Currently ships primitives for - * RFC 2369 / RFC 8058 List-Unsubscribe: signing one-click tokens and a - * framework-agnostic HTTP handler that verifies + dispatches to a - * suppression store. - * - * @module - */ - -import type { SuppressionStore } from "../suppression/index.ts" - -const encoder = /* @__PURE__ */ new TextEncoder() - -/** Base64url encode a byte array without padding. */ -function b64url(bytes: Uint8Array): string { - let s = "" - for (const b of bytes) s += String.fromCharCode(b) - return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") -} - -function b64urlDecode(s: string): Uint8Array { - const pad = s.length % 4 === 2 ? "==" : s.length % 4 === 3 ? "=" : "" - const std = s.replace(/-/g, "+").replace(/_/g, "/") + pad - const bin = atob(std) - const buf = new ArrayBuffer(bin.length) - const out = new Uint8Array(buf) - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) - return out -} - -async function hmacKey(secret: string): Promise { - return crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign", "verify"], - ) -} - -/** Opaque, tamper-proof token encoding the unsubscribe subject. */ -export interface UnsubscribeTokenPayload { - recipient: string - campaign?: string - /** Expiry as epoch seconds. Omit for non-expiring tokens. */ - exp?: number -} - -/** Sign a one-click unsubscribe token with HMAC-SHA256. */ -export async function signUnsubscribeToken( - payload: UnsubscribeTokenPayload, - secret: string, -): Promise { - const body = b64url(encoder.encode(JSON.stringify(payload))) - const key = await hmacKey(secret) - const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(body))) - return `${body}.${b64url(sig)}` -} - -/** Verify a token. Returns the payload on success, `null` on tamper / - * expiry. Constant-time via Web Crypto `verify`. */ -export async function verifyUnsubscribeToken( - token: string, - secret: string, - now: () => number = Date.now, -): Promise { - const dot = token.indexOf(".") - if (dot < 0) return null - const body = token.slice(0, dot) - const sig = token.slice(dot + 1) - const key = await hmacKey(secret) - const sigBytes = b64urlDecode(sig) - const bodyBytes = encoder.encode(body) - let ok: boolean - try { - ok = await crypto.subtle.verify( - "HMAC", - key, - sigBytes as BufferSource, - bodyBytes as BufferSource, - ) - } catch { - return null - } - if (!ok) return null - let payload: UnsubscribeTokenPayload - try { - payload = JSON.parse(new TextDecoder().decode(b64urlDecode(body))) as UnsubscribeTokenPayload - } catch { - return null - } - if (payload.exp !== undefined && now() / 1000 > payload.exp) return null - return payload -} - -/** Options for `defineUnsubscribeHandler`. */ -export interface UnsubscribeHandlerOptions { - secret: string - /** Query-string key that carries the token. Default: `t`. */ - tokenParam?: string - /** Suppression store receiving the opt-out. */ - store?: SuppressionStore - /** Optional hook fired after a successful unsubscribe. */ - onUnsubscribe?: (payload: UnsubscribeTokenPayload) => void | Promise - /** Custom clock for testing. */ - now?: () => number -} - -/** Framework-agnostic handler — give it a `Request`, it returns a - * `Response`. RFC 8058 requires 200 OK on POST with no user - * confirmation; we honor that. GET also works for mail-client URL - * rendering. */ -export function defineUnsubscribeHandler( - opts: UnsubscribeHandlerOptions, -): (request: Request) => Promise { - const param = opts.tokenParam ?? "t" - return async (request: Request) => { - const url = new URL(request.url) - const token = url.searchParams.get(param) - if (!token) return new Response("missing token", { status: 400 }) - const payload = await verifyUnsubscribeToken(token, opts.secret, opts.now) - if (!payload) return new Response("invalid token", { status: 400 }) - await opts.store?.add(payload.recipient, "unsubscribed", payload.campaign) - await opts.onUnsubscribe?.(payload) - return new Response("unsubscribed", { - status: 200, - headers: { "content-type": "text/plain; charset=utf-8" }, - }) - } -} diff --git a/src/core/address.ts b/src/core/address.ts new file mode 100644 index 0000000..ae189c1 --- /dev/null +++ b/src/core/address.ts @@ -0,0 +1,62 @@ +import type { AddressInput, EmailAddress } from "./types.ts" + +/** Parse `"Ada Lovelace "` or a bare `"ada@acme.com"`. */ +export function parseAddress(value: string): EmailAddress { + const match = /^\s*(.*?)\s*<([^>]+)>\s*$/.exec(value) + if (!match) return { email: value.trim() } + const name = match[1]?.replace(/^"|"$/g, "").trim() + return name ? { email: match[2]!.trim(), name } : { email: match[2]!.trim() } +} + +/** Render an address back into its canonical header form, quoting the + * display name when it contains a character RFC 5322 treats as a + * delimiter. */ +export function formatAddress(address: EmailAddress): string { + if (!address.name) return address.email + const needsQuote = /["(),:;<>@[\\\]]/.test(address.name) + const name = needsQuote ? `"${address.name.replace(/"/g, '\\"')}"` : address.name + return `${name} <${address.email}>` +} + +/** Join a list into one header value. */ +export function formatAddressList(addresses: readonly EmailAddress[]): string { + return addresses.map(formatAddress).join(", ") +} + +/** Flatten any accepted address input into a list. Unrecognized entries + * are dropped rather than throwing — validation happens once, in + * `normalizeMessage()`, where it can name the offending field. */ +export function toAddressList(input: AddressInput | undefined): EmailAddress[] { + if (input == null) return [] + const items = Array.isArray(input) ? input : [input as string | EmailAddress] + const out: EmailAddress[] = [] + for (const item of items) { + if (typeof item === "string") { + if (item.trim()) out.push(parseAddress(item)) + } else if (item && typeof item === "object" && typeof item.email === "string") { + out.push( + item.name ? { email: item.email.trim(), name: item.name } : { email: item.email.trim() }, + ) + } + } + return out +} + +/** Deduplicate by address, keeping the first occurrence's display name. */ +export function dedupeAddresses(addresses: readonly EmailAddress[]): EmailAddress[] { + const seen = new Set() + const out: EmailAddress[] = [] + for (const address of addresses) { + const key = address.email.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(address) + } + return out +} + +/** Loose syntax check — strict enough to catch a typo, lenient enough not + * to reject addresses that are legal but unusual. */ +export function isValidEmail(value: string): boolean { + return /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(value) +} diff --git a/src/core/define.ts b/src/core/define.ts new file mode 100644 index 0000000..c01767d --- /dev/null +++ b/src/core/define.ts @@ -0,0 +1,170 @@ +import type { + DriverFactory, + EmailDriver, + EmailResult, + Middleware, + NormalizedMessage, + Result, + SendContext, + SendHandler, +} from "./types.ts" +import { err, ok } from "./result.ts" +import { toEmailError } from "./error.ts" + +/** + * Declare a driver. Purely a typing helper — it makes `TOpts` required + * when it has required fields, so a missing API key is a compile error + * rather than a throw on the first send. + * + * ```ts + * export default defineDriver<{ apiKey: string }>((options) => ({ + * name: "acme", + * features: { html: true }, + * send: (msg) => post(options.apiKey, msg), + * })) + * ``` + */ +export function defineDriver( + factory: DriverFactory, +): DriverFactory { + return factory +} + +/** + * Declare a middleware. One shape covers retry, logging, rate limiting and + * everything else: wrap the next handler and return the results. + * + * The unit of work is a list, so a middleware sees the whole batch and can + * act on part of it — retry re-sends only the failed indices, even when + * the driver reached the provider in a single request. + * + * ```ts + * const timing = defineMiddleware("timing", (next) => async (msgs, ctx) => { + * const start = Date.now() + * const results = await next(msgs, ctx) + * ctx.meta.durationMs = Date.now() - start + * return results + * }) + * ``` + */ +export function defineMiddleware( + name: string, + handle: (next: SendHandler) => SendHandler, +): Middleware { + return { name, handle } +} + +/** Lift a per-message function into a middleware, for the common case + * where the batch is irrelevant. Messages are processed concurrently. */ +export function perMessage( + name: string, + handle: ( + next: (msg: NormalizedMessage, ctx: SendContext) => Promise>, + ) => (msg: NormalizedMessage, ctx: SendContext) => Promise>, +): Middleware { + return defineMiddleware(name, (next) => { + const one = handle(async (msg, ctx) => (await next([msg], ctx))[0]!) + return async (msgs, ctx) => Promise.all(msgs.map((msg) => one(msg, ctx))) + }) +} + +/** + * Compose middleware around a handler. The first registered middleware is + * the outermost, so `use(logger); use(retry)` logs once around all the + * retries rather than once per attempt. + */ +export function compose(middleware: readonly Middleware[], handler: SendHandler): SendHandler { + let composed = handler + for (let i = middleware.length - 1; i >= 0; i--) { + composed = guard(middleware[i]!.name, middleware[i]!.handle(composed)) + } + return composed +} + +/** + * Attach middleware to a single driver rather than a whole instance. This + * is what makes retry compose with failover: each leg retries on its own + * before the fallback moves to the next. + * + * ```ts + * fallback([wrap(resend(...), withRetry()), wrap(ses(...), withRetry())]) + * ``` + */ +export function wrap( + driver: EmailDriver, + ...middleware: readonly Middleware[] +): EmailDriver { + if (middleware.length === 0) return driver + const handler = compose(middleware, driverHandler(driver)) + return { + ...driver, + send: async (msg, ctx) => (await handler([msg], ctx))[0]!, + sendBatch: (msgs, ctx) => handler(msgs, ctx), + } +} + +/** + * The terminal handler: hand the list to the driver. Uses `sendBatch` when + * the driver has one, otherwise sends sequentially — and either way + * returns exactly one result per input. + */ +export function driverHandler(driver: EmailDriver): SendHandler { + return async (msgs, ctx) => { + if (msgs.length === 0) return [] + + if (msgs.length > 1 && driver.sendBatch) { + let results: readonly Result[] + try { + results = await driver.sendBatch(msgs, ctx) + } catch (error) { + const wrapped = err(toEmailError(driver.name, error)) + return msgs.map(() => wrapped) + } + if (results.length !== msgs.length) { + // A driver that loses the 1:1 mapping makes every downstream + // index meaningless, so fail the batch rather than guess. + const mismatch = err( + toEmailError( + driver.name, + new Error(`sendBatch returned ${results.length} results for ${msgs.length} messages`), + ), + ) + return msgs.map(() => mismatch) + } + return results + } + + const out: Result[] = [] + for (const msg of msgs) { + if (ctx.signal?.aborted) { + out.push(err(toEmailError(driver.name, ctx.signal.reason ?? new Error("aborted")))) + continue + } + try { + out.push(await driver.send(msg, ctx)) + } catch (error) { + out.push(err(toEmailError(driver.name, error))) + } + } + return out + } +} + +/** A middleware that throws must not take the batch down with it — its + * failure is reported per message, like any other. */ +function guard(name: string, handler: SendHandler): SendHandler { + return async (msgs, ctx) => { + try { + const results = await handler(msgs, ctx) + if (results.length === msgs.length) return results + throw new Error(`middleware "${name}" returned ${results.length} of ${msgs.length} results`) + } catch (error) { + const wrapped = err(toEmailError(ctx.driver, error)) + return msgs.map(() => wrapped) + } + } +} + +/** Re-exported so drivers can build results without importing `result.ts` + * separately. */ +export { ok, err } diff --git a/src/core/email.ts b/src/core/email.ts new file mode 100644 index 0000000..b6a07bb --- /dev/null +++ b/src/core/email.ts @@ -0,0 +1,292 @@ +import type { + BatchResult, + EmailDriver, + EmailMessage, + EmailResult, + Middleware, + NormalizedMessage, + Result, + SendContext, + SendHandler, + SendStatus, +} from "./types.ts" +import type { MessageDefaults } from "./message.ts" +import { compose, driverHandler } from "./define.ts" +import { createUnsupportedError, toEmailError } from "./error.ts" +import { err, ok, toBatchResult } from "./result.ts" +import { normalizeMessage } from "./message.ts" + +/** Everything `createEmail()` accepts. Only `driver` is required. */ +export interface CreateEmailOptions { + /** The driver every message goes to unless `stream` routes it elsewhere. */ + driver: EmailDriver + /** Named drivers, routed by `message.stream`. Same as calling `mount()`. */ + mounts?: Readonly> + /** Middleware, outermost first. Same as calling `use()` in order. */ + use?: readonly Middleware[] + /** Fields applied to every message that does not set them itself. */ + defaults?: MessageDefaults + /** Cancels in-flight sends for the whole instance. */ + signal?: AbortSignal +} + +/** Options accepted by `sendStream()`. */ +export interface SendStreamOptions { + /** Messages handed to the pipeline at once. Larger values let a driver + * batch natively; smaller values yield sooner. Default: 50. */ + chunkSize?: number +} + +/** The handle returned by `createEmail()`. */ +export interface Email { + /** The default driver. */ + readonly driver: EmailDriver + + /** Append middleware. Returns `this`, so calls chain. */ + use: (middleware: Middleware) => Email + /** Route messages carrying this `stream` to `driver`. */ + mount: (stream: string, driver: EmailDriver) => Email + unmount: (stream: string, options?: { dispose?: boolean }) => Promise + getMount: (stream?: string) => EmailDriver + getMounts: () => readonly { stream: string; driver: EmailDriver }[] + /** Whether the driver believes it can send. Never throws. */ + isAvailable: (stream?: string) => Promise + + send: (message: EmailMessage) => Promise> + /** Send many. Never short-circuits: `result.results[i]` always + * corresponds to `messages[i]`, failed or not. */ + sendBatch: (messages: readonly EmailMessage[]) => Promise + /** Same as `sendBatch` without holding every result in memory. */ + sendStream: ( + messages: Iterable | AsyncIterable, + options?: SendStreamOptions, + ) => AsyncIterable> + + cancel: (id: string, options?: { stream?: string }) => Promise> + retrieve: (id: string, options?: { stream?: string }) => Promise> + dispose: () => Promise +} + +/** + * Build an email instance. + * + * ```ts + * const email = createEmail({ + * driver: resend({ apiKey: process.env.RESEND_API_KEY! }), + * defaults: { from: "Acme " }, + * use: [withRetry()], + * }) + * + * const { data, error } = await email.send({ to, subject, html }) + * ``` + */ +export function createEmail(options: CreateEmailOptions): Email { + const mounts = new Map(Object.entries(options.mounts ?? {})) + const middleware: Middleware[] = [...(options.use ?? [])] + const defaults = options.defaults ?? {} + + // Composition is rebuilt whenever `use()` changes the chain; `revision` + // is what invalidates the per-driver cache. + const pipelines = new Map() + const initializing = new Map>() + let revision = 0 + + const api: Email = { + get driver() { + return options.driver + }, + + use(mw) { + middleware.push(mw) + revision++ + return api + }, + + mount(stream, driver) { + mounts.set(stream, driver) + return api + }, + + async unmount(stream, opts = {}) { + const driver = mounts.get(stream) + if (!driver) return + mounts.delete(stream) + pipelines.delete(driver) + initializing.delete(driver) + if (opts.dispose ?? true) await driver.dispose?.() + }, + + getMount(stream) { + return (stream ? mounts.get(stream) : undefined) ?? options.driver + }, + + getMounts() { + return [...mounts].map(([stream, driver]) => ({ stream, driver })) + }, + + async isAvailable(stream) { + const driver = api.getMount(stream) + if (!driver.isAvailable) return true + try { + return await driver.isAvailable() + } catch { + return false + } + }, + + async send(message) { + const results = await dispatch([message]) + return results[0]! + }, + + async sendBatch(messages) { + return toBatchResult(await dispatch(messages)) + }, + + sendStream(messages, opts = {}) { + const chunkSize = Math.max(1, opts.chunkSize ?? 50) + return { + async *[Symbol.asyncIterator]() { + let chunk: EmailMessage[] = [] + for await (const message of messages) { + chunk.push(message) + if (chunk.length < chunkSize) continue + yield* await dispatch(chunk) + chunk = [] + } + if (chunk.length > 0) yield* await dispatch(chunk) + }, + } + }, + + async cancel(id, opts = {}) { + const driver = api.getMount(opts.stream) + if (!driver.cancel) return err(createUnsupportedError(driver.name, "cancel()")) + try { + await ensureInitialized(driver) + return await driver.cancel(id) + } catch (error) { + return err(toEmailError(driver.name, error)) + } + }, + + async retrieve(id, opts = {}) { + const driver = api.getMount(opts.stream) + if (!driver.retrieve) return err(createUnsupportedError(driver.name, "retrieve()")) + try { + await ensureInitialized(driver) + return await driver.retrieve(id) + } catch (error) { + return err(toEmailError(driver.name, error)) + } + }, + + async dispose() { + const drivers = new Set([options.driver, ...mounts.values()]) + mounts.clear() + pipelines.clear() + initializing.clear() + await Promise.all([...drivers].map((driver) => driver.dispose?.())) + }, + } + + /** + * Normalize, group by destination driver, run each group through that + * driver's pipeline, and stitch the results back into input order. + * + * A message that fails normalization takes only its own slot with it — + * one bad address in a batch of a thousand does not stop the other 999. + */ + async function dispatch( + messages: readonly EmailMessage[], + ): Promise[]> { + // Pre-filled, so a slot no driver claims still reports something + // rather than reading back as a hole. + const results = Array.from({ length: messages.length }, () => + err(missingResult("unemail")), + ) + const groups = new Map() + + for (const [index, message] of messages.entries()) { + let normalized: NormalizedMessage + try { + normalized = normalizeMessage(message, defaults) + } catch (error) { + results[index] = err(toEmailError("unemail", error)) + continue + } + const driver = api.getMount(normalized.stream) + let group = groups.get(driver) + if (!group) { + group = { indices: [], msgs: [] } + groups.set(driver, group) + } + group.indices.push(index) + group.msgs.push(normalized) + } + + await Promise.all( + [...groups].map(async ([driver, group]) => { + const ctx: SendContext = { + driver: driver.name, + ...(group.msgs[0]?.stream ? { stream: group.msgs[0].stream } : {}), + attempt: 1, + ...(options.signal ? { signal: options.signal } : {}), + meta: {}, + } + let produced: readonly Result[] + try { + await ensureInitialized(driver) + produced = await pipelineFor(driver)(group.msgs, ctx) + } catch (error) { + const failure = err(toEmailError(driver.name, error)) + produced = group.msgs.map(() => failure) + } + for (const [slot, index] of group.indices.entries()) { + results[index] = produced[slot] ?? err(missingResult(driver.name)) + } + }), + ) + + return results + } + + function pipelineFor(driver: EmailDriver): SendHandler { + const cached = pipelines.get(driver) + if (cached && cached.revision === revision) return cached.handler + const handler = compose(middleware, driverHandler(driver)) + pipelines.set(driver, { revision, handler }) + return handler + } + + /** + * Initialize a driver at most once, per driver. + * + * The promise is stored before it is awaited: two concurrent sends share + * one initialization instead of racing past a half-open connection. It + * is keyed by driver rather than by instance so a driver mounted after + * the first send still gets initialized. + */ + function ensureInitialized(driver: EmailDriver): Promise { + let pending = initializing.get(driver) + if (pending) return pending + pending = (async () => { + try { + await driver.initialize?.() + } catch (error) { + initializing.delete(driver) + throw toEmailError(driver.name, error) + } + })() + initializing.set(driver, pending) + return pending + } + + return api +} + +function missingResult(driver: string) { + return toEmailError(driver, new Error("pipeline produced no result for this message")) +} + +export { ok } diff --git a/src/core/error.ts b/src/core/error.ts new file mode 100644 index 0000000..b12ca59 --- /dev/null +++ b/src/core/error.ts @@ -0,0 +1,80 @@ +import type { EmailErrorCode } from "./types.ts" + +/** Every failure the library reports. Drivers never throw raw errors past + * their own boundary — `toEmailError()` wraps whatever they catch, so + * `error.code` and `error.retryable` are always meaningful. */ +export class EmailError extends Error { + override readonly name = "EmailError" + readonly driver: string + readonly code: EmailErrorCode + readonly status?: number + readonly retryable: boolean + override readonly cause?: unknown + + constructor(init: { + driver: string + code: EmailErrorCode + message: string + status?: number + retryable?: boolean + cause?: unknown + }) { + super(init.message) + this.driver = init.driver + this.code = init.code + this.status = init.status + this.retryable = init.retryable ?? RETRYABLE_BY_DEFAULT.has(init.code) + this.cause = init.cause + } +} + +const RETRYABLE_BY_DEFAULT: ReadonlySet = new Set([ + "NETWORK", + "RATE_LIMIT", + "TIMEOUT", +]) + +/** Build an `EmailError` with the `[unemail] [driver]` prefix, so one + * provider's failures are greppable in a mixed log. */ +export function createError( + driver: string, + code: EmailErrorCode, + message: string, + init?: { status?: number; retryable?: boolean; cause?: unknown }, +): EmailError { + return new EmailError({ + driver, + code, + message: `[unemail] [${driver}] ${message}`, + status: init?.status, + retryable: init?.retryable, + cause: init?.cause, + }) +} + +/** Missing driver options. Raised from the factory so misconfiguration + * fails at construction rather than on the first send. */ +export function createRequiredError(driver: string, name: string | readonly string[]): EmailError { + const names = Array.isArray(name) ? name.join(", ") : String(name) + return createError(driver, "INVALID_OPTIONS", `missing required option(s): ${names}`) +} + +/** Raised when a message asks for something the driver cannot do. */ +export function createUnsupportedError(driver: string, what: string): EmailError { + return createError(driver, "UNSUPPORTED", `${what} is not supported by "${driver}"`) +} + +/** Normalize any thrown value. An existing `EmailError` passes through + * untouched so its `retryable` and `status` survive re-wrapping. */ +export function toEmailError(driver: string, error: unknown): EmailError { + if (error instanceof EmailError) return error + if (isAbort(error)) return createError(driver, "CANCELLED", "aborted", { cause: error }) + if (error instanceof Error) { + return createError(driver, "PROVIDER", error.message, { cause: error }) + } + return createError(driver, "PROVIDER", String(error), { cause: error }) +} + +function isAbort(error: unknown): boolean { + return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError") +} diff --git a/src/core/message.ts b/src/core/message.ts new file mode 100644 index 0000000..0679b22 --- /dev/null +++ b/src/core/message.ts @@ -0,0 +1,182 @@ +import type { EmailMessage, NormalizedMessage } from "./types.ts" +import { dedupeAddresses, isValidEmail, toAddressList } from "./address.ts" +import { createError } from "./error.ts" + +/** Fields an instance can supply once instead of on every message. */ +export type MessageDefaults = Pick< + EmailMessage, + "from" | "replyTo" | "headers" | "tags" | "metadata" | "stream" +> + +const CORE = "unemail" + +/** + * Turn user input into the shape drivers consume: addresses parsed, lists + * always present, headers validated, derived headers applied. + * + * This runs exactly once per message, at the edge of the library. It is + * why no driver in this repo parses an address or guards a header. + * + * Throws `EmailError(INVALID_OPTIONS)`; `createEmail()` catches and + * returns it as a `Result`, so callers still never see a throw. + */ +export function normalizeMessage( + input: EmailMessage, + defaults: MessageDefaults = {}, +): NormalizedMessage { + const fromList = toAddressList(input.from ?? defaults.from) + const from = fromList[0] + if (!from) throw invalid("`from` is required (set it on the message or as an instance default)") + + const to = dedupeAddresses(toAddressList(input.to)) + if (to.length === 0) throw invalid("`to` must contain at least one recipient") + + const cc = dedupeAddresses(toAddressList(input.cc)) + const bcc = dedupeAddresses(toAddressList(input.bcc)) + const replyTo = dedupeAddresses(toAddressList(input.replyTo ?? defaults.replyTo)) + + for (const [field, list] of [ + ["from", [from]], + ["to", to], + ["cc", cc], + ["bcc", bcc], + ["replyTo", replyTo], + ] as const) { + for (const address of list) { + if (!isValidEmail(address.email)) { + throw invalid(`\`${field}\` contains an invalid address: ${JSON.stringify(address.email)}`) + } + } + } + + if (typeof input.subject !== "string") throw invalid("`subject` is required") + + const hasBody = + input.text != null || + input.html != null || + input.content != null || + input.raw != null || + input.template != null + if (!hasBody) { + throw invalid("message has no body — set one of `text`, `html`, `content`, `template`, `raw`") + } + + const headers = buildHeaders(input, defaults) + const html = + input.preheader && input.html ? injectPreheader(input.html, input.preheader) : input.html + + const message: NormalizedMessage = { + ...((input.stream ?? defaults.stream) ? { stream: input.stream ?? defaults.stream } : {}), + from, + to, + cc, + bcc, + replyTo, + subject: input.subject, + ...(input.text == null ? {} : { text: input.text }), + ...(html == null ? {} : { html }), + ...(input.content == null ? {} : { content: input.content }), + headers, + attachments: input.attachments ?? [], + tags: [...(defaults.tags ?? []), ...(input.tags ?? [])], + metadata: { ...defaults.metadata, ...input.metadata }, + ...(input.idempotencyKey == null ? {} : { idempotencyKey: input.idempotencyKey }), + ...(input.scheduledAt == null ? {} : { scheduledAt: parseDate(input.scheduledAt) }), + ...(input.template == null ? {} : { template: input.template }), + ...(input.tracking == null ? {} : { tracking: input.tracking }), + ...(input.sandbox == null ? {} : { sandbox: input.sandbox }), + ...(input.raw == null ? {} : { raw: input.raw }), + } + + return Object.freeze(message) +} + +/** Derive a new message from a normalized one. The only supported way for + * middleware to change a message — the caller's object is never touched, + * so a template object stays reusable across sends. */ +export function patchMessage( + message: NormalizedMessage, + patch: Partial, +): NormalizedMessage { + return Object.freeze({ ...message, ...patch }) +} + +/** Case-insensitive header lookup, since callers write `Message-ID`, + * `message-id`, and `Message-Id` interchangeably. */ +export function getHeader( + headers: Readonly>, + name: string, +): string | undefined { + const target = name.toLowerCase() + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) return value + } + return undefined +} + +/** True when a header is already set under any casing. */ +export function hasHeader(headers: Readonly>, name: string): boolean { + return getHeader(headers, name) !== undefined +} + +function buildHeaders( + input: EmailMessage, + defaults: MessageDefaults, +): Readonly> { + const headers: Record = { ...defaults.headers, ...input.headers } + + for (const [name, value] of Object.entries(headers)) { + // A newline in a header value lets a caller append arbitrary headers + // (and a body) to the message — RFC 5322 §2.2 forbids it outright. + if (/[\r\n]/.test(value) || /[\r\n:]/.test(name)) { + throw invalid(`header ${JSON.stringify(name)} contains a line break`) + } + } + + const unsubscribe = input.unsubscribe + if (unsubscribe && (unsubscribe.url || unsubscribe.mailto)) { + const parts: string[] = [] + if (unsubscribe.url) parts.push(`<${unsubscribe.url}>`) + if (unsubscribe.mailto) parts.push(``) + if (!hasHeader(headers, "list-unsubscribe")) headers["List-Unsubscribe"] = parts.join(", ") + const oneClick = unsubscribe.oneClick ?? Boolean(unsubscribe.url) + if (oneClick && unsubscribe.url && !hasHeader(headers, "list-unsubscribe-post")) { + headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" + } + } + + return Object.freeze(headers) +} + +/** Hidden span most clients read as the preview line. The trailing + * zero-width joiners stop the client from spilling body text into the + * preview after the preheader ends. */ +function injectPreheader(html: string, preheader: string): string { + const block = + `
` + + `${escapeHtml(preheader)}${"‌ ".repeat(60)}
` + const bodyOpen = /]*>/i.exec(html) + if (bodyOpen) { + const at = bodyOpen.index + bodyOpen[0].length + return html.slice(0, at) + block + html.slice(at) + } + return block + html +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) +} + +function parseDate(value: string | Date): Date { + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) throw invalid(`\`scheduledAt\` is not a valid date: ${value}`) + return date +} + +function invalid(message: string) { + return createError(CORE, "INVALID_OPTIONS", message) +} diff --git a/src/core/result.ts b/src/core/result.ts new file mode 100644 index 0000000..a29e640 --- /dev/null +++ b/src/core/result.ts @@ -0,0 +1,36 @@ +import type { BatchResult, EmailResult, Result } from "./types.ts" +import type { EmailError } from "./error.ts" + +/** Wrap a value as a success. */ +export function ok(data: T): Result { + return { data, error: null } +} + +/** Wrap an error as a failure. */ +export function err(error: EmailError): Result { + return { data: null, error } +} + +/** Narrow a `Result` to its success branch. */ +export function isOk(result: Result): result is { data: T; error: null } { + return result.error === null +} + +/** Throw on failure, return the value on success. For callers who prefer + * exceptions to branching — `const sent = unwrap(await email.send(msg))`. */ +export function unwrap(result: Result): T { + if (result.error) throw result.error + return result.data +} + +/** Summarize positional per-message results. Never loses a partial + * success: `sent` holds what got through even when `ok` is false. */ +export function toBatchResult(results: readonly Result[]): BatchResult { + const sent: EmailResult[] = [] + const failed: { index: number; error: EmailError }[] = [] + for (const [index, result] of results.entries()) { + if (result.error) failed.push({ index, error: result.error }) + else sent.push(result.data) + } + return { results, sent, failed, ok: failed.length === 0 } +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..43556c5 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,305 @@ +/** + * Every type in the public surface. Runtime-free by construction — this + * module compiles to nothing, so importing it costs no bytes in a Worker + * bundle. + * + * @module + */ + +import type { EmailError } from "./error.ts" + +/** A value that may be returned synchronously or as a promise. */ +export type MaybePromise = T | Promise + +// --------------------------------------------------------------------------- +// Addresses +// --------------------------------------------------------------------------- + +/** A contact: an address plus an optional display name. */ +export interface EmailAddress { + readonly email: string + readonly name?: string +} + +/** Anything accepted where an address is expected — `"ada@acme.com"`, + * `"Ada "`, `{ email, name }`, or a list of those. */ +export type AddressInput = string | EmailAddress | readonly (string | EmailAddress)[] + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +/** A file part. `content` is either raw bytes or a base64 string; set + * `cid` to reference it from HTML as ``. */ +export interface Attachment { + readonly filename: string + readonly content: string | Uint8Array + readonly contentType?: string + readonly disposition?: "attachment" | "inline" + readonly cid?: string +} + +/** Key-value pair forwarded to provider analytics. */ +export interface EmailTag { + readonly name: string + readonly value: string +} + +/** RFC 2369 + RFC 8058 unsubscribe configuration. Gmail and Yahoo require + * a one-click unsubscribe on bulk mail; setting `url` turns it on. */ +export interface UnsubscribeOptions { + readonly url?: string + readonly mailto?: string + /** Emit `List-Unsubscribe-Post`. Defaults to `true` when `url` is set. */ + readonly oneClick?: boolean +} + +/** A provider-hosted template. Use `id` where the provider addresses + * templates numerically, `alias` where it addresses them by name. */ +export interface TemplateOptions { + readonly id?: string + readonly alias?: string + readonly variables?: Readonly> +} + +/** Per-message open/click tracking. Unset fields defer to the provider's + * account-level setting. */ +export interface TrackingOptions { + readonly opens?: boolean + readonly clicks?: boolean +} + +/** Unrendered body handed to a `Renderer`. The core never inspects + * anything but `type`, which is how a renderer claims a message — + * so a new template language is a package, not a core change. */ +export interface MessageContent { + readonly type: string + readonly [key: string]: unknown +} + +/** What you pass to `email.send()`. Every address field is loose; the core + * normalizes and validates before a driver sees it. */ +export interface EmailMessage { + /** Route to a driver registered with `mount(stream, driver)`. */ + readonly stream?: string + + readonly from?: AddressInput + readonly to: AddressInput + readonly cc?: AddressInput + readonly bcc?: AddressInput + readonly replyTo?: AddressInput + + readonly subject: string + /** Preview line most clients show next to the subject. */ + readonly preheader?: string + readonly text?: string + readonly html?: string + /** Unrendered body — a `Renderer` turns this into `html`. */ + readonly content?: MessageContent + + readonly headers?: Readonly> + readonly attachments?: readonly Attachment[] + readonly tags?: readonly EmailTag[] + /** Provider-agnostic metadata, echoed back on webhook events. */ + readonly metadata?: Readonly> + + readonly idempotencyKey?: string + readonly scheduledAt?: string | Date + readonly unsubscribe?: UnsubscribeOptions + readonly template?: TemplateOptions + readonly tracking?: TrackingOptions + /** Route to the provider's sandbox instead of real delivery. */ + readonly sandbox?: boolean + /** A pre-composed RFC 5322 message. Bypasses the MIME builder; SMTP only. */ + readonly raw?: string | Uint8Array +} + +/** What a driver receives: validated, address-parsed, header-folded. Lists + * are always present (empty rather than `undefined`) so drivers never + * branch on nullish, and `from` is guaranteed. */ +export interface NormalizedMessage { + readonly stream?: string + + readonly from: EmailAddress + readonly to: readonly EmailAddress[] + readonly cc: readonly EmailAddress[] + readonly bcc: readonly EmailAddress[] + readonly replyTo: readonly EmailAddress[] + + readonly subject: string + readonly text?: string + readonly html?: string + readonly content?: MessageContent + + /** Header names are as-cased by the caller; `hasHeader()` compares + * case-insensitively. Already includes derived `List-Unsubscribe`. */ + readonly headers: Readonly> + readonly attachments: readonly Attachment[] + readonly tags: readonly EmailTag[] + readonly metadata: Readonly> + + readonly idempotencyKey?: string + readonly scheduledAt?: Date + readonly template?: TemplateOptions + readonly tracking?: TrackingOptions + readonly sandbox?: boolean + readonly raw?: string | Uint8Array +} + +// --------------------------------------------------------------------------- +// Results +// --------------------------------------------------------------------------- + +/** A delivery accepted by the provider. */ +export interface EmailResult { + readonly id: string + readonly driver: string + readonly stream?: string + readonly at: Date + /** The provider's own response, untouched. */ + readonly provider?: Readonly> +} + +/** Discriminated union — narrowing on `error` yields typed `data`. */ +export type Result = { data: T; error: null } | { data: null; error: EmailError } + +/** Outcome of `sendBatch()`. `results` is positional: `results[i]` is the + * outcome of `messages[i]`, always, even when some failed. */ +export interface BatchResult { + readonly results: readonly Result[] + readonly sent: readonly EmailResult[] + readonly failed: readonly { readonly index: number; readonly error: EmailError }[] + /** True when every message was accepted. */ + readonly ok: boolean +} + +/** Lifecycle state of a message, as far as the provider will tell us. */ +export type SendState = + | "scheduled" + | "queued" + | "sent" + | "delivered" + | "bounced" + | "complained" + | "opened" + | "clicked" + | "cancelled" + | "failed" + | "unknown" + +export interface SendStatus { + readonly id: string + readonly driver: string + readonly state: SendState + readonly at?: Date + readonly provider?: Readonly> +} + +/** Machine-readable failure taxonomy. Stable across every driver. */ +export type EmailErrorCode = + | "INVALID_OPTIONS" + | "NETWORK" + | "AUTH" + | "RATE_LIMIT" + | "TIMEOUT" + | "PROVIDER" + | "UNSUPPORTED" + | "CANCELLED" + +// --------------------------------------------------------------------------- +// Drivers +// --------------------------------------------------------------------------- + +/** Capabilities a driver advertises. Callers gate on these instead of + * string-matching `driver.name`. */ +export interface DriverFeatures { + readonly attachments?: boolean + readonly html?: boolean + readonly text?: boolean + /** `sendBatch` reaches the provider in one request. */ + readonly batch?: boolean + readonly scheduling?: boolean + /** The provider itself deduplicates on `idempotencyKey`. */ + readonly idempotency?: boolean + readonly tracking?: boolean + readonly templates?: boolean + readonly tagging?: boolean + readonly replyTo?: boolean + readonly customHeaders?: boolean + readonly sandbox?: boolean + readonly cancelable?: boolean + readonly retrievable?: boolean +} + +/** Everything a driver is asked to do. Only `name` and `send` are + * required; the rest is feature-gated and routed to `UNSUPPORTED`. */ +export interface EmailDriver { + readonly name: string + readonly features?: DriverFeatures + /** The underlying client (a pool, an inbox array, an SDK handle). */ + readonly getInstance?: () => TInstance + readonly initialize?: () => MaybePromise + readonly dispose?: () => MaybePromise + readonly isAvailable?: () => MaybePromise + + readonly send: (msg: NormalizedMessage, ctx: SendContext) => MaybePromise> + /** One request for many messages. Must return one result per input, in + * order — the core checks this and fails loudly if it does not. */ + readonly sendBatch?: ( + msgs: readonly NormalizedMessage[], + ctx: SendContext, + ) => MaybePromise[]> + readonly cancel?: (id: string) => MaybePromise> + readonly retrieve?: (id: string) => MaybePromise> +} + +/** A driver that is guaranteed to expose its underlying client, so callers + * reach it without an optional-call guard. */ +export type DriverWithInstance = EmailDriver & { + readonly getInstance: () => TInstance +} + +/** The driver shape implied by an instance type. Naming one obliges the + * driver to expose it and lets callers reach it unguarded; leaving it + * `unknown` keeps `getInstance` optional. */ +export type DriverOf = unknown extends TInstance + ? EmailDriver + : DriverWithInstance + +/** What `defineDriver` returns. Options are required when `TOpts` has a + * required field — `resend()` with no key is a type error, not a + * runtime surprise. */ +export type DriverFactory = (options: TOpts) => DriverOf + +// --------------------------------------------------------------------------- +// Pipeline +// --------------------------------------------------------------------------- + +/** Ambient state for one trip through the pipeline. `meta` is a shared + * mutable bag for middleware to leave notes in; everything else is + * replaced, not mutated, when a middleware derives a new context. */ +export interface SendContext { + readonly driver: string + readonly stream?: string + /** 1 on the first try. Retry middleware derives a context per attempt. */ + readonly attempt: number + readonly signal?: AbortSignal + readonly meta: Record +} + +/** The one unit of work in this library. Always a list, always one result + * per input, in order — `send()` is the single-element case. Making the + * list the primitive is what lets retry re-send only the failures even + * when the driver batches natively. */ +export type SendHandler = ( + msgs: readonly NormalizedMessage[], + ctx: SendContext, +) => Promise[]> + +/** The one composition primitive. Wrap the next handler, do work around + * it, return the results. Register with `email.use()` to cover a whole + * instance, or `wrap(driver, ...)` to cover one driver. */ +export interface Middleware { + readonly name: string + readonly handle: (next: SendHandler) => SendHandler +} diff --git a/src/dmarc/index.ts b/src/dmarc/index.ts deleted file mode 100644 index 19b5e83..0000000 --- a/src/dmarc/index.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Minimal DMARC aggregate (RUA) XML report parser. Zero-dep — we only - * need the narrow RUA schema Google/Yahoo/Microsoft emit. Input can be - * raw XML, gzipped bytes, or a fetch Response body. - * - * @module - */ - -export interface DmarcReport { - orgName?: string - email?: string - reportId?: string - domain?: string - dateRange?: { begin: Date; end: Date } - policy?: { - p?: "none" | "quarantine" | "reject" - sp?: "none" | "quarantine" | "reject" - adkim?: "r" | "s" - aspf?: "r" | "s" - pct?: number - } - records: ReadonlyArray -} - -export interface DmarcRecord { - sourceIp?: string - count: number - disposition?: "none" | "quarantine" | "reject" - dkim?: "pass" | "fail" - spf?: "pass" | "fail" - headerFrom?: string -} - -/** Parse a DMARC aggregate report. Accepts a plain XML string or a - * gzipped Uint8Array (detected via magic bytes). */ -export async function parseDmarcAggregate(input: string | Uint8Array): Promise { - const xml = typeof input === "string" ? input : await gunzipOrUtf8(input) - return parseReportXml(xml) -} - -async function gunzipOrUtf8(bytes: Uint8Array): Promise { - if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) { - // Use DecompressionStream if available, else node:zlib. - const g = globalThis as { DecompressionStream?: typeof DecompressionStream } - if (g.DecompressionStream) { - const stream = new Response( - new Response(bytes as BufferSource).body!.pipeThrough(new g.DecompressionStream("gzip")), - ) - return stream.text() - } - // Node.js fallback. - const zlibModule = await import("node:zlib").catch(() => null) - if (zlibModule) { - return new Promise((resolve, reject) => { - zlibModule.gunzip(Buffer.from(bytes), (err, out) => { - if (err) reject(err) - else resolve(out.toString("utf8")) - }) - }) - } - throw new Error("[unemail/dmarc] gzip input but no DecompressionStream / node:zlib available") - } - return new TextDecoder().decode(bytes) -} - -function parseReportXml(xml: string): DmarcReport { - const out: DmarcReport = { records: [] } - const records: DmarcRecord[] = [] - - out.orgName = takeTag(xml, "org_name") - out.email = takeTag(xml, "email") - out.reportId = takeTag(xml, "report_id") - out.domain = takeTag(xml, "domain") - - const rangeBlock = takeSection(xml, "date_range") - if (rangeBlock) { - const begin = Number(takeTag(rangeBlock, "begin")) - const end = Number(takeTag(rangeBlock, "end")) - if (Number.isFinite(begin) && Number.isFinite(end)) - out.dateRange = { begin: new Date(begin * 1000), end: new Date(end * 1000) } - } - - const policyBlock = takeSection(xml, "policy_published") - if (policyBlock) { - out.policy = { - p: takeTag(policyBlock, "p") as DmarcReport["policy"] extends infer P - ? P extends { p?: infer T } - ? T - : never - : never, - sp: takeTag(policyBlock, "sp") as "none" | "quarantine" | "reject" | undefined, - adkim: takeTag(policyBlock, "adkim") as "r" | "s" | undefined, - aspf: takeTag(policyBlock, "aspf") as "r" | "s" | undefined, - pct: Number(takeTag(policyBlock, "pct")) || undefined, - } - } - - const recordRegex = /([\s\S]*?)<\/record>/g - let m: RegExpExecArray | null - while ((m = recordRegex.exec(xml)) !== null) { - const block = m[1]! - records.push({ - sourceIp: takeTag(block, "source_ip"), - count: Number(takeTag(block, "count")) || 0, - disposition: takeTag(block, "disposition") as DmarcRecord["disposition"], - dkim: takeTag(block, "dkim") as DmarcRecord["dkim"], - spf: takeTag(block, "spf") as DmarcRecord["spf"], - headerFrom: takeTag(block, "header_from"), - }) - } - return { ...out, records } -} - -function takeTag(block: string, tag: string): string | undefined { - const re = new RegExp(`<${tag}>([^<]*)<\\/${tag}>`, "i") - const m = re.exec(block) - return m ? m[1]!.trim() : undefined -} - -function takeSection(block: string, tag: string): string | undefined { - const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, "i") - const m = re.exec(block) - return m ? m[1] : undefined -} diff --git a/src/driver/_http.ts b/src/driver/_http.ts deleted file mode 100644 index 1f8587d..0000000 --- a/src/driver/_http.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { Result } from "../types.ts" -import { createError, toEmailError } from "../errors.ts" - -/** Thin wrapper around `fetch` used by every HTTP-based driver (Resend, - * Postmark, SendGrid, Mailgun, Mailtrap, Brevo, MailerSend, Loops, Zeptomail, - * MailChannels, HTTP). Handles JSON encoding, response parsing, and - * mapping HTTP status codes to our `EmailErrorCode` taxonomy. - * - * Drivers pass a tiny `classifyError()` callback when the provider - * returns richer error codes than plain HTTP (Postmark's `ErrorCode 10`, - * SendGrid's `errors[].field`, etc.). - */ -export interface HttpRequestInit { - fetch: typeof fetch - driver: string - url: string - method?: string - headers?: Record - body?: unknown - /** Return a custom EmailErrorCode classification from the parsed body. */ - classifyError?: ( - status: number, - body: unknown, - ) => { - code: "AUTH" | "RATE_LIMIT" | "NETWORK" | "PROVIDER" - retryable?: boolean - message?: string - } | null -} - -/** Issue a JSON HTTP request and return a `Result` where the - * data is the parsed response (or null for empty bodies). */ -export async function httpJson(init: HttpRequestInit): Promise> { - const headers: Record = { - accept: "application/json", - "content-type": "application/json", - ...init.headers, - } - - let res: Response - try { - res = await init.fetch(init.url, { - method: init.method ?? "POST", - headers, - body: init.body == null ? undefined : JSON.stringify(init.body), - }) - } catch (err) { - return { data: null, error: toEmailError(init.driver, err) } - } - - const text = await res.text() - const parsed = text ? safeJson(text) : null - - if (!res.ok) { - const custom = init.classifyError?.(res.status, parsed) - const code = custom?.code ?? defaultCodeForStatus(res.status) - const message = custom?.message ?? extractMessage(parsed) ?? `HTTP ${res.status}` - const retryable = custom?.retryable ?? (code === "RATE_LIMIT" || code === "NETWORK") - return { - data: null, - error: createError(init.driver, code, message, { - status: res.status, - retryable, - cause: { headers: res.headers, body: parsed ?? text }, - }), - } - } - - return { data: parsed, error: null } -} - -function defaultCodeForStatus(status: number): "AUTH" | "RATE_LIMIT" | "NETWORK" | "PROVIDER" { - if (status === 401 || status === 403) return "AUTH" - if (status === 429) return "RATE_LIMIT" - if (status >= 500) return "NETWORK" - return "PROVIDER" -} - -function extractMessage(body: unknown): string | null { - if (!body || typeof body !== "object") return null - const record = body as Record - // Common shapes: { message }, { Message }, { error }, { errors: [{ message }] } - const direct = record.message ?? record.Message ?? record.error ?? record.detail - if (typeof direct === "string") return direct - if (Array.isArray(record.errors) && record.errors[0] && typeof record.errors[0] === "object") { - const first = record.errors[0] as Record - if (typeof first.message === "string") return first.message - } - return null -} - -function safeJson(text: string): unknown { - try { - return JSON.parse(text) - } catch { - return null - } -} diff --git a/src/driver/_smtp/mime.ts b/src/driver/_smtp/mime.ts deleted file mode 100644 index 99b7d0e..0000000 --- a/src/driver/_smtp/mime.ts +++ /dev/null @@ -1,320 +0,0 @@ -import type { Attachment, EmailAddress, EmailMessage } from "../../types.ts" -import { formatAddress, normalizeAddresses } from "../../_normalize.ts" - -/** Inputs used to assemble the MIME document. Kept separate from - * `EmailMessage` so the builder can be unit-tested in isolation. */ -export interface MimeInput { - from: EmailAddress - to: EmailAddress[] - cc: EmailAddress[] - bcc: EmailAddress[] - replyTo: EmailAddress[] - subject: string - text?: string - html?: string - amp?: string - headers?: Record - attachments?: ReadonlyArray - date?: Date - messageId?: string -} - -/** Output of `buildMime()` — the serialized RFC 5322 message plus the list - * of envelope recipients (to/cc/bcc merged) for `RCPT TO`. */ -export interface MimeOutput { - envelope: { - from: string - rcpt: string[] - } - body: string - headers: Record -} - -export function normalizeMimeInput( - msg: EmailMessage, - messageId: string, - date: Date = new Date(), -): MimeInput { - const fromList = normalizeAddresses(msg.from) - const from = fromList[0] - if (!from) throw new Error("`from` is required") - return { - from, - to: normalizeAddresses(msg.to), - cc: normalizeAddresses(msg.cc), - bcc: normalizeAddresses(msg.bcc), - replyTo: normalizeAddresses(msg.replyTo), - subject: msg.subject, - text: msg.text, - html: msg.html, - amp: msg.amp, - headers: msg.headers, - attachments: msg.attachments, - date, - messageId, - } -} - -export function buildMime(input: MimeInput): MimeOutput { - const boundary = `----unemail_${randomBoundary()}` - const altBoundary = `----unemail_alt_${randomBoundary()}` - const hasAttachments = (input.attachments?.length ?? 0) > 0 - const hasBothBodies = Boolean(input.text && input.html) || Boolean(input.amp) - - const headers: Record = { - From: formatAddress(input.from), - To: input.to.map(formatAddress).join(", "), - Subject: encodeHeader(input.subject), - "Message-ID": input.messageId ?? "", - Date: (input.date ?? new Date()).toUTCString(), - "MIME-Version": "1.0", - } - if (input.cc.length) headers.Cc = input.cc.map(formatAddress).join(", ") - if (input.replyTo.length) headers["Reply-To"] = input.replyTo.map(formatAddress).join(", ") - if (input.headers) { - for (const [k, v] of Object.entries(input.headers)) headers[k] = v - } - - const body = hasAttachments - ? buildMultipartMixed(input, boundary, altBoundary, hasBothBodies, headers) - : hasBothBodies - ? buildMultipartAlternative(input, altBoundary, headers) - : buildSinglePart(input, headers) - - const rendered = renderHeaders(headers) + "\r\n" + body - - return { - envelope: { - from: input.from.email, - rcpt: dedupe([...input.to, ...input.cc, ...input.bcc].map((a) => a.email)), - }, - headers, - body: rendered, - } -} - -function renderHeaders(headers: Record): string { - const lines: string[] = [] - for (const [name, value] of Object.entries(headers)) { - if (value === "") continue - lines.push(`${name}: ${foldHeader(value)}`) - } - return lines.join("\r\n") + "\r\n" -} - -function buildSinglePart(input: MimeInput, headers: Record): string { - if (input.html) { - headers["Content-Type"] = "text/html; charset=utf-8" - headers["Content-Transfer-Encoding"] = "quoted-printable" - return encodeQuotedPrintable(input.html) - } - headers["Content-Type"] = "text/plain; charset=utf-8" - headers["Content-Transfer-Encoding"] = "quoted-printable" - return encodeQuotedPrintable(input.text ?? "") -} - -function buildMultipartAlternative( - input: MimeInput, - boundary: string, - headers: Record, -): string { - headers["Content-Type"] = `multipart/alternative; boundary="${boundary}"` - const parts: string[] = [] - if (input.text) { - parts.push( - [ - `--${boundary}`, - "Content-Type: text/plain; charset=utf-8", - "Content-Transfer-Encoding: quoted-printable", - "", - encodeQuotedPrintable(input.text), - ].join("\r\n"), - ) - } - if (input.amp) { - parts.push( - [ - `--${boundary}`, - "Content-Type: text/x-amp-html; charset=utf-8", - "Content-Transfer-Encoding: quoted-printable", - "", - encodeQuotedPrintable(input.amp), - ].join("\r\n"), - ) - } - if (input.html) { - parts.push( - [ - `--${boundary}`, - "Content-Type: text/html; charset=utf-8", - "Content-Transfer-Encoding: quoted-printable", - "", - encodeQuotedPrintable(input.html), - ].join("\r\n"), - ) - } - parts.push(`--${boundary}--`) - return parts.join("\r\n") -} - -function buildMultipartMixed( - input: MimeInput, - outerBoundary: string, - altBoundary: string, - hasBothBodies: boolean, - headers: Record, -): string { - headers["Content-Type"] = `multipart/mixed; boundary="${outerBoundary}"` - const parts: string[] = [] - - const altHeaders: Record = {} - const bodyPart = hasBothBodies - ? buildMultipartAlternative(input, altBoundary, altHeaders) - : buildSinglePart(input, altHeaders) - - parts.push( - [ - `--${outerBoundary}`, - `Content-Type: ${altHeaders["Content-Type"] ?? "text/plain; charset=utf-8"}`, - ...(altHeaders["Content-Transfer-Encoding"] - ? [`Content-Transfer-Encoding: ${altHeaders["Content-Transfer-Encoding"]}`] - : []), - "", - bodyPart, - ].join("\r\n"), - ) - - for (const a of input.attachments ?? []) { - parts.push(renderAttachment(outerBoundary, a)) - } - parts.push(`--${outerBoundary}--`) - return parts.join("\r\n") -} - -function renderAttachment(boundary: string, a: Attachment): string { - const base64 = - typeof a.content === "string" - ? isLikelyBase64(a.content) - ? a.content - : toBase64FromString(a.content) - : toBase64FromBytes(a.content) - const folded = foldBase64(base64) - const contentType = a.contentType ?? "application/octet-stream" - const disposition = a.disposition ?? "attachment" - const lines = [ - `--${boundary}`, - `Content-Type: ${contentType}; name="${encodeHeader(a.filename)}"`, - "Content-Transfer-Encoding: base64", - `Content-Disposition: ${disposition}; filename="${encodeHeader(a.filename)}"`, - ] - if (a.cid) lines.push(`Content-ID: <${a.cid}>`) - lines.push("", folded) - return lines.join("\r\n") -} - -/** Dot-stuff a body for DATA transmission per RFC 5321 §4.5.2. Lines that - * begin with `.` get an extra `.` prepended so the sequence `\r\n.\r\n` - * never appears inside the payload. Returns a single string with CRLF - * line endings. */ -export function dotStuff(body: string): string { - const crlfBody = body.replace(/\r?\n/g, "\r\n") - return crlfBody.replace(/(^|\r\n)(\.)/g, "$1.$2") -} - -function foldHeader(value: string, max = 76): string { - if (value.length <= max) return value - const words = value.split(" ") - const lines: string[] = [] - let current = "" - for (const word of words) { - if (current.length + word.length + 1 > max) { - lines.push(current) - current = ` ${word}` - } else { - current = current ? `${current} ${word}` : word - } - } - if (current) lines.push(current) - return lines.join("\r\n") -} - -function encodeHeader(value: string): string { - if (/^[\x20-\x7E]*$/.test(value)) return value - const b64 = toBase64FromString(value) - return `=?utf-8?B?${b64}?=` -} - -function encodeQuotedPrintable(input: string): string { - const out: string[] = [] - for (const ch of input) { - const code = ch.codePointAt(0)! - if (ch === "\n") { - out.push("\r\n") - continue - } - if (ch === "\r") continue - if (code === 0x20 || code === 0x09) { - out.push(ch) - continue - } - if (code >= 0x21 && code <= 0x7e && ch !== "=") { - out.push(ch) - continue - } - const bytes = new TextEncoder().encode(ch) - for (const b of bytes) out.push(`=${b.toString(16).toUpperCase().padStart(2, "0")}`) - } - return softWrap(out.join(""), 76) -} - -function softWrap(input: string, max: number): string { - const lines = input.split(/\r\n/) - return lines - .map((line) => { - if (line.length <= max) return line - const out: string[] = [] - let rest = line - while (rest.length > max - 1) { - let cut = max - 1 - while (cut > 0 && (rest[cut - 1] === "=" || (cut >= 2 && rest[cut - 2] === "="))) cut-- - out.push(`${rest.slice(0, cut)}=`) - rest = rest.slice(cut) - } - out.push(rest) - return out.join("\r\n") - }) - .join("\r\n") -} - -function toBase64FromString(value: string): string { - const bytes = new TextEncoder().encode(value) - return toBase64FromBytes(bytes) -} - -function toBase64FromBytes(bytes: Uint8Array): string { - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (enc: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} - -function isLikelyBase64(value: string): boolean { - return /^[A-Za-z0-9+/=\r\n]+$/.test(value) && value.length > 0 && value.length % 4 === 0 -} - -function foldBase64(b64: string, width = 76): string { - const chunks: string[] = [] - for (let i = 0; i < b64.length; i += width) chunks.push(b64.slice(i, i + width)) - return chunks.join("\r\n") -} - -function randomBoundary(): string { - return Math.random().toString(36).slice(2, 12) + Date.now().toString(36) -} - -function dedupe(values: string[]): string[] { - return [...new Set(values)] -} diff --git a/src/driver/brevo.ts b/src/driver/brevo.ts deleted file mode 100644 index baf56e1..0000000 --- a/src/driver/brevo.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { - Attachment, - DriverFactory, - EmailAddress, - EmailMessage, - EmailResult, - Result, -} from "../types.ts" -import { defineDriver } from "../_define.ts" -import { normalizeAddresses } from "../_normalize.ts" -import { createError, createRequiredError } from "../errors.ts" -import { httpJson } from "./_http.ts" - -export interface BrevoDriverOptions { - apiKey: string - endpoint?: string - fetch?: typeof fetch -} - -const DRIVER = "brevo" - -const brevo: DriverFactory = defineDriver((options) => { - if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey") - const endpoint = options.endpoint ?? "https://api.brevo.com" - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - return { - name: DRIVER, - options, - flags: { - html: true, - text: true, - attachments: true, - tagging: true, - tracking: true, - replyTo: true, - customHeaders: true, - scheduling: true, - templates: true, - }, - - async isAvailable() { - return Boolean(options.apiKey) - }, - - async send(msg) { - const payload = buildBrevoPayload(msg) - const res = await httpJson({ - fetch: fetchImpl, - driver: DRIVER, - url: `${endpoint}/v3/smtp/email`, - headers: { "api-key": options.apiKey }, - body: payload, - }) - if (res.error) return res as Result - const body = (res.data ?? {}) as { messageId?: string } - return { - data: { - id: body.messageId ?? `brevo_${Date.now().toString(36)}`, - driver: DRIVER, - at: new Date(), - provider: body as Record, - }, - error: null, - } - }, - } -}) - -export default brevo - -function buildBrevoPayload(msg: EmailMessage): Record { - const from = normalizeAddresses(msg.from)[0] - if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required") - - const payload: Record = { - sender: toBrevoAddress(from), - to: normalizeAddresses(msg.to).map(toBrevoAddress), - subject: msg.subject, - } - if (msg.cc) payload.cc = normalizeAddresses(msg.cc).map(toBrevoAddress) - if (msg.bcc) payload.bcc = normalizeAddresses(msg.bcc).map(toBrevoAddress) - if (msg.replyTo) { - const r = normalizeAddresses(msg.replyTo)[0] - if (r) payload.replyTo = toBrevoAddress(r) - } - if (msg.text) payload.textContent = msg.text - if (msg.html) payload.htmlContent = msg.html - if (msg.headers) payload.headers = msg.headers - if (msg.tags?.length) payload.tags = msg.tags.map((t) => t.name) - if (msg.scheduledAt) { - const d = msg.scheduledAt instanceof Date ? msg.scheduledAt : new Date(msg.scheduledAt) - payload.scheduledAt = d.toISOString() - } - if (msg.attachments?.length) payload.attachment = msg.attachments.map(toBrevoAttachment) - if (msg.template) { - if (msg.template.id) - payload.templateId = Number.parseInt(msg.template.id, 10) || msg.template.id - if (msg.template.variables) payload.params = { ...msg.template.variables } - } - return payload -} - -function toBrevoAddress(a: EmailAddress): Record { - return a.name ? { email: a.email, name: a.name } : { email: a.email } -} - -function toBrevoAttachment(a: Attachment): Record { - const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content) - return { name: a.filename, content } -} - -function bytesToBase64(bytes: Uint8Array): string { - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} diff --git a/src/driver/cloudflare-email-service.ts b/src/driver/cloudflare-email-service.ts deleted file mode 100644 index cebd548..0000000 --- a/src/driver/cloudflare-email-service.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { - Attachment, - DriverFactory, - EmailAddress, - EmailErrorCode, - EmailResult, - Result, -} from "../types.ts" -import { defineDriver } from "../_define.ts" -import { createError, createRequiredError, toEmailError } from "../errors.ts" -import { normalizeAddresses } from "../_normalize.ts" - -/** Cloudflare **Email Service** (Email Sending) outbound binding. - * - * Distinct from `unemail/driver/cloudflare-email`, which targets the older - * **Email Routing** API: that one constructs `EmailMessage` from the virtual - * `cloudflare:email` module and hands the binding raw RFC 5322 text. Email - * Service takes structured fields on the same `send_email` binding, so this - * driver needs no ambient global, no virtual module, and no MIME builder. - * Both are kept — pick the one matching the API your binding speaks. - * - * ```ts - * export default { - * async fetch(req, env) { - * const email = createEmail({ driver: cloudflareEmailService({ binding: env.EMAIL }) }) - * await email.send({ from, to, subject, html, text }) - * } - * } - * ``` - * - * Requires a `send_email` binding in `wrangler.jsonc` and a sender domain - * onboarded via `wrangler email sending enable `. */ -export interface CloudflareEmailServiceDriverOptions { - binding: CloudflareEmailServiceBinding -} - -export interface CloudflareEmailServiceBinding { - send: (message: CloudflareEmailServiceMessage) => Promise -} - -/** Structured payload accepted by the binding. Declared locally so the driver - * depends on neither `@cloudflare/workers-types` nor `cloudflare:email` — - * when those types are available, prefer them at the call site. */ -export interface CloudflareEmailServiceMessage { - from: EmailAddress - to: EmailAddress[] - subject: string - text?: string - html?: string - cc?: EmailAddress[] - bcc?: EmailAddress[] - replyTo?: EmailAddress - headers?: Record - attachments?: CloudflareEmailServiceAttachment[] -} - -export interface CloudflareEmailServiceAttachment { - content: string | Uint8Array - filename: string - type: string - disposition: "attachment" | "inline" - contentId?: string -} - -export interface CloudflareEmailSendResult { - messageId?: string -} - -const DRIVER = "cloudflare-email-service" - -/** The binding throws `Error`s carrying an `E_*` `code`. Map them onto our - * taxonomy so `retryable` is meaningful — otherwise the retry middleware - * would keep re-sending messages that a validation fix, not a retry, cures. */ -const ERROR_CODES: Record = { - E_VALIDATION_ERROR: "INVALID_OPTIONS", - E_FIELD_MISSING: "INVALID_OPTIONS", - E_TOO_MANY_RECIPIENTS: "INVALID_OPTIONS", - E_TOO_MANY_ATTACHMENTS: "INVALID_OPTIONS", - E_CONTENT_TOO_LARGE: "INVALID_OPTIONS", - E_HEADER_NOT_ALLOWED: "INVALID_OPTIONS", - E_HEADER_USE_API_FIELD: "INVALID_OPTIONS", - E_HEADER_VALUE_INVALID: "INVALID_OPTIONS", - E_HEADER_VALUE_TOO_LONG: "INVALID_OPTIONS", - E_HEADER_NAME_INVALID: "INVALID_OPTIONS", - E_HEADERS_TOO_LARGE: "INVALID_OPTIONS", - E_HEADERS_TOO_MANY: "INVALID_OPTIONS", - E_SENDER_NOT_VERIFIED: "AUTH", - E_SENDER_DOMAIN_NOT_AVAILABLE: "AUTH", - E_RECIPIENT_NOT_ALLOWED: "AUTH", - E_RECIPIENT_SUPPRESSED: "PROVIDER", - E_RATE_LIMIT_EXCEEDED: "RATE_LIMIT", - E_DAILY_LIMIT_EXCEEDED: "RATE_LIMIT", - E_DELIVERY_FAILED: "NETWORK", - E_INTERNAL_SERVER_ERROR: "NETWORK", -} - -function toDriverError(err: unknown) { - const code = ERROR_CODES[String((err as { code?: unknown })?.code ?? "")] - if (!code) return toEmailError(DRIVER, err) - return createError(DRIVER, code, (err as Error).message, { cause: err }) -} - -/** The binding validates every key it receives, so `name` is left out - * entirely rather than sent as `undefined`. */ -function toAddress(addr: EmailAddress): EmailAddress { - return addr.name ? { email: addr.email, name: addr.name } : { email: addr.email } -} - -/** Email Service takes structured attachments rather than MIME parts, so the - * mapping is a rename: `contentType` → `type`, `cid` → `contentId`. */ -function toCloudflareAttachment(file: Attachment): CloudflareEmailServiceAttachment { - const out: CloudflareEmailServiceAttachment = { - content: file.content, - filename: file.filename, - type: file.contentType ?? "application/octet-stream", - disposition: file.disposition ?? (file.cid ? "inline" : "attachment"), - } - if (file.cid) out.contentId = file.cid - return out -} - -const cloudflareEmailService: DriverFactory = - defineDriver((options) => { - if (!options?.binding) throw createRequiredError(DRIVER, "binding") - - return { - name: DRIVER, - options, - flags: { - html: true, - text: true, - attachments: true, - customHeaders: true, - replyTo: true, - }, - - async isAvailable() { - return true - }, - - async send(msg): Promise> { - try { - const from = normalizeAddresses(msg.from)[0] - const to = normalizeAddresses(msg.to).map(toAddress) - if (!from || !to.length) - return { - data: null, - error: createError(DRIVER, "INVALID_OPTIONS", "`from` and `to` are required"), - } - - const cc = normalizeAddresses(msg.cc).map(toAddress) - const bcc = normalizeAddresses(msg.bcc).map(toAddress) - const replyTo = normalizeAddresses(msg.replyTo)[0] - - const result = await options.binding.send({ - from: toAddress(from), - to, - subject: msg.subject, - text: msg.text, - html: msg.html, - cc: cc.length ? cc : undefined, - bcc: bcc.length ? bcc : undefined, - replyTo: replyTo ? toAddress(replyTo) : undefined, - headers: msg.headers, - attachments: msg.attachments?.length - ? msg.attachments.map(toCloudflareAttachment) - : undefined, - }) - - return { - data: { - id: result?.messageId ?? "", - driver: DRIVER, - at: new Date(), - }, - error: null, - } - } catch (err) { - return { data: null, error: toDriverError(err) } - } - }, - } - }) - -export default cloudflareEmailService diff --git a/src/driver/cloudflare-email.ts b/src/driver/cloudflare-email.ts deleted file mode 100644 index ddb2111..0000000 --- a/src/driver/cloudflare-email.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { DriverFactory, EmailResult, Result } from "../types.ts" -import { defineDriver } from "../_define.ts" -import { buildMime, normalizeMimeInput } from "./_smtp/mime.ts" -import { createError, createRequiredError, toEmailError } from "../errors.ts" -import { normalizeAddresses } from "../_normalize.ts" - -/** Cloudflare Email Workers outbound binding. Instantiate with the binding - * object defined in your \`wrangler.toml\` (\`send_email\` rule): - * - * ```ts - * export default { - * async fetch(req, env) { - * const email = createEmail({ driver: cloudflareEmail({ binding: env.SEND_EMAIL }) }) - * await email.send({ from, to, subject, text }) - * } - * } - * ``` - * - * The binding accepts a constructed \`EmailMessage\` (see Cloudflare docs — - * the SDK exposes \`new EmailMessage(from, to, raw)\` via the global - * \`postalmime\` bindings); we build raw RFC 5322 text ourselves. */ -export interface CloudflareEmailDriverOptions { - binding: CloudflareEmailBinding - /** Optional factory for the \`EmailMessage\` class. Defaults to - * \`globalThis.EmailMessage\`, which Workers injects at runtime. */ - EmailMessage?: CloudflareEmailMessageCtor -} - -export interface CloudflareEmailBinding { - send: (message: unknown) => Promise | void -} - -export type CloudflareEmailMessageCtor = new (from: string, to: string, raw: string) => unknown - -const DRIVER = "cloudflare-email" - -const cloudflareEmail: DriverFactory = - defineDriver((options) => { - if (!options?.binding) throw createRequiredError(DRIVER, "binding") - const Ctor = - options.EmailMessage ?? - (globalThis as { EmailMessage?: CloudflareEmailMessageCtor }).EmailMessage - if (!Ctor) - throw createError( - DRIVER, - "INVALID_OPTIONS", - "EmailMessage constructor is unavailable; pass it via options when not running on Cloudflare Workers", - ) - - return { - name: DRIVER, - options, - flags: { - html: true, - text: true, - attachments: true, - customHeaders: true, - replyTo: true, - }, - - async isAvailable() { - return true - }, - - async send(msg): Promise> { - try { - const from = normalizeAddresses(msg.from)[0] - const to = normalizeAddresses(msg.to)[0] - if (!from || !to) - return { - data: null, - error: createError(DRIVER, "INVALID_OPTIONS", "`from` and `to` are required"), - } - const messageId = - msg.headers?.["Message-ID"] ?? - `<${Date.now().toString(36)}.${Math.random().toString(36).slice(2)}@cloudflare-email>` - const mime = buildMime(normalizeMimeInput(msg, messageId)) - const message = new Ctor(from.email, to.email, mime.body) - await options.binding.send(message) - return { - data: { - id: messageId, - driver: DRIVER, - at: new Date(), - }, - error: null, - } - } catch (err) { - return { data: null, error: toEmailError(DRIVER, err) } - } - }, - } - }) - -export default cloudflareEmail diff --git a/src/driver/fallback.ts b/src/driver/fallback.ts deleted file mode 100644 index d5d1deb..0000000 --- a/src/driver/fallback.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { DriverFactory, EmailDriver } from "../types.ts" -import { defineDriver } from "../_define.ts" -import { createError, toEmailError } from "../errors.ts" - -/** Try each wrapped driver in order; move on to the next when the current - * one returns a retryable error. Non-retryable errors short-circuit. - * - * ```ts - * createEmail({ driver: fallback([resend({...}), ses({...})]) }) - * ``` - */ -export interface FallbackOptions { - drivers: ReadonlyArray - /** Override the "is this error worth moving on for" check. */ - shouldAdvance?: (error: NonNullable>["error"]>) => boolean -} - -const fallback: DriverFactory = defineDriver((options) => { - if (!options || options.drivers.length === 0) - throw createError("fallback", "INVALID_OPTIONS", "at least one driver is required") - const drivers = options.drivers - const shouldAdvance = options.shouldAdvance ?? ((err) => err.retryable) - - return { - name: "fallback", - options, - async send(msg, ctx) { - let lastError: ReturnType | null = null - for (const driver of drivers) { - ctx.driver = driver.name - try { - const result = await driver.send(msg, ctx) - if (result.data) return result - lastError = result.error - if (!shouldAdvance(result.error)) return result - } catch (thrown) { - lastError = toEmailError(driver.name, thrown) - } - } - return { - data: null, - error: lastError ?? createError("fallback", "PROVIDER", "all drivers failed"), - } - }, - async initialize() { - await Promise.all(drivers.map((d) => d.initialize?.())) - }, - async dispose() { - await Promise.all(drivers.map((d) => d.dispose?.())) - }, - async isAvailable() { - for (const d of drivers) { - if (!d.isAvailable) return true - if (await d.isAvailable()) return true - } - return false - }, - } -}) - -export default fallback diff --git a/src/driver/http.ts b/src/driver/http.ts deleted file mode 100644 index 5ef7fbb..0000000 --- a/src/driver/http.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { - Attachment, - DriverFactory, - EmailAddress, - EmailMessage, - EmailResult, - Result, -} from "../types.ts" -import { defineDriver } from "../_define.ts" -import { formatAddress, normalizeAddresses } from "../_normalize.ts" -import { createError, createRequiredError } from "../errors.ts" -import { httpJson } from "./_http.ts" - -/** Options for the generic `http` driver — useful for proxying through - * your own endpoint (a Next.js route handler, a hosted worker, a test - * harness, anything JSON-shaped). */ -export interface HttpDriverOptions { - /** The POST target. */ - endpoint: string - /** HTTP method. Defaults to POST. */ - method?: string - /** Bearer token sent as `Authorization: Bearer ` when set. */ - apiKey?: string - /** Extra headers merged on every request. */ - headers?: Record - /** Transform the normalized message into the payload shape your API - * expects. The default emits a sensible object similar to Resend's - * public shape. */ - transform?: (msg: EmailMessage) => unknown - /** Extract the provider-assigned id from the response body. Default: - * looks at \`id\`, \`messageId\`, \`data.id\`, \`data.messageId\`. */ - extractId?: (body: unknown) => string | null - /** Injected fetch — defaults to global \`fetch\`. */ - fetch?: typeof fetch -} - -const DRIVER = "http" - -const http: DriverFactory = defineDriver((options) => { - if (!options?.endpoint) throw createRequiredError(DRIVER, "endpoint") - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - const transform = options.transform ?? defaultTransform - const extractId = options.extractId ?? defaultExtractId - - return { - name: DRIVER, - options, - flags: { - html: true, - text: true, - attachments: true, - replyTo: true, - customHeaders: true, - }, - - async isAvailable() { - return true - }, - - async send(msg) { - const payload = transform(msg) - const headers: Record = { ...options.headers } - if (options.apiKey) headers.authorization = `Bearer ${options.apiKey}` - const res = await httpJson({ - fetch: fetchImpl, - driver: DRIVER, - url: options.endpoint, - method: options.method ?? "POST", - headers, - body: payload, - }) - if (res.error) return res as Result - const id = extractId(res.data) ?? synthId() - return { - data: { - id, - driver: DRIVER, - at: new Date(), - provider: (res.data ?? null) as Record | undefined, - }, - error: null, - } - }, - } -}) - -export default http - -function defaultTransform(msg: EmailMessage): Record { - const from = normalizeAddresses(msg.from)[0] - const out: Record = { - from: from ? formatAddress(from) : undefined, - to: normalizeAddresses(msg.to).map((a: EmailAddress) => formatAddress(a)), - subject: msg.subject, - } - if (msg.cc) out.cc = normalizeAddresses(msg.cc).map(formatAddress) - if (msg.bcc) out.bcc = normalizeAddresses(msg.bcc).map(formatAddress) - if (msg.replyTo) out.replyTo = normalizeAddresses(msg.replyTo).map(formatAddress) - if (msg.text) out.text = msg.text - if (msg.html) out.html = msg.html - if (msg.headers) out.headers = msg.headers - if (msg.attachments?.length) out.attachments = msg.attachments.map(toAttachmentPayload) - return out -} - -function toAttachmentPayload(a: Attachment): Record { - const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content) - const out: Record = { - filename: a.filename, - content, - } - if (a.contentType) out.contentType = a.contentType - if (a.disposition) out.disposition = a.disposition - if (a.cid) out.cid = a.cid - return out -} - -function defaultExtractId(body: unknown): string | null { - if (!body || typeof body !== "object") return null - const r = body as Record - if (typeof r.id === "string") return r.id - if (typeof r.messageId === "string") return r.messageId - if (r.data && typeof r.data === "object") { - const inner = r.data as Record - if (typeof inner.id === "string") return inner.id - if (typeof inner.messageId === "string") return inner.messageId - } - return null -} - -function synthId(): string { - return `http_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` -} - -function bytesToBase64(bytes: Uint8Array): string { - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} diff --git a/src/driver/loops.ts b/src/driver/loops.ts deleted file mode 100644 index 127abaf..0000000 --- a/src/driver/loops.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { DriverFactory, EmailResult, Result } from "../types.ts" -import { defineDriver } from "../_define.ts" -import { normalizeAddresses } from "../_normalize.ts" -import { createError, createRequiredError } from "../errors.ts" -import { httpJson } from "./_http.ts" - -/** The Loops transactional API takes \`transactionalId\` + \`email\` + \`dataVariables\`. - * Map from our \`EmailMessage\`: \`headers["x-loops-transactional-id"]\` or - * the driver's default \`transactionalId\` option carries the id; - * \`dataVariables\` come from \`msg.tags\` (repurposed as vars since Loops - * doesn't have free-form tag support). */ -export interface LoopsDriverOptions { - apiKey: string - /** Default Loops transactionalId if the message doesn't specify one. */ - transactionalId?: string - endpoint?: string - fetch?: typeof fetch -} - -const DRIVER = "loops" - -const loops: DriverFactory = defineDriver((options) => { - if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey") - const endpoint = options.endpoint ?? "https://app.loops.so" - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - return { - name: DRIVER, - options, - flags: { - templates: true, - tracking: true, - tagging: true, - }, - - async isAvailable() { - return Boolean(options.apiKey) - }, - - async send(msg) { - const transactionalId = - msg.template?.id ?? msg.headers?.["x-loops-transactional-id"] ?? options.transactionalId - if (!transactionalId) { - return { - data: null, - error: createError( - DRIVER, - "INVALID_OPTIONS", - "transactionalId is required: pass via msg.template.id, headers['x-loops-transactional-id'] or driver options", - ), - } - } - const to = normalizeAddresses(msg.to)[0] - if (!to) { - return { - data: null, - error: createError(DRIVER, "INVALID_OPTIONS", "`to` is required"), - } - } - const dataVariables: Record = { - ...msg.template?.variables, - } - for (const t of msg.tags ?? []) dataVariables[t.name] = t.value - - const res = await httpJson({ - fetch: fetchImpl, - driver: DRIVER, - url: `${endpoint}/api/v1/transactional`, - headers: { authorization: `Bearer ${options.apiKey}` }, - body: { - transactionalId, - email: to.email, - dataVariables, - }, - }) - if (res.error) return res as Result - const body = (res.data ?? {}) as { success?: boolean } - return { - data: { - id: `loops_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, - driver: DRIVER, - at: new Date(), - provider: body as Record, - }, - error: null, - } - }, - } -}) - -export default loops diff --git a/src/driver/mailchannels.ts b/src/driver/mailchannels.ts deleted file mode 100644 index 29630ae..0000000 --- a/src/driver/mailchannels.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { - Attachment, - DriverFactory, - EmailAddress, - EmailMessage, - EmailResult, - Result, -} from "../types.ts" -import { defineDriver } from "../_define.ts" -import { normalizeAddresses } from "../_normalize.ts" -import { createError } from "../errors.ts" -import { httpJson } from "./_http.ts" - -/** MailChannels — free transactional send from Cloudflare Workers (no auth - * needed when running inside a CF Worker; requires SPF/DKIM configured - * for your sending domain). Outside Workers you need an API key. */ -export interface MailChannelsDriverOptions { - /** Required when not running inside a Cloudflare Worker. */ - apiKey?: string - /** DKIM signing for non-Worker usage. */ - dkim?: { - domain: string - selector: string - privateKey: string - } - endpoint?: string - fetch?: typeof fetch -} - -const DRIVER = "mailchannels" - -const mailchannels: DriverFactory = - defineDriver((options = {}) => { - const endpoint = options.endpoint ?? "https://api.mailchannels.net" - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - return { - name: DRIVER, - options, - flags: { - html: true, - text: true, - attachments: true, - replyTo: true, - customHeaders: true, - }, - - async isAvailable() { - return true - }, - - async send(msg) { - const payload = buildMailChannelsPayload(msg, options) - const headers: Record = {} - if (options.apiKey) headers["x-api-key"] = options.apiKey - const res = await httpJson({ - fetch: fetchImpl, - driver: DRIVER, - url: `${endpoint}/tx/v1/send`, - headers, - body: payload, - }) - if (res.error) return res as Result - return { - data: { - id: `mc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, - driver: DRIVER, - at: new Date(), - provider: (res.data as Record | null) ?? undefined, - }, - error: null, - } - }, - } - }) - -export default mailchannels - -function buildMailChannelsPayload( - msg: EmailMessage, - options: MailChannelsDriverOptions, -): Record { - const from = normalizeAddresses(msg.from)[0] - if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required") - - const personalization: Record = { - to: normalizeAddresses(msg.to).map(toMcAddress), - } - if (msg.cc) personalization.cc = normalizeAddresses(msg.cc).map(toMcAddress) - if (msg.bcc) personalization.bcc = normalizeAddresses(msg.bcc).map(toMcAddress) - if (msg.headers) personalization.headers = msg.headers - if (options.dkim) { - personalization.dkim_domain = options.dkim.domain - personalization.dkim_selector = options.dkim.selector - personalization.dkim_private_key = options.dkim.privateKey - } - - const content: Array> = [] - if (msg.text) content.push({ type: "text/plain", value: msg.text }) - if (msg.html) content.push({ type: "text/html", value: msg.html }) - - const payload: Record = { - personalizations: [personalization], - from: toMcAddress(from), - subject: msg.subject, - content, - } - if (msg.replyTo) { - const r = normalizeAddresses(msg.replyTo)[0] - if (r) payload.reply_to = toMcAddress(r) - } - if (msg.attachments?.length) payload.attachments = msg.attachments.map(toMcAttachment) - return payload -} - -function toMcAddress(a: EmailAddress): Record { - return a.name ? { email: a.email, name: a.name } : { email: a.email } -} - -function toMcAttachment(a: Attachment): Record { - const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content) - return { - filename: a.filename, - content, - type: a.contentType ?? "application/octet-stream", - } -} - -function bytesToBase64(bytes: Uint8Array): string { - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} diff --git a/src/driver/mailcrab.ts b/src/driver/mailcrab.ts deleted file mode 100644 index 60e0bd3..0000000 --- a/src/driver/mailcrab.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { DriverFactory } from "../types.ts" -import type { SmtpDriverOptions } from "./smtp.ts" -import { defineDriver } from "../_define.ts" -import smtp from "./smtp.ts" - -/** MailCrab is a local SMTP sink (the \`unemail-mailcrab\` CLI spins it up - * via docker). This driver is a thin wrapper over the SMTP driver with - * MailCrab-friendly defaults so \`createEmail({ driver: mailcrab() })\` - * Just Works in dev. - * - * The web UI lives at \`http://localhost:1080\` — a one-liner pointer is - * printed on first use so new users find it fast. */ -export interface MailCrabDriverOptions extends Partial { - /** Defaults to \`localhost\`. */ - host?: string - /** Defaults to \`1025\` (MailCrab's SMTP port). */ - port?: number - /** Web UI port, used only for the on-first-send help message. Default 1080. */ - uiPort?: number - /** Silence the "see messages at …" hint. Default \`false\`. */ - quiet?: boolean -} - -const mailcrab: DriverFactory = defineDriver( - (options = {}) => { - const host = options.host ?? "localhost" - const port = options.port ?? 1025 - const uiPort = options.uiPort ?? 1080 - const delegate = smtp({ - ...options, - host, - port, - secure: false, - rejectUnauthorized: false, - commandTimeoutMs: options.commandTimeoutMs ?? 5000, - connectionTimeoutMs: options.connectionTimeoutMs ?? 5000, - }) - let hinted = false - - return { - ...delegate, - name: "mailcrab", - async send(msg, ctx) { - if (!hinted && !options.quiet) { - hinted = true - console.info(`[unemail] [mailcrab] inspecting messages at http://${host}:${uiPort}`) - } - const result = await delegate.send(msg, ctx) - if (result.data) return { data: { ...result.data, driver: "mailcrab" }, error: null } - return result - }, - } - }, -) - -export default mailcrab diff --git a/src/driver/mailersend.ts b/src/driver/mailersend.ts deleted file mode 100644 index 6c77e6d..0000000 --- a/src/driver/mailersend.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { - Attachment, - DriverFactory, - EmailAddress, - EmailMessage, - EmailResult, - Result, -} from "../types.ts" -import { defineDriver } from "../_define.ts" -import { normalizeAddresses } from "../_normalize.ts" -import { createError, createRequiredError } from "../errors.ts" -import { httpJson } from "./_http.ts" - -export interface MailerSendDriverOptions { - apiKey: string - endpoint?: string - fetch?: typeof fetch -} - -const DRIVER = "mailersend" - -const mailersend: DriverFactory = defineDriver( - (options) => { - if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey") - const endpoint = options.endpoint ?? "https://api.mailersend.com" - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - return { - name: DRIVER, - options, - flags: { - html: true, - text: true, - attachments: true, - tagging: true, - tracking: true, - replyTo: true, - customHeaders: true, - scheduling: true, - batch: true, - }, - - async isAvailable() { - return Boolean(options.apiKey) - }, - - async send(msg) { - const payload = buildMailerSendPayload(msg) - const res = await httpJson({ - fetch: fetchImpl, - driver: DRIVER, - url: `${endpoint}/v1/email`, - headers: { authorization: `Bearer ${options.apiKey}` }, - body: payload, - }) - if (res.error) return res as Result - // MailerSend returns 202 with `X-Message-Id` header; body is empty. - const id = `ms_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` - return { - data: { - id, - driver: DRIVER, - at: new Date(), - provider: (res.data as Record | null) ?? undefined, - }, - error: null, - } - }, - - async sendBatch(msgs) { - const payload = msgs.map(buildMailerSendPayload) - const res = await httpJson({ - fetch: fetchImpl, - driver: DRIVER, - url: `${endpoint}/v1/bulk-email`, - headers: { authorization: `Bearer ${options.apiKey}` }, - body: payload, - }) - if (res.error) return res as never - const body = (res.data ?? {}) as { bulk_email_id?: string } - const results: EmailResult[] = msgs.map((_, i) => ({ - id: `${body.bulk_email_id ?? "ms_bulk"}_${i}`, - driver: DRIVER, - at: new Date(), - provider: body as Record, - })) - return { data: results, error: null } - }, - } - }, -) - -export default mailersend - -function buildMailerSendPayload(msg: EmailMessage): Record { - const from = normalizeAddresses(msg.from)[0] - if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required") - - const payload: Record = { - from: toMsAddress(from), - to: normalizeAddresses(msg.to).map(toMsAddress), - subject: msg.subject, - } - if (msg.cc) payload.cc = normalizeAddresses(msg.cc).map(toMsAddress) - if (msg.bcc) payload.bcc = normalizeAddresses(msg.bcc).map(toMsAddress) - if (msg.replyTo) { - const r = normalizeAddresses(msg.replyTo)[0] - if (r) payload.reply_to = toMsAddress(r) - } - if (msg.text) payload.text = msg.text - if (msg.html) payload.html = msg.html - if (msg.tags?.length) payload.tags = msg.tags.map((t) => t.name) - if (msg.headers) - payload.headers = Object.entries(msg.headers).map(([name, value]) => ({ name, value })) - if (msg.scheduledAt) { - const d = msg.scheduledAt instanceof Date ? msg.scheduledAt : new Date(msg.scheduledAt) - payload.send_at = Math.floor(d.getTime() / 1000) - } - if (msg.attachments?.length) payload.attachments = msg.attachments.map(toMsAttachment) - if (msg.template) { - if (msg.template.id) payload.template_id = msg.template.id - if (msg.template.variables) { - payload.personalization = [ - { - email: normalizeAddresses(msg.to)[0]?.email, - data: { ...msg.template.variables }, - }, - ] - } - } - return payload -} - -function toMsAddress(a: EmailAddress): Record { - return a.name ? { email: a.email, name: a.name } : { email: a.email } -} - -function toMsAttachment(a: Attachment): Record { - const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content) - const out: Record = { - filename: a.filename, - content, - disposition: a.disposition ?? "attachment", - } - if (a.cid) out.id = a.cid - return out -} - -function bytesToBase64(bytes: Uint8Array): string { - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} diff --git a/src/driver/mailgun.ts b/src/driver/mailgun.ts deleted file mode 100644 index 683a955..0000000 --- a/src/driver/mailgun.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { DriverFactory, EmailMessage, EmailResult, Result } from "../types.ts" -import { defineDriver } from "../_define.ts" -import { formatAddress, normalizeAddresses } from "../_normalize.ts" -import { createError, createRequiredError, toEmailError } from "../errors.ts" - -export interface MailgunDriverOptions { - apiKey: string - domain: string - /** Region preset — `"us"` (default) or `"eu"`. Maps to the matching - * Mailgun API endpoint. Overridden by `endpoint`. */ - region?: "us" | "eu" - /** Explicit API endpoint override (highest precedence). */ - endpoint?: string - fetch?: typeof fetch -} - -const DRIVER = "mailgun" - -const mailgun: DriverFactory = defineDriver( - (options) => { - if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey") - if (!options?.domain) throw createRequiredError(DRIVER, "domain") - const endpoint = - options.endpoint ?? - (options.region === "eu" ? "https://api.eu.mailgun.net" : "https://api.mailgun.net") - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - return { - name: DRIVER, - options, - flags: { - html: true, - text: true, - attachments: true, - tagging: true, - tracking: true, - replyTo: true, - customHeaders: true, - scheduling: true, - }, - - async isAvailable() { - return Boolean(options.apiKey && options.domain) - }, - - async send(msg) { - const form = buildMailgunForm(msg) - return mailgunRequest( - fetchImpl, - `${endpoint}/v3/${options.domain}/messages`, - options.apiKey, - form, - ) - }, - } - }, -) - -export default mailgun - -function buildMailgunForm(msg: EmailMessage): FormData { - const from = normalizeAddresses(msg.from)[0] - if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required") - - const form = new FormData() - form.append("from", formatAddress(from)) - for (const t of normalizeAddresses(msg.to)) form.append("to", formatAddress(t)) - for (const c of normalizeAddresses(msg.cc)) form.append("cc", formatAddress(c)) - for (const b of normalizeAddresses(msg.bcc)) form.append("bcc", formatAddress(b)) - form.append("subject", msg.subject) - if (msg.text) form.append("text", msg.text) - if (msg.html) form.append("html", msg.html) - for (const r of normalizeAddresses(msg.replyTo)) form.append("h:Reply-To", formatAddress(r)) - if (msg.headers) { - for (const [k, v] of Object.entries(msg.headers)) form.append(`h:${k}`, v) - } - if (msg.tags?.length) { - for (const t of msg.tags) form.append("o:tag", t.name) - } - if (msg.scheduledAt) { - const d = msg.scheduledAt instanceof Date ? msg.scheduledAt : new Date(msg.scheduledAt) - form.append("o:deliverytime", d.toUTCString()) - } - if (msg.attachments?.length) { - for (const a of msg.attachments) { - const blob = new Blob( - [typeof a.content === "string" ? a.content : (a.content as unknown as BlobPart)], - { type: a.contentType ?? "application/octet-stream" }, - ) - form.append("attachment", blob, a.filename) - } - } - if (msg.template) { - if (msg.template.id) form.append("template", msg.template.id) - if (msg.template.variables) - form.append("h:X-Mailgun-Variables", JSON.stringify(msg.template.variables)) - } - if (msg.sandbox) form.append("o:testmode", "yes") - if (msg.tracking) { - if (msg.tracking.opens !== undefined) - form.append("o:tracking-opens", msg.tracking.opens ? "yes" : "no") - if (msg.tracking.clicks !== undefined) - form.append("o:tracking-clicks", msg.tracking.clicks ? "yes" : "no") - if (msg.tracking.opens !== undefined || msg.tracking.clicks !== undefined) - form.append("o:tracking", "yes") - } - if (msg.metadata) { - for (const [k, v] of Object.entries(msg.metadata)) form.append(`v:${k}`, v) - } - return form -} - -async function mailgunRequest( - fetchImpl: typeof fetch, - url: string, - apiKey: string, - form: FormData, -): Promise> { - const auth = `Basic ${basicAuth("api", apiKey)}` - let res: Response - try { - res = await fetchImpl(url, { - method: "POST", - headers: { authorization: auth, accept: "application/json" }, - body: form, - }) - } catch (err) { - return { data: null, error: toEmailError(DRIVER, err) } - } - const text = await res.text() - const parsed = text ? safeJson(text) : null - if (!res.ok) { - const body = (parsed ?? {}) as { message?: string } - const code = - res.status === 401 || res.status === 403 - ? "AUTH" - : res.status === 429 - ? "RATE_LIMIT" - : res.status >= 500 - ? "NETWORK" - : "PROVIDER" - return { - data: null, - error: createError(DRIVER, code, body.message ?? `HTTP ${res.status}`, { - status: res.status, - retryable: code === "RATE_LIMIT" || code === "NETWORK", - cause: { headers: res.headers, body: parsed ?? text }, - }), - } - } - const body = (parsed ?? {}) as { id?: string; message?: string } - const id = body.id ?? `mg_${Date.now().toString(36)}` - return { - data: { - id: id.replace(/^<|>$/g, ""), - driver: DRIVER, - at: new Date(), - provider: body as Record, - }, - error: null, - } -} - -function basicAuth(user: string, pass: string): string { - const raw = `${user}:${pass}` - const g = globalThis as { Buffer?: { from: (v: string) => { toString: (e: string) => string } } } - if (g.Buffer) return g.Buffer.from(raw).toString("base64") - return btoa(raw) -} - -function safeJson(text: string): unknown { - try { - return JSON.parse(text) - } catch { - return null - } -} diff --git a/src/driver/mailtrap.ts b/src/driver/mailtrap.ts deleted file mode 100644 index caa1dd8..0000000 --- a/src/driver/mailtrap.ts +++ /dev/null @@ -1,391 +0,0 @@ -import type { - Attachment, - DriverFactory, - EmailAddress, - EmailMessage, - EmailResult, - EmailTag, - Result, -} from "../types.ts" -import { defineDriver } from "../_define.ts" -import { normalizeAddresses } from "../_normalize.ts" -import { createError, createRequiredError } from "../errors.ts" -import { httpJson } from "./_http.ts" - -/** Mailtrap driver — Email API sending and Email Sandbox testing - * with the same API token. Mirrors the official SDK env pattern: - * \`MAILTRAP_API_KEY\` → \`apiKey\`, \`MAILTRAP_USE_SANDBOX\` → \`sandbox\`, - * \`MAILTRAP_INBOX_ID\` → \`inboxId\`. */ -export interface MailtrapDriverOptions { - apiKey: string - /** Email API base. Default \`https://send.api.mailtrap.io\`. */ - endpoint?: string - fetch?: typeof fetch - /** Used when no \`tags\` entry has \`name: "category"\`. */ - defaultCategory?: string - /** Mailtrap edge protection may block requests without a User-Agent. */ - userAgent?: string - /** Default sandbox mode when \`msg.sandbox\` is unset. */ - sandbox?: boolean - /** Sandbox inbox ID (from mailtrap.io/sandboxes/{id}). Required for sandbox sends. */ - inboxId?: number | string - /** Sandbox API base. Default \`https://sandbox.api.mailtrap.io\`. */ - sandboxEndpoint?: string -} - -interface MailtrapSendSuccess { - success?: boolean - message_ids?: string[] - errors?: string[] -} - -interface MailtrapBatchItemResponse { - success?: boolean - message_ids?: string[] - errors?: string[] -} - -interface MailtrapBatchSuccess { - success?: boolean - responses?: MailtrapBatchItemResponse[] - errors?: string[] -} - -const DRIVER = "mailtrap" -const DEFAULT_ENDPOINT = "https://send.api.mailtrap.io" -const DEFAULT_SANDBOX_ENDPOINT = "https://sandbox.api.mailtrap.io" - -const mailtrap: DriverFactory = defineDriver( - (options) => { - if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey") - - const sendEndpoint = options.endpoint ?? DEFAULT_ENDPOINT - const sandboxEndpoint = options.sandboxEndpoint ?? DEFAULT_SANDBOX_ENDPOINT - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - const defaultCategory = options.defaultCategory ?? "transactional" - const userAgent = options.userAgent ?? "unemail/mailtrap" - - const mailtrapHeaders = (): Record => ({ - "api-token": options.apiKey, - "user-agent": userAgent, - }) - - return { - name: DRIVER, - options, - flags: { - attachments: true, - html: true, - text: true, - batch: true, - templates: true, - tagging: true, - replyTo: true, - customHeaders: true, - }, - - async isAvailable() { - return Boolean(options.apiKey) - }, - - async send(msg) { - const unsupported = rejectUnsupported(msg) - if (unsupported) return unsupported - - const useSandbox = resolveSandboxMode(msg, options) - const inboxErr = requireInboxIdForSandbox(useSandbox, options) - if (inboxErr) return inboxErr as Result - - const payload = buildPayload(msg, defaultCategory) - const res = await httpJson({ - fetch: fetchImpl, - driver: DRIVER, - url: resolveApiUrl(useSandbox, "send", options, sendEndpoint, sandboxEndpoint), - headers: mailtrapHeaders(), - body: payload, - classifyError: classifyMailtrapError, - }) - if (res.error) return res as Result - return parseSendSuccess(res.data) - }, - - async sendBatch(msgs) { - if (msgs.length === 0) return { data: [], error: null } - for (const msg of msgs) { - const unsupported = rejectUnsupported(msg) - if (unsupported) return unsupported as Result> - } - - const mixed = validateBatchSandboxModes(msgs, options) - if (mixed) return mixed as Result> - - const useSandbox = resolveSandboxMode(msgs[0]!, options) - const inboxErr = requireInboxIdForSandbox(useSandbox, options) - if (inboxErr) return inboxErr as Result> - - const payload = { - requests: msgs.map((m) => buildPayload(m, defaultCategory)), - } - const res = await httpJson({ - fetch: fetchImpl, - driver: DRIVER, - url: resolveApiUrl(useSandbox, "batch", options, sendEndpoint, sandboxEndpoint), - headers: mailtrapHeaders(), - body: payload, - classifyError: classifyMailtrapError, - }) - if (res.error) return res as never - - const body = (res.data ?? {}) as MailtrapBatchSuccess - if (body.success === false) { - return { - data: null, - error: createError( - DRIVER, - "PROVIDER", - formatErrors(body.errors) ?? "batch request failed", - { cause: body }, - ), - } - } - - const responses = body.responses ?? [] - for (let i = 0; i < responses.length; i++) { - const item = responses[i] - if (item?.success === false) { - return { - data: null, - error: createError( - DRIVER, - "PROVIDER", - formatErrors(item.errors) || `batch item ${i} failed`, - { cause: item }, - ), - } - } - } - - const results: EmailResult[] = responses.map((item, i) => ({ - id: item?.message_ids?.[0] ?? `mailtrap_${Date.now().toString(36)}_${i}`, - driver: DRIVER, - at: new Date(), - provider: item as unknown as Record, - })) - return { data: results, error: null } - }, - } - }, -) - -export default mailtrap - -function resolveSandboxMode(msg: EmailMessage, options: MailtrapDriverOptions): boolean { - return msg.sandbox ?? options.sandbox ?? false -} - -function isValidInboxId(inboxId: number | string | undefined): boolean { - if (inboxId === undefined || inboxId === null) return false - if (typeof inboxId === "number") return true - return String(inboxId).trim().length > 0 -} - -function requireInboxIdForSandbox( - useSandbox: boolean, - options: MailtrapDriverOptions, -): Result | null { - if (!useSandbox) return null - if (!isValidInboxId(options.inboxId)) { - return { - data: null, - error: createError( - DRIVER, - "INVALID_OPTIONS", - "`inboxId` is required for Mailtrap Email Sandbox", - { retryable: false }, - ), - } - } - return null -} - -function resolveApiUrl( - useSandbox: boolean, - kind: "send" | "batch", - options: MailtrapDriverOptions, - sendEndpoint: string, - sandboxEndpoint: string, -): string { - const host = useSandbox ? sandboxEndpoint : sendEndpoint - const suffix = useSandbox && isValidInboxId(options.inboxId) ? `/${options.inboxId}` : "" - return `${host}/api/${kind}${suffix}` -} - -function validateBatchSandboxModes( - msgs: ReadonlyArray, - options: MailtrapDriverOptions, -): Result | null { - if (msgs.length === 0) return null - const first = resolveSandboxMode(msgs[0]!, options) - for (let i = 1; i < msgs.length; i++) { - if (resolveSandboxMode(msgs[i]!, options) !== first) { - return { - data: null, - error: createError( - DRIVER, - "INVALID_OPTIONS", - "mixed Email Sandbox and Email API messages in one batch", - { retryable: false }, - ), - } - } - } - return null -} - -function rejectUnsupported(msg: EmailMessage): Result | null { - if (msg.scheduledAt) { - return { - data: null, - error: createError(DRIVER, "UNSUPPORTED", "scheduling is not supported by Mailtrap", { - retryable: false, - }), - } - } - return null -} - -function parseSendSuccess(data: unknown): Result { - const body = (data ?? {}) as MailtrapSendSuccess - if (body.success === false) { - return { - data: null, - error: createError(DRIVER, "PROVIDER", formatErrors(body.errors) ?? "send failed", { - cause: body, - }), - } - } - const id = body.message_ids?.[0] ?? `mailtrap_${Date.now().toString(36)}` - return { - data: { - id, - driver: DRIVER, - at: new Date(), - provider: body as Record, - }, - error: null, - } -} - -function classifyMailtrapError( - status: number, - body: unknown, -): { - code: "AUTH" | "RATE_LIMIT" | "NETWORK" | "PROVIDER" - retryable?: boolean - message?: string -} | null { - const record = body && typeof body === "object" ? (body as Record) : null - const errors = record?.errors - const message = - formatErrors(Array.isArray(errors) ? (errors as string[]) : undefined) ?? - (typeof record?.message === "string" ? record.message : null) - - if (status === 401 || status === 403) { - return { code: "AUTH", retryable: false, message: message ?? undefined } - } - if (status === 429) { - return { code: "RATE_LIMIT", retryable: true, message: message ?? undefined } - } - if (status >= 500) { - return { code: "NETWORK", retryable: true, message: message ?? undefined } - } - if (message) return { code: "PROVIDER", retryable: false, message } - return null -} - -function formatErrors(errors: string[] | undefined): string | null { - if (!errors?.length) return null - return errors.join("; ") -} - -function buildPayload(msg: EmailMessage, defaultCategory: string): Record { - const from = normalizeAddresses(msg.from)[0] - if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required") - - const payload: Record = { - from: toMailtrapAddress(from), - to: normalizeAddresses(msg.to).map(toMailtrapAddress), - subject: msg.subject, - } - if (msg.cc) payload.cc = normalizeAddresses(msg.cc).map(toMailtrapAddress) - if (msg.bcc) payload.bcc = normalizeAddresses(msg.bcc).map(toMailtrapAddress) - if (msg.replyTo) { - const r = normalizeAddresses(msg.replyTo)[0] - if (r) payload.reply_to = toMailtrapAddress(r) - } - if (msg.text) payload.text = msg.text - if (msg.html) payload.html = msg.html - if (msg.headers) payload.headers = msg.headers - if (msg.attachments?.length) payload.attachments = msg.attachments.map(toMailtrapAttachment) - - const customVars: Record = {} - if (msg.metadata) { - for (const [k, v] of Object.entries(msg.metadata)) customVars[k] = String(v) - } - if (msg.tags?.length) { - for (const tag of msg.tags) { - if (tag.name === "category") continue - customVars[`tag_${tag.name}`] = tag.value ?? "" - } - } - if (Object.keys(customVars).length) payload.custom_variables = customVars - - payload.category = resolveCategory(msg.tags, defaultCategory) - - if (msg.template) { - if (msg.template.id) payload.template_uuid = msg.template.id - if (msg.template.variables) payload.template_variables = { ...msg.template.variables } - } - - if (!msg.template && !msg.text && !msg.html) { - throw createError(DRIVER, "INVALID_OPTIONS", "`text`, `html`, or `template` is required") - } - - return payload -} - -function resolveCategory(tags: ReadonlyArray | undefined, fallback: string): string { - const cat = tags?.find((t) => t.name === "category") - if (cat?.value) return cat.value - if (cat?.name === "category" && !cat.value) return fallback - return fallback -} - -function toMailtrapAddress(a: EmailAddress): Record { - return a.name ? { email: a.email, name: a.name } : { email: a.email } -} - -function toMailtrapAttachment(a: Attachment): Record { - const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content) - const out: Record = { - content, - filename: a.filename, - } - if (a.contentType) out.type = a.contentType - if (a.disposition) out.disposition = a.disposition - if (a.cid) out.content_id = a.cid - return out -} - -function bytesToBase64(bytes: Uint8Array): string { - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} diff --git a/src/driver/mock.ts b/src/driver/mock.ts deleted file mode 100644 index 1f247a2..0000000 --- a/src/driver/mock.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { DriverFactory, EmailMessage, EmailResult } from "../types.ts" -import { defineDriver } from "../_define.ts" - -/** Options for the `mock` driver — a drop-in replacement used in tests that - * records every sent message instead of hitting the network. */ -export interface MockDriverOptions { - /** When true, drivers simulate a rejection on every send — useful for - * exercising `onError` middleware. */ - fail?: boolean - /** Inspect or mutate the captured inbox. Exposed via `driver.inbox` too. */ - inbox?: EmailMessage[] -} - -/** Driver with an injected `inbox` you can assert against. Also returned as - * `driver.getInstance()`. */ -const mock: DriverFactory = defineDriver< - MockDriverOptions, - EmailMessage[] ->((options) => { - const inbox: EmailMessage[] = options?.inbox ?? [] - let counter = 0 - - return { - name: "mock", - options, - flags: { - attachments: true, - html: true, - text: true, - batch: true, - replyTo: true, - customHeaders: true, - tagging: true, - idempotency: true, - scheduling: true, - }, - getInstance: () => inbox, - async isAvailable() { - return !options?.fail - }, - send(msg, ctx) { - if (options?.fail) { - return { - data: null, - error: new (class extends Error {})(`[unemail] [mock] configured to fail`) as never, - } - } - inbox.push(msg) - const result: EmailResult = { - id: `mock_${++counter}_${Date.now()}`, - driver: "mock", - stream: ctx.stream, - at: new Date(), - } - return { data: result, error: null } - }, - async sendBatch(msgs, ctx) { - const out: EmailResult[] = [] - for (const msg of msgs) { - const r = await this.send(msg, ctx) - if (r.error) return r as never - out.push(r.data!) - } - return { data: out, error: null } - }, - } -}) - -export default mock diff --git a/src/driver/postmark.ts b/src/driver/postmark.ts deleted file mode 100644 index 02d6c51..0000000 --- a/src/driver/postmark.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { - Attachment, - DriverFactory, - EmailAddress, - EmailMessage, - EmailResult, - Result, -} from "../types.ts" -import { defineDriver } from "../_define.ts" -import { formatAddress, normalizeAddresses } from "../_normalize.ts" -import { createError, createRequiredError, toEmailError } from "../errors.ts" - -/** Options for the Postmark driver. Postmark is the only mainstream - * provider with native transactional vs broadcast stream isolation — - * route by `msg.stream`, or set `messageStream` as a driver-level default. */ -export interface PostmarkDriverOptions { - /** Server API token (the per-server token, not the account token). */ - token: string - /** Default \`MessageStream\` if the message doesn't specify \`stream\`. */ - messageStream?: string - /** Override for self-hosted gateways or test stubs. */ - endpoint?: string - /** Injected fetch — defaults to global \`fetch\`. */ - fetch?: typeof fetch -} - -const DRIVER = "postmark" -const DEFAULT_ENDPOINT = "https://api.postmarkapp.com" - -const postmark: DriverFactory = defineDriver( - (options) => { - if (!options?.token) throw createRequiredError(DRIVER, "token") - const endpoint = options.endpoint ?? DEFAULT_ENDPOINT - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - return { - name: DRIVER, - options, - flags: { - attachments: true, - html: true, - text: true, - batch: true, - tracking: true, - templates: true, - tagging: true, - replyTo: true, - customHeaders: true, - }, - - async isAvailable() { - return Boolean(options.token) - }, - - async send(msg) { - const payload = buildPayload(msg, options.messageStream) - const path = msg.template ? "/email/withTemplate" : "/email" - const res = await request(fetchImpl, endpoint, path, "POST", options.token, payload) - if (res.error) return res as Result - const body = res.data as PostmarkSendResponse - const result: EmailResult = { - id: body.MessageID, - driver: DRIVER, - stream: msg.stream ?? options.messageStream, - at: parsePostmarkDate(body.SubmittedAt) ?? new Date(), - provider: body as unknown as Record, - } - return { data: result, error: null } - }, - - async sendBatch(msgs) { - const payload = msgs.map((m) => buildPayload(m, options.messageStream)) - const anyTemplate = msgs.some((m) => m.template) - const path = anyTemplate ? "/email/batchWithTemplates" : "/email/batch" - const requestBody = anyTemplate ? { Messages: payload } : payload - const res = await request(fetchImpl, endpoint, path, "POST", options.token, requestBody) - if (res.error) return res as never - const responses = res.data as PostmarkSendResponse[] - const failures = responses.filter((entry) => (entry.ErrorCode ?? 0) !== 0) - if (failures.length > 0) { - const first = failures[0]! - return { - data: null, - error: createError( - DRIVER, - "PROVIDER", - first.Message ?? `batch partial failure (${failures.length}/${responses.length})`, - { - status: first.ErrorCode, - cause: responses, - retryable: false, - }, - ), - } - } - const results: EmailResult[] = responses.map((entry, i) => ({ - id: entry.MessageID, - driver: DRIVER, - stream: msgs[i]?.stream ?? options.messageStream, - at: parsePostmarkDate(entry.SubmittedAt) ?? new Date(), - provider: entry as unknown as Record, - })) - return { data: results, error: null } - }, - } - }, -) - -export default postmark - -interface PostmarkSendResponse { - MessageID: string - SubmittedAt?: string - To?: string - ErrorCode?: number - Message?: string -} - -function buildPayload(msg: EmailMessage, defaultStream?: string): Record { - const from = normalizeAddresses(msg.from)[0] - if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required") - - const body: Record = { - From: formatAddress(from), - To: addressList(msg.to), - Subject: msg.subject, - } - if (msg.cc) body.Cc = addressList(msg.cc) - if (msg.bcc) body.Bcc = addressList(msg.bcc) - if (msg.replyTo) body.ReplyTo = addressList(msg.replyTo) - if (msg.text) body.TextBody = msg.text - if (msg.html) body.HtmlBody = msg.html - if (msg.headers) - body.Headers = Object.entries(msg.headers).map(([Name, Value]) => ({ Name, Value })) - // Postmark treats Metadata as the metadata bag. Prefer msg.metadata; fall back to tags. - if (msg.metadata) body.Metadata = { ...msg.metadata } - else if (msg.tags?.length) - body.Metadata = Object.fromEntries(msg.tags.map((t) => [t.name, t.value])) - if (msg.tags?.length) body.Tag = msg.tags[0]!.name - if (msg.tracking?.opens !== undefined) body.TrackOpens = msg.tracking.opens - if (msg.tracking?.clicks !== undefined) - body.TrackLinks = msg.tracking.clicks ? "HtmlAndText" : "None" - if (msg.attachments?.length) body.Attachments = msg.attachments.map(toPostmarkAttachment) - if (msg.template) { - if (msg.template.id) body.TemplateId = Number.parseInt(msg.template.id, 10) || msg.template.id - if (msg.template.alias) body.TemplateAlias = msg.template.alias - if (msg.template.variables) body.TemplateModel = { ...msg.template.variables } - } - const stream = msg.stream ?? defaultStream - if (stream) body.MessageStream = stream - return body -} - -function addressList(input: EmailMessage["to"]): string { - return normalizeAddresses(input) - .map((a: EmailAddress) => formatAddress(a)) - .join(", ") -} - -function toPostmarkAttachment(a: Attachment): Record { - const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content) - const out: Record = { - Name: a.filename, - Content: content, - ContentType: a.contentType ?? "application/octet-stream", - } - if (a.cid) out.ContentID = `cid:${a.cid}` - return out -} - -function bytesToBase64(bytes: Uint8Array): string { - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} - -function parsePostmarkDate(value?: string): Date | null { - if (!value) return null - const d = new Date(value) - return Number.isNaN(d.getTime()) ? null : d -} - -async function request( - fetchImpl: typeof fetch, - endpoint: string, - path: string, - method: string, - token: string, - body: unknown, -): Promise> { - let res: Response - try { - res = await fetchImpl(`${endpoint}${path}`, { - method, - headers: { - accept: "application/json", - "content-type": "application/json", - "x-postmark-server-token": token, - }, - body: JSON.stringify(body), - }) - } catch (err) { - return { data: null, error: toEmailError(DRIVER, err) } - } - - const text = await res.text() - const parsed = text ? safeJson(text) : null - - if (!res.ok) { - const apiError = (parsed ?? {}) as { Message?: string; ErrorCode?: number } - const code = - res.status === 401 || res.status === 403 || apiError.ErrorCode === 10 - ? "AUTH" - : res.status === 429 - ? "RATE_LIMIT" - : res.status >= 500 - ? "NETWORK" - : "PROVIDER" - return { - data: null, - error: createError(DRIVER, code, apiError.Message ?? `HTTP ${res.status}`, { - status: res.status, - cause: { headers: res.headers, body: parsed ?? text }, - retryable: code === "RATE_LIMIT" || code === "NETWORK", - }), - } - } - - return { data: parsed, error: null } -} - -function safeJson(text: string): unknown { - try { - return JSON.parse(text) - } catch { - return null - } -} diff --git a/src/driver/resend.ts b/src/driver/resend.ts deleted file mode 100644 index bdac3d2..0000000 --- a/src/driver/resend.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { - Attachment, - DriverFactory, - EmailAddress, - EmailMessage, - EmailResult, - EmailTag, - Result, - SendStatus, - SendStatusState, -} from "../types.ts" -import { defineDriver } from "../_define.ts" -import { formatAddress, normalizeAddresses } from "../_normalize.ts" -import { createError, createRequiredError, toEmailError } from "../errors.ts" - -/** Options for the Resend driver. Keep the surface minimal — everything - * Resend-specific (tags, scheduling, idempotency) is carried on the - * `EmailMessage` itself. */ -export interface ResendDriverOptions { - apiKey: string - /** Override for self-hosted gateways or test stubs. */ - endpoint?: string - /** Fetch impl — useful for tests. Defaults to the global `fetch`. */ - fetch?: typeof fetch -} - -interface ResendApiSuccess { - id: string - [k: string]: unknown -} - -interface ResendApiError { - name?: string - message?: string - statusCode?: number -} - -const DRIVER = "resend" -const DEFAULT_ENDPOINT = "https://api.resend.com" - -const resend: DriverFactory = defineDriver((options) => { - if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey") - if (!options.apiKey.startsWith("re_")) - throw createError(DRIVER, "INVALID_OPTIONS", "apiKey must start with 're_'") - - const endpoint = options.endpoint ?? DEFAULT_ENDPOINT - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - return { - name: DRIVER, - options, - flags: { - attachments: true, - html: true, - text: true, - batch: true, - scheduling: true, - idempotency: true, - tagging: true, - replyTo: true, - customHeaders: true, - cancelable: true, - retrievable: true, - }, - - async isAvailable() { - return Boolean(options.apiKey) - }, - - async send(msg) { - const payload = buildPayload(msg) - const res = await request(fetchImpl, endpoint, "/emails", "POST", options.apiKey, payload, { - idempotencyKey: msg.idempotencyKey, - }) - if (res.error) return res as Result - const data = res.data as ResendApiSuccess - return { - data: { - id: data.id, - driver: DRIVER, - at: new Date(), - provider: data, - }, - error: null, - } - }, - - async cancel(id) { - const res = await request( - fetchImpl, - endpoint, - `/emails/${id}/cancel`, - "POST", - options.apiKey, - {}, - ) - if (res.error) return res as Result - return { data: undefined, error: null } - }, - - async retrieve(id) { - const res = await request(fetchImpl, endpoint, `/emails/${id}`, "GET", options.apiKey, null) - if (res.error) return res as Result - const body = (res.data ?? {}) as { - id?: string - last_event?: string - created_at?: string - } - return { - data: { - id: body.id ?? id, - driver: DRIVER, - state: mapResendStatus(body.last_event), - at: body.created_at ? new Date(body.created_at) : undefined, - provider: body, - }, - error: null, - } - }, - - async sendBatch(msgs) { - const payload = msgs.map((m) => buildPayload(m)) - const res = await request( - fetchImpl, - endpoint, - "/emails/batch", - "POST", - options.apiKey, - payload, - ) - if (res.error) return res as never - const body = (res.data ?? {}) as { data?: Array<{ id: string }> } - const items = body.data ?? [] - return { - data: items.map((entry) => ({ - id: entry.id, - driver: DRIVER, - at: new Date(), - provider: entry, - })), - error: null, - } - }, - } -}) - -export default resend - -function buildPayload(msg: EmailMessage): Record { - const from = normalizeAddresses(msg.from)[0] - if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required") - - const body: Record = { - from: formatAddress(from), - to: addressList(msg.to), - subject: msg.subject, - } - if (msg.cc) body.cc = addressList(msg.cc) - if (msg.bcc) body.bcc = addressList(msg.bcc) - if (msg.replyTo) body.reply_to = addressList(msg.replyTo) - if (msg.text) body.text = msg.text - if (msg.html) body.html = msg.html - if (msg.headers) body.headers = msg.headers - if (msg.tags) body.tags = msg.tags.map((t: EmailTag) => ({ name: t.name, value: t.value })) - if (msg.metadata) { - const headers = (body.headers as Record) ?? {} - for (const [k, v] of Object.entries(msg.metadata)) headers[`X-Metadata-${k}`] = v - body.headers = headers - } - if (msg.attachments?.length) body.attachments = msg.attachments.map(toResendAttachment) - if (msg.scheduledAt) { - body.scheduled_at = - msg.scheduledAt instanceof Date ? msg.scheduledAt.toISOString() : msg.scheduledAt - } - return body -} - -function addressList(input: EmailMessage["to"]): string[] { - return normalizeAddresses(input).map((a: EmailAddress) => formatAddress(a)) -} - -function toResendAttachment(a: Attachment): Record { - const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content) - const out: Record = { - filename: a.filename, - content, - content_type: a.contentType, - } - if (a.disposition) out.disposition = a.disposition - if (a.cid) out.content_id = a.cid - return out -} - -function mapResendStatus(event?: string): SendStatusState { - switch (event) { - case "sent": - return "sent" - case "delivered": - return "delivered" - case "bounced": - case "delivery_delayed": - return "bounced" - case "complained": - return "complained" - case "opened": - return "opened" - case "clicked": - return "clicked" - case "scheduled": - return "scheduled" - case "cancelled": - return "cancelled" - default: - return "unknown" - } -} - -function bytesToBase64(bytes: Uint8Array): string { - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (enc: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - // Web API fallback (browsers, Workers, Deno). - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} - -async function request( - fetchImpl: typeof fetch, - endpoint: string, - path: string, - method: string, - apiKey: string, - body: unknown, - extras?: { idempotencyKey?: string }, -): Promise> { - const headers: Record = { - authorization: `Bearer ${apiKey}`, - "content-type": "application/json", - } - if (extras?.idempotencyKey) headers["Idempotency-Key"] = extras.idempotencyKey - - let res: Response - try { - const init: RequestInit = { method, headers } - if (body !== null && method !== "GET") init.body = JSON.stringify(body) - res = await fetchImpl(`${endpoint}${path}`, init) - } catch (err) { - return { data: null, error: toEmailError(DRIVER, err) } - } - - const text = await res.text() - const parsed = text ? safeJson(text) : null - - if (!res.ok) { - const apiError = (parsed ?? {}) as ResendApiError - const code = - res.status === 401 || res.status === 403 - ? "AUTH" - : res.status === 429 - ? "RATE_LIMIT" - : res.status >= 500 - ? "NETWORK" - : "PROVIDER" - return { - data: null, - error: createError(DRIVER, code, apiError.message ?? `HTTP ${res.status}`, { - status: res.status, - cause: { headers: res.headers, body: parsed ?? text }, - retryable: code === "RATE_LIMIT" || code === "NETWORK", - }), - } - } - - return { data: parsed, error: null } -} - -function safeJson(text: string): unknown { - try { - return JSON.parse(text) - } catch { - return null - } -} diff --git a/src/driver/round-robin.ts b/src/driver/round-robin.ts deleted file mode 100644 index 8da5934..0000000 --- a/src/driver/round-robin.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { DriverFactory, EmailDriver } from "../types.ts" -import { defineDriver } from "../_define.ts" -import { createError } from "../errors.ts" - -export interface RoundRobinOptions { - drivers: ReadonlyArray - /** Optional integer weights — `[2, 1, 1]` sends 2 messages to `drivers[0]` - * for every 1 sent to the others. Defaults to equal weighting. */ - weights?: ReadonlyArray -} - -/** Cycle through drivers per-send. Unlike `fallback`, errors are *not* - * retried on another driver — use `fallback` (or `withRetry`) for that. */ -const roundRobin: DriverFactory = defineDriver((options) => { - if (!options || options.drivers.length === 0) - throw createError("round-robin", "INVALID_OPTIONS", "at least one driver is required") - - const drivers = options.drivers - const weights = options.weights ?? drivers.map(() => 1) - if (weights.length !== drivers.length) - throw createError("round-robin", "INVALID_OPTIONS", "weights length must match drivers") - - const schedule: EmailDriver[] = [] - for (let i = 0; i < drivers.length; i++) { - for (let n = 0; n < (weights[i] ?? 1); n++) schedule.push(drivers[i]!) - } - let cursor = 0 - - return { - name: "round-robin", - options, - send(msg, ctx) { - const driver = schedule[cursor % schedule.length]! - cursor++ - ctx.driver = driver.name - return driver.send(msg, ctx) - }, - async initialize() { - await Promise.all(drivers.map((d) => d.initialize?.())) - }, - async dispose() { - await Promise.all(drivers.map((d) => d.dispose?.())) - }, - } -}) - -export default roundRobin diff --git a/src/driver/sendgrid.ts b/src/driver/sendgrid.ts deleted file mode 100644 index 46ee18f..0000000 --- a/src/driver/sendgrid.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { - Attachment, - DriverFactory, - EmailAddress, - EmailMessage, - EmailResult, - Result, -} from "../types.ts" -import { defineDriver } from "../_define.ts" -import { normalizeAddresses } from "../_normalize.ts" -import { createError, createRequiredError } from "../errors.ts" -import { httpJson } from "./_http.ts" - -export interface SendGridDriverOptions { - apiKey: string - endpoint?: string - fetch?: typeof fetch - /** Set X-Smtpapi IP pool. Optional. */ - ipPoolName?: string - /** SendGrid dynamic template id — can also come on the message via \`headers["x-template-id"]\`. */ - templateId?: string -} - -const DRIVER = "sendgrid" - -const sendgrid: DriverFactory = defineDriver( - (options) => { - if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey") - const endpoint = options.endpoint ?? "https://api.sendgrid.com" - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - return { - name: DRIVER, - options, - flags: { - attachments: true, - html: true, - text: true, - templates: true, - tagging: true, - tracking: true, - replyTo: true, - customHeaders: true, - scheduling: true, - personalizations: true, - }, - - async isAvailable() { - return Boolean(options.apiKey) - }, - - async send(msg) { - const payload = buildSendGridPayload(msg, options) - const res = await httpJson({ - fetch: fetchImpl, - driver: DRIVER, - url: `${endpoint}/v3/mail/send`, - headers: { authorization: `Bearer ${options.apiKey}` }, - body: payload, - }) - if (res.error) return res as Result - // SendGrid returns 202 Accepted with empty body and the message id in the header. - return { - data: { - id: `sg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, - driver: DRIVER, - at: new Date(), - provider: (res.data as Record | null) ?? undefined, - }, - error: null, - } - }, - } - }, -) - -export default sendgrid - -function buildSendGridPayload( - msg: EmailMessage, - options: SendGridDriverOptions, -): Record { - const from = normalizeAddresses(msg.from)[0] - if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required") - - const personalizations: Array> = [] - if (msg.personalizations?.length) { - for (const p of msg.personalizations) { - const entry: Record = { - to: normalizeAddresses(p.to).map(toSgAddress), - } - if (p.cc) entry.cc = normalizeAddresses(p.cc).map(toSgAddress) - if (p.bcc) entry.bcc = normalizeAddresses(p.bcc).map(toSgAddress) - if (p.subject) entry.subject = p.subject - if (p.variables) entry.dynamic_template_data = { ...p.variables } - if (p.customArgs) entry.custom_args = { ...p.customArgs } - if (p.sendAt) entry.send_at = Math.floor(toDate(p.sendAt).getTime() / 1000) - personalizations.push(entry) - } - } else { - const personalization: Record = { - to: normalizeAddresses(msg.to).map(toSgAddress), - } - if (msg.cc) personalization.cc = normalizeAddresses(msg.cc).map(toSgAddress) - if (msg.bcc) personalization.bcc = normalizeAddresses(msg.bcc).map(toSgAddress) - if (msg.scheduledAt) - personalization.send_at = Math.floor(toDate(msg.scheduledAt).getTime() / 1000) - personalizations.push(personalization) - } - - const personalization = personalizations[0]! - const payload: Record = { - personalizations, - from: toSgAddress(from), - subject: msg.subject, - content: buildContent(msg), - } - if (msg.replyTo) { - const replyTo = normalizeAddresses(msg.replyTo)[0] - if (replyTo) payload.reply_to = toSgAddress(replyTo) - } - if (msg.attachments?.length) payload.attachments = msg.attachments.map(toSgAttachment) - if (msg.headers) payload.headers = msg.headers - if (msg.tags?.length) payload.categories = msg.tags.map((t) => t.name) - const templateId = msg.template?.id ?? options.templateId - if (templateId) payload.template_id = templateId - if (msg.template?.variables) personalization.dynamic_template_data = { ...msg.template.variables } - if (options.ipPoolName) payload.ip_pool_name = options.ipPoolName - if (msg.metadata) personalization.custom_args = { ...msg.metadata } - if (msg.tracking) { - const t: Record = {} - if (msg.tracking.opens !== undefined) t.open_tracking = { enable: msg.tracking.opens } - if (msg.tracking.clicks !== undefined) t.click_tracking = { enable: msg.tracking.clicks } - if (msg.tracking.unsubscribes !== undefined) - t.subscription_tracking = { enable: msg.tracking.unsubscribes } - if (Object.keys(t).length) payload.tracking_settings = t - } - if (msg.sandbox) payload.mail_settings = { sandbox_mode: { enable: true } } - return payload -} - -function buildContent(msg: EmailMessage): Array<{ type: string; value: string }> { - const content: Array<{ type: string; value: string }> = [] - if (msg.text) content.push({ type: "text/plain", value: msg.text }) - if (msg.html) content.push({ type: "text/html", value: msg.html }) - return content -} - -function toSgAddress(a: EmailAddress): Record { - return a.name ? { email: a.email, name: a.name } : { email: a.email } -} - -function toSgAttachment(a: Attachment): Record { - const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content) - const out: Record = { - filename: a.filename, - content, - type: a.contentType ?? "application/octet-stream", - } - if (a.disposition) out.disposition = a.disposition - if (a.cid) { - out.content_id = a.cid - out.disposition = "inline" - } - return out -} - -function toDate(value: string | Date): Date { - return value instanceof Date ? value : new Date(value) -} - -function bytesToBase64(bytes: Uint8Array): string { - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} diff --git a/src/driver/ses.ts b/src/driver/ses.ts deleted file mode 100644 index eeb4ffa..0000000 --- a/src/driver/ses.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type { DriverFactory, EmailMessage, EmailResult, Result } from "../types.ts" -import type { AwsCredentials } from "./_ses/sigv4.ts" -import { defineDriver } from "../_define.ts" -import { createError, createRequiredError, toEmailError } from "../errors.ts" -import { buildMime, normalizeMimeInput } from "./_smtp/mime.ts" -import { signRequest } from "./_ses/sigv4.ts" - -/** Options for the AWS SES v2 driver. Zero-dep: no \`@aws-sdk/*\` imports, - * Web Crypto SigV4, raw MIME via our shared builder (so attachments and - * inline content work). Targets the SES v2 public API endpoint - * \`email.{region}.amazonaws.com\`. */ -export interface SesDriverOptions { - region: string - accessKeyId?: string - secretAccessKey?: string - sessionToken?: string - /** Optional: SES Configuration Set used for event routing. */ - configurationSetName?: string - /** Optional: FromEmailAddressIdentityArn / ReturnPath helpers. */ - fromArn?: string - /** Override endpoint (for VPC endpoints, GovCloud, or test stubs). */ - endpoint?: string - /** Injected fetch — defaults to global \`fetch\`. */ - fetch?: typeof fetch - /** Injected clock — used for SigV4 signing. Exposed for tests. */ - now?: () => Date -} - -const DRIVER = "ses" - -const ses: DriverFactory = defineDriver((options) => { - if (!options?.region) throw createRequiredError(DRIVER, "region") - - const credentials = resolveCredentials(options) - if (!credentials) { - throw createError( - DRIVER, - "INVALID_OPTIONS", - "credentials not found: pass accessKeyId + secretAccessKey, or set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY", - ) - } - - const endpoint = options.endpoint ?? `https://email.${options.region}.amazonaws.com` - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - return { - name: DRIVER, - options, - flags: { - attachments: true, - html: true, - text: true, - batch: true, - tagging: true, - replyTo: true, - customHeaders: true, - }, - - async isAvailable() { - return Boolean(credentials.accessKeyId && credentials.secretAccessKey) - }, - - async send(msg) { - const payload = buildSendPayload(msg, options) - const res = await sesRequest( - fetchImpl, - endpoint, - "/v2/email/outbound-emails", - payload, - options, - credentials, - ) - if (res.error) return res as Result - const body = (res.data ?? {}) as { MessageId?: string } - if (!body.MessageId) { - return { - data: null, - error: createError(DRIVER, "PROVIDER", "ses response missing MessageId", { cause: body }), - } - } - return { - data: { - id: body.MessageId, - driver: DRIVER, - at: new Date(), - provider: body as Record, - }, - error: null, - } - }, - - async sendBatch(msgs) { - // SES v2 has SendBulkEmail but it requires a template; raw-MIME bulk - // isn't a supported API. Fall back to sequential sends — the core - // `sendBatch()` wrapper does this too, but implementing here keeps - // the contract consistent (no `sendBatch` → fall through to sequential). - const results: EmailResult[] = [] - for (const msg of msgs) { - const r = await this.send!(msg, { driver: DRIVER, attempt: 1, meta: {} }) - if (r.error) return r as never - results.push(r.data!) - } - return { data: results, error: null } - }, - } -}) - -export default ses - -function resolveCredentials(options: SesDriverOptions): AwsCredentials | null { - const envAccess = readEnv("AWS_ACCESS_KEY_ID") - const envSecret = readEnv("AWS_SECRET_ACCESS_KEY") - const envSession = readEnv("AWS_SESSION_TOKEN") - const accessKeyId = options.accessKeyId ?? envAccess - const secretAccessKey = options.secretAccessKey ?? envSecret - if (!accessKeyId || !secretAccessKey) return null - return { - accessKeyId, - secretAccessKey, - sessionToken: options.sessionToken ?? envSession, - } -} - -function readEnv(name: string): string | undefined { - const g = globalThis as { process?: { env?: Record } } - return g.process?.env?.[name] -} - -function buildSendPayload(msg: EmailMessage, options: SesDriverOptions): Record { - const messageId = - msg.headers?.["Message-ID"] ?? - `<${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 10)}@ses.amazonaws.com>` - const mime = buildMime(normalizeMimeInput(msg, messageId)) - const destination: Record = { ToAddresses: splitHeader(mime.headers.To) } - if (mime.headers.Cc) destination.CcAddresses = splitHeader(mime.headers.Cc) - const payload: Record = { - FromEmailAddress: mime.headers.From, - Destination: destination, - Content: { - Raw: { Data: toBase64(mime.body) }, - }, - } - if (options.configurationSetName) payload.ConfigurationSetName = options.configurationSetName - if (options.fromArn) payload.FromEmailAddressIdentityArn = options.fromArn - if (mime.headers["Reply-To"]) payload.ReplyToAddresses = splitHeader(mime.headers["Reply-To"]) - if (msg.tags?.length) payload.EmailTags = msg.tags.map((t) => ({ Name: t.name, Value: t.value })) - return payload -} - -function splitHeader(value: string | undefined): string[] { - if (!value) return [] - return value - .split(",") - .map((v) => v.trim()) - .filter(Boolean) -} - -function toBase64(value: string): string { - const bytes = new TextEncoder().encode(value) - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} - -async function sesRequest( - fetchImpl: typeof fetch, - endpoint: string, - path: string, - body: unknown, - options: SesDriverOptions, - credentials: AwsCredentials, -): Promise> { - const bodyText = JSON.stringify(body) - let signed - try { - signed = await signRequest({ - method: "POST", - url: `${endpoint}${path}`, - body: bodyText, - headers: { "content-type": "application/json" }, - region: options.region, - service: "ses", - credentials, - now: options.now, - }) - } catch (err) { - return { data: null, error: toEmailError(DRIVER, err) } - } - - let res: Response - try { - res = await fetchImpl(signed.url, { - method: signed.method, - headers: signed.headers, - body: signed.body, - }) - } catch (err) { - return { data: null, error: toEmailError(DRIVER, err) } - } - - const text = await res.text() - const parsed = text ? safeJson(text) : null - - if (!res.ok) { - const apiError = (parsed ?? {}) as { message?: string; Message?: string; __type?: string } - const errType = apiError.__type ?? "" - const message = apiError.message ?? apiError.Message ?? `HTTP ${res.status}` - const code = - /InvalidClientTokenId|SignatureDoesNotMatch|AccessDenied|UnrecognizedClientException/.test( - errType, - ) - ? "AUTH" - : res.status === 429 || /Throttling|TooManyRequests/.test(errType) - ? "RATE_LIMIT" - : res.status >= 500 - ? "NETWORK" - : "PROVIDER" - return { - data: null, - error: createError(DRIVER, code, message, { - status: res.status, - cause: { headers: res.headers, body: parsed ?? text }, - retryable: code === "RATE_LIMIT" || code === "NETWORK", - }), - } - } - - return { data: parsed, error: null } -} - -function safeJson(text: string): unknown { - try { - return JSON.parse(text) - } catch { - return null - } -} diff --git a/src/driver/smtp.ts b/src/driver/smtp.ts deleted file mode 100644 index 68eeaf1..0000000 --- a/src/driver/smtp.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { DriverFactory, EmailMessage, EmailResult } from "../types.ts" -import type { ConnectionOptions } from "./_smtp/connection.ts" -import type { PoolOptions } from "./_smtp/pool.ts" -import type { AuthMethod } from "./_smtp/auth.ts" -import { defineDriver } from "../_define.ts" -import { EmailError } from "../errors.ts" -import { createError, createRequiredError, toEmailError } from "../errors.ts" -import { buildMime, normalizeMimeInput } from "./_smtp/mime.ts" -import { normalizeAddresses } from "../_normalize.ts" -import { createPool, type ConnectionPool } from "./_smtp/pool.ts" -import { signDkim, type DkimSignerOptions } from "./_smtp/dkim.ts" - -export type { DkimSignerOptions } - -function normalizeAddressList(input: EmailMessage["to"] | undefined): string[] { - return normalizeAddresses(input).map((a) => a.email) -} - -/** User-visible options. See `docs/drivers/smtp.md` (lands with #54) for - * the full matrix. Defaults favor security: `rejectUnauthorized: true`, - * AUTO auth, STARTTLS if the server advertises it. */ -export interface SmtpDriverOptions { - host: string - port?: number - secure?: boolean - requireTLS?: boolean - user?: string - password?: string - authMethod?: AuthMethod | "AUTO" - getAccessToken?: () => Promise - rejectUnauthorized?: boolean - tls?: import("node:tls").ConnectionOptions - localName?: string - pool?: boolean - maxConnections?: number - maxMessagesPerConnection?: number - idleTimeoutMs?: number - connectionTimeoutMs?: number - commandTimeoutMs?: number - disposeGraceMs?: number - /** Sign outbound messages with DKIM (RFC 6376 / RFC 8463). Accepts a - * single signer config or a per-message resolver for multi-tenant - * sending. */ - dkim?: DkimSignerOptions | ((msg: EmailMessage) => DkimSignerOptions | null) -} - -const DRIVER = "smtp" - -const smtp: DriverFactory = defineDriver((opts) => { - if (!opts?.host) throw createRequiredError(DRIVER, "host") - - const secure = opts.secure ?? false - const port = opts.port ?? (secure ? 465 : 587) - const connectionOpts: ConnectionOptions = { - host: opts.host, - port, - secure, - requireTLS: opts.requireTLS, - user: opts.user, - password: opts.password, - authMethod: opts.authMethod ?? "AUTO", - getAccessToken: opts.getAccessToken, - rejectUnauthorized: opts.rejectUnauthorized ?? true, - tls: opts.tls, - localName: opts.localName ?? resolveLocalName(), - connectionTimeoutMs: opts.connectionTimeoutMs ?? 30_000, - commandTimeoutMs: opts.commandTimeoutMs ?? 10_000, - } - - const poolOpts: PoolOptions = { - enabled: opts.pool ?? false, - maxConnections: opts.maxConnections ?? 5, - maxMessagesPerConnection: opts.maxMessagesPerConnection ?? 0, - idleTimeoutMs: opts.idleTimeoutMs ?? 60_000, - disposeGraceMs: opts.disposeGraceMs ?? 10_000, - connection: connectionOpts, - } - - let pool: ConnectionPool | null = null - function getPool(): ConnectionPool { - pool ??= createPool(poolOpts) - return pool - } - - return { - name: DRIVER, - options: opts, - flags: { - attachments: true, - html: true, - text: true, - customHeaders: true, - replyTo: true, - }, - - async dispose() { - if (pool) await pool.dispose() - pool = null - }, - - async send(msg) { - try { - const messageId = msg.headers?.["Message-ID"] ?? generateMessageId(opts.host) - let envelope: { from: string; rcpt: string[] } - let rawBody: string - if (msg.raw) { - rawBody = typeof msg.raw === "string" ? msg.raw : new TextDecoder().decode(msg.raw) - envelope = { - from: normalizeMimeInput(msg, messageId).from.email, - rcpt: Array.from( - new Set([ - ...normalizeAddressList(msg.to), - ...normalizeAddressList(msg.cc), - ...normalizeAddressList(msg.bcc), - ]), - ), - } - } else { - const mime = buildMime(normalizeMimeInput(msg, messageId)) - envelope = mime.envelope - rawBody = mime.body - } - if (envelope.rcpt.length === 0) - throw createError(DRIVER, "INVALID_OPTIONS", "at least one recipient is required") - const dkimConfig = typeof opts.dkim === "function" ? opts.dkim(msg) : opts.dkim - const body = dkimConfig ? await signDkim(rawBody, dkimConfig) : rawBody - const conn = await getPool().acquire() - let failed = false - try { - await conn.sendMessage(envelope, body) - const result: EmailResult = { - id: messageId, - driver: DRIVER, - at: new Date(), - provider: { capabilities: Array.from(conn.capabilities.authMethods) }, - } - return { data: result, error: null } - } catch (err) { - failed = true - const error = err instanceof EmailError ? err : toEmailError(DRIVER, err) - return { data: null, error } - } finally { - await getPool() - .release(conn, failed) - .catch(() => {}) - } - } catch (err) { - return { data: null, error: err instanceof EmailError ? err : toEmailError(DRIVER, err) } - } - }, - } -}) - -export default smtp - -function resolveLocalName(): string { - const g = globalThis as { process?: { versions?: { node?: string } } } - if (!g.process?.versions?.node) return "localhost" - try { - // Dynamic require keeps this file Workers-parseable. - // eslint-disable-next-line ts/no-require-imports - const os = (globalThis as any).require?.("node:os") as { hostname?: () => string } | undefined - const host = os?.hostname?.() - return host && /^[\w.-]+$/.test(host) ? host : "localhost.localdomain" - } catch { - return "localhost.localdomain" - } -} - -function generateMessageId(host: string): string { - const rand = Math.random().toString(36).slice(2, 10) - const ts = Date.now().toString(36) - return `<${ts}.${rand}@${host}>` -} diff --git a/src/driver/tee.ts b/src/driver/tee.ts deleted file mode 100644 index 545e440..0000000 --- a/src/driver/tee.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { DriverFactory, EmailDriver } from "../types.ts" -import { defineDriver } from "../_define.ts" -import { createError } from "../errors.ts" - -/** Fan-out meta driver: every send goes to **all** listed drivers. - * - * The first driver is authoritative — its result is returned and any - * failure propagates. The rest are "mirror" drivers used for - * auditing/archival; their failures are swallowed (reported via - * \`onMirrorError\`) so they never cause the user-facing send to fail. - * - * ```ts - * createEmail({ driver: tee({ drivers: [resendPrimary, sesArchive] }) }) - * ``` - */ -export interface TeeOptions { - drivers: ReadonlyArray - /** Called whenever a non-primary (mirror) driver errors — typical use - * is logging to Sentry/OTel. */ - onMirrorError?: (driverName: string, error: Error) => void - /** Await mirror sends before resolving. Default: false — mirrors run - * fire-and-forget so the user doesn't wait on their tail latency. */ - awaitMirrors?: boolean -} - -const tee: DriverFactory = defineDriver((options) => { - if (!options || options.drivers.length === 0) - throw createError("tee", "INVALID_OPTIONS", "at least one driver is required") - - const [primary, ...mirrors] = options.drivers - return { - name: "tee", - options, - async send(msg, ctx) { - const result = await primary!.send(msg, ctx) - - const fanOut = async (): Promise => { - await Promise.all( - mirrors.map(async (driver) => { - try { - const r = await driver.send(msg, { ...ctx, driver: driver.name }) - if (r.error) options.onMirrorError?.(driver.name, r.error) - } catch (err) { - options.onMirrorError?.( - driver.name, - err instanceof Error ? err : new Error(String(err)), - ) - } - }), - ) - } - - if (options.awaitMirrors) await fanOut() - else void fanOut() - - return result - }, - async initialize() { - await Promise.all(options.drivers.map((d) => d.initialize?.())) - }, - async dispose() { - await Promise.all(options.drivers.map((d) => d.dispose?.())) - }, - } -}) - -export default tee diff --git a/src/driver/zeptomail.ts b/src/driver/zeptomail.ts deleted file mode 100644 index 6505478..0000000 --- a/src/driver/zeptomail.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { - Attachment, - DriverFactory, - EmailAddress, - EmailMessage, - EmailResult, - Result, -} from "../types.ts" -import { defineDriver } from "../_define.ts" -import { normalizeAddresses } from "../_normalize.ts" -import { createError, createRequiredError } from "../errors.ts" -import { httpJson } from "./_http.ts" - -/** Options for the Zeptomail driver. The token **must** be prefixed with - * \`Zoho-enczapikey \` per Zeptomail's auth format. */ -export interface ZeptomailDriverOptions { - /** Full token including the \`Zoho-enczapikey \` prefix. */ - token: string - endpoint?: string - fetch?: typeof fetch - trackClicks?: boolean - trackOpens?: boolean -} - -const DRIVER = "zeptomail" -const DEFAULT_ENDPOINT = "https://api.zeptomail.com/v1.1" - -const zeptomail: DriverFactory = defineDriver( - (options) => { - if (!options?.token) throw createRequiredError(DRIVER, "token") - if (!options.token.startsWith("Zoho-enczapikey ")) - throw createError(DRIVER, "INVALID_OPTIONS", "token must start with 'Zoho-enczapikey '") - - const endpoint = options.endpoint ?? DEFAULT_ENDPOINT - const fetchImpl = options.fetch ?? globalThis.fetch - if (typeof fetchImpl !== "function") - throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly") - - return { - name: DRIVER, - options, - flags: { - attachments: true, - html: true, - text: true, - tracking: true, - replyTo: true, - customHeaders: true, - }, - - async isAvailable() { - return Boolean(options.token) - }, - - async send(msg) { - const payload = buildPayload(msg, options) - const res = await httpJson({ - fetch: fetchImpl, - driver: DRIVER, - url: `${endpoint}/email`, - headers: { authorization: options.token }, - body: payload, - }) - if (res.error) return res as Result - const body = (res.data ?? {}) as { data?: Array<{ message_id?: string }>; message?: string } - const id = body.data?.[0]?.message_id ?? `zepto_${Date.now().toString(36)}` - return { - data: { - id, - driver: DRIVER, - at: new Date(), - provider: body as unknown as Record, - }, - error: null, - } - }, - } - }, -) - -export default zeptomail - -function buildPayload(msg: EmailMessage, options: ZeptomailDriverOptions): Record { - const from = normalizeAddresses(msg.from)[0] - if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required") - - const payload: Record = { - from: toAddress(from), - to: normalizeAddresses(msg.to).map(toRecipient), - subject: msg.subject, - } - if (msg.cc) payload.cc = normalizeAddresses(msg.cc).map(toRecipient) - if (msg.bcc) payload.bcc = normalizeAddresses(msg.bcc).map(toRecipient) - if (msg.replyTo) payload.reply_to = normalizeAddresses(msg.replyTo).map(toAddress) - if (msg.text) payload.textbody = msg.text - if (msg.html) payload.htmlbody = msg.html - if (msg.headers) payload.mime_headers = msg.headers - if (msg.attachments?.length) payload.attachments = msg.attachments.map(toZeptoAttachment) - if (options.trackClicks) payload.track_clicks = true - if (options.trackOpens) payload.track_opens = true - if (msg.template) { - if (msg.template.id) payload.template_key = msg.template.id - if (msg.template.alias) payload.template_alias = msg.template.alias - if (msg.template.variables) payload.merge_info = { ...msg.template.variables } - } - return payload -} - -function toAddress(a: EmailAddress): Record { - return a.name ? { address: a.email, name: a.name } : { address: a.email } -} - -function toRecipient(a: EmailAddress): Record { - return { email_address: toAddress(a) } -} - -function toZeptoAttachment(a: Attachment): Record { - const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content) - return { - name: a.filename, - content, - mime_type: a.contentType ?? "application/octet-stream", - } -} - -function bytesToBase64(bytes: Uint8Array): string { - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} diff --git a/src/drivers/_base64.ts b/src/drivers/_base64.ts new file mode 100644 index 0000000..68f8cbd --- /dev/null +++ b/src/drivers/_base64.ts @@ -0,0 +1,46 @@ +/** Base64 without a runtime dependency. Uses `Buffer` where it exists + * (Node, Bun) and falls back to `btoa` everywhere else (Workers, Deno, + * browsers) — the fallback goes through a chunked loop because + * `String.fromCharCode(...bytes)` blows the argument limit on anything + * larger than a small attachment. + * + * @module + */ + +interface BufferGlobal { + Buffer?: { + from: ( + input: Uint8Array | string, + encoding?: string, + ) => { toString: (encoding: string) => string } + } +} + +export function bytesToBase64(bytes: Uint8Array): string { + const buffer = (globalThis as BufferGlobal).Buffer + if (buffer) return buffer.from(bytes).toString("base64") + + let binary = "" + const chunk = 0x8000 + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)) + } + return btoa(binary) +} + +export function stringToBase64(value: string): string { + return bytesToBase64(new TextEncoder().encode(value)) +} + +/** Whether a string is already base64, so an attachment handed to us + * pre-encoded is not encoded twice. */ +export function isBase64(value: string): boolean { + const compact = value.replace(/[\r\n]/g, "") + return compact.length > 0 && compact.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(compact) +} + +/** Encode attachment content for a provider that wants base64. */ +export function attachmentToBase64(content: string | Uint8Array): string { + if (typeof content !== "string") return bytesToBase64(content) + return isBase64(content) ? content : stringToBase64(content) +} diff --git a/src/drivers/_fetch.ts b/src/drivers/_fetch.ts new file mode 100644 index 0000000..8d42ef7 --- /dev/null +++ b/src/drivers/_fetch.ts @@ -0,0 +1,149 @@ +import type { EmailErrorCode, Result } from "../core/types.ts" +import { createError, toEmailError } from "../core/error.ts" +import { err, ok } from "../core/result.ts" + +/** + * The one HTTP path every API-backed driver takes. + * + * Drivers do not parse responses, classify status codes, or wrap network + * errors — they describe the request and read the parsed body. The status + * taxonomy lives here so `error.retryable` means the same thing whichever + * provider produced it. + * + * @module + */ + +export interface HttpRequest { + fetch: typeof fetch + driver: string + url: string + method?: string + headers?: Record + /** Serialized as JSON unless it is already a string. */ + body?: unknown + signal?: AbortSignal + /** Milliseconds before the request is aborted. Default: 30_000. */ + timeoutMs?: number + /** Refine the classification when the provider says more than the + * status code does — Postmark's `ErrorCode`, SES's `__type`. */ + classify?: (status: number, body: unknown) => Classification | null +} + +export interface Classification { + code: EmailErrorCode + message?: string + retryable?: boolean +} + +/** Issue a JSON request. The parsed body is the `data` on success, `null` + * for an empty response. */ +export async function httpJson(request: HttpRequest): Promise> { + const headers: Record = { + accept: "application/json", + ...request.headers, + } + const hasBody = request.body != null + if (hasBody && !hasHeader(headers, "content-type")) { + headers["content-type"] = "application/json" + } + + const timeout = AbortSignal.timeout(request.timeoutMs ?? 30_000) + const signal = request.signal ? anySignal([request.signal, timeout]) : timeout + + let response: Response + try { + response = await request.fetch(request.url, { + method: request.method ?? "POST", + headers, + body: hasBody + ? typeof request.body === "string" + ? request.body + : JSON.stringify(request.body) + : undefined, + signal, + }) + } catch (error) { + return err(toEmailError(request.driver, error)) + } + + const text = await response.text() + const parsed = text ? safeJson(text) : null + + if (!response.ok) { + const custom = request.classify?.(response.status, parsed) + const code = custom?.code ?? classifyStatus(response.status) + const message = custom?.message ?? extractMessage(parsed) ?? `HTTP ${response.status}` + return err( + createError(request.driver, code, message, { + status: response.status, + ...(custom?.retryable == null ? {} : { retryable: custom.retryable }), + // Retry middleware reads `Retry-After` off these headers, so the + // provider's own backoff advice has to survive the trip out. + cause: { headers: response.headers, body: parsed ?? text }, + }), + ) + } + + return ok(parsed) +} + +/** Default status → code mapping. Anything 5xx or 429 is retryable; a 4xx + * is the caller's problem and retrying it just burns quota. */ +export function classifyStatus(status: number): EmailErrorCode { + if (status === 401 || status === 403) return "AUTH" + if (status === 408) return "TIMEOUT" + if (status === 429) return "RATE_LIMIT" + if (status >= 500) return "NETWORK" + return "PROVIDER" +} + +/** Resolve the fetch a driver should use, failing at construction rather + * than on the first send when there is none. */ +export function resolveFetch(driver: string, injected?: typeof fetch): typeof fetch { + const impl = injected ?? globalThis.fetch + if (typeof impl !== "function") { + throw createError(driver, "INVALID_OPTIONS", "no global fetch — pass `fetch` explicitly") + } + return impl +} + +/** Pull a human-readable message out of the half-dozen error envelopes + * the providers use between them. */ +function extractMessage(body: unknown): string | null { + if (!body || typeof body !== "object") return null + const record = body as Record + const direct = record.message ?? record.Message ?? record.error ?? record.detail + if (typeof direct === "string") return direct + const errors = record.errors + if (Array.isArray(errors) && errors[0] && typeof errors[0] === "object") { + const first = errors[0] as Record + if (typeof first.message === "string") return first.message + } + return null +} + +function hasHeader(headers: Record, name: string): boolean { + return Object.keys(headers).some((key) => key.toLowerCase() === name) +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return null + } +} + +// `AbortSignal.any` is still missing from a few runtimes we target. +function anySignal(signals: readonly AbortSignal[]): AbortSignal { + if (typeof AbortSignal.any === "function") return AbortSignal.any([...signals]) + const controller = new AbortController() + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason) + break + } + signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true }) + } + return controller.signal +} diff --git a/src/drivers/_mime.ts b/src/drivers/_mime.ts new file mode 100644 index 0000000..73537bf --- /dev/null +++ b/src/drivers/_mime.ts @@ -0,0 +1,279 @@ +import type { Attachment, EmailAddress, NormalizedMessage } from "../core/types.ts" +import { formatAddress } from "../core/address.ts" +import { getHeader } from "../core/message.ts" +import { attachmentToBase64, stringToBase64 } from "./_base64.ts" + +/** + * RFC 5322 / 2045 message builder, shared by SMTP (which transmits it) and + * SES (which posts it as raw content). Zero dependencies and no Node + * built-ins, so it runs unchanged in a Worker. + * + * @module + */ + +export interface MimeInput { + from: EmailAddress + to: readonly EmailAddress[] + cc: readonly EmailAddress[] + bcc: readonly EmailAddress[] + replyTo: readonly EmailAddress[] + subject: string + text?: string + html?: string + headers?: Readonly> + attachments?: readonly Attachment[] + date?: Date + messageId: string +} + +export interface MimeOutput { + /** `MAIL FROM` and the `RCPT TO` list — to/cc/bcc merged and deduped. + * Bcc appears here and never in the rendered headers. */ + envelope: { from: string; rcpt: string[] } + headers: Record + body: string +} + +/** Adapt an already-normalized message for the builder. */ +export function toMimeInput( + msg: NormalizedMessage, + messageId: string, + date: Date = new Date(), +): MimeInput { + return { + from: msg.from, + to: msg.to, + cc: msg.cc, + bcc: msg.bcc, + replyTo: msg.replyTo, + subject: msg.subject, + ...(msg.text == null ? {} : { text: msg.text }), + ...(msg.html == null ? {} : { html: msg.html }), + headers: msg.headers, + attachments: msg.attachments, + date, + messageId, + } +} + +/** A `Message-ID` with the sending domain in it, which is what receivers + * expect and what DKIM alignment checks read. */ +export function generateMessageId(domain: string): string { + const random = Math.random().toString(36).slice(2, 12) + const time = Date.now().toString(36) + return `<${time}.${random}@${domain}>` +} + +/** Read the caller's `Message-ID` if they set one, otherwise mint one. */ +export function resolveMessageId(msg: NormalizedMessage, fallbackDomain: string): string { + return getHeader(msg.headers, "message-id") ?? generateMessageId(fallbackDomain) +} + +export function buildMime(input: MimeInput): MimeOutput { + const mixedBoundary = boundary("mixed") + const altBoundary = boundary("alt") + const hasAttachments = (input.attachments?.length ?? 0) > 0 + const hasAlternatives = Boolean(input.text && input.html) + + const headers: Record = { + From: formatAddress(input.from), + To: input.to.map(formatAddress).join(", "), + Subject: encodeHeaderValue(input.subject), + "Message-ID": input.messageId, + Date: (input.date ?? new Date()).toUTCString(), + "MIME-Version": "1.0", + } + if (input.cc.length > 0) headers.Cc = input.cc.map(formatAddress).join(", ") + if (input.replyTo.length > 0) { + headers["Reply-To"] = input.replyTo.map(formatAddress).join(", ") + } + for (const [name, value] of Object.entries(input.headers ?? {})) { + // Bcc is an envelope concern; putting it in the document would show + // every blind recipient to every other one. + if (name.toLowerCase() === "bcc") continue + headers[name] = value + } + + const body = hasAttachments + ? buildMixed(input, mixedBoundary, altBoundary, hasAlternatives, headers) + : hasAlternatives + ? buildAlternative(input, altBoundary, headers) + : buildSingle(input, headers) + + return { + envelope: { + from: input.from.email, + rcpt: [...new Set([...input.to, ...input.cc, ...input.bcc].map((a) => a.email))], + }, + headers, + body: renderHeaders(headers) + "\r\n" + body, + } +} + +/** + * Dot-stuff a body for `DATA` per RFC 5321 §4.5.2 — a line that starts + * with `.` gets a second one, so the payload can never contain the + * `\r\n.\r\n` sequence that ends the transmission. + */ +export function dotStuff(body: string): string { + return body.replace(/\r?\n/g, "\r\n").replace(/(^|\r\n)\./g, "$1..") +} + +function buildSingle(input: MimeInput, headers: Record): string { + const isHtml = Boolean(input.html) + headers["Content-Type"] = `text/${isHtml ? "html" : "plain"}; charset=utf-8` + headers["Content-Transfer-Encoding"] = "quoted-printable" + return encodeQuotedPrintable((isHtml ? input.html : input.text) ?? "") +} + +function buildAlternative( + input: MimeInput, + bound: string, + headers: Record, +): string { + headers["Content-Type"] = `multipart/alternative; boundary="${bound}"` + const parts: string[] = [] + // Least-preferred first: a client picks the last part it can render. + if (input.text) parts.push(part(bound, "text/plain", input.text)) + if (input.html) parts.push(part(bound, "text/html", input.html)) + parts.push(`--${bound}--`) + return parts.join("\r\n") +} + +function buildMixed( + input: MimeInput, + outer: string, + inner: string, + hasAlternatives: boolean, + headers: Record, +): string { + headers["Content-Type"] = `multipart/mixed; boundary="${outer}"` + const partHeaders: Record = {} + const content = hasAlternatives + ? buildAlternative(input, inner, partHeaders) + : buildSingle(input, partHeaders) + + const parts = [ + [ + `--${outer}`, + `Content-Type: ${partHeaders["Content-Type"] ?? "text/plain; charset=utf-8"}`, + ...(partHeaders["Content-Transfer-Encoding"] + ? [`Content-Transfer-Encoding: ${partHeaders["Content-Transfer-Encoding"]}`] + : []), + "", + content, + ].join("\r\n"), + ] + for (const attachment of input.attachments ?? []) parts.push(renderAttachment(outer, attachment)) + parts.push(`--${outer}--`) + return parts.join("\r\n") +} + +function part(bound: string, contentType: string, content: string): string { + return [ + `--${bound}`, + `Content-Type: ${contentType}; charset=utf-8`, + "Content-Transfer-Encoding: quoted-printable", + "", + encodeQuotedPrintable(content), + ].join("\r\n") +} + +function renderAttachment(bound: string, attachment: Attachment): string { + const contentType = attachment.contentType ?? "application/octet-stream" + const disposition = attachment.disposition ?? (attachment.cid ? "inline" : "attachment") + const name = encodeHeaderValue(attachment.filename) + const lines = [ + `--${bound}`, + `Content-Type: ${contentType}; name="${name}"`, + "Content-Transfer-Encoding: base64", + `Content-Disposition: ${disposition}; filename="${name}"`, + ] + if (attachment.cid) lines.push(`Content-ID: <${attachment.cid}>`) + lines.push("", foldBase64(attachmentToBase64(attachment.content))) + return lines.join("\r\n") +} + +function renderHeaders(headers: Record): string { + const lines: string[] = [] + for (const [name, value] of Object.entries(headers)) { + if (value === "") continue + lines.push(`${name}: ${foldHeader(value)}`) + } + return lines.join("\r\n") + "\r\n" +} + +/** RFC 5322 §2.1.1 caps a line at 998 octets; 76 keeps it comfortable and + * matches what every other mailer emits. */ +function foldHeader(value: string, max = 76): string { + if (value.length <= max) return value + const lines: string[] = [] + let current = "" + for (const word of value.split(" ")) { + if (current && current.length + word.length + 1 > max) { + lines.push(current) + current = ` ${word}` + } else { + current = current ? `${current} ${word}` : word + } + } + if (current) lines.push(current) + return lines.join("\r\n") +} + +/** RFC 2047 encoded-word, so a non-ASCII subject survives the 7-bit + * header channel. */ +function encodeHeaderValue(value: string): string { + if (/^[\x20-\x7E]*$/.test(value)) return value + return `=?utf-8?B?${stringToBase64(value)}?=` +} + +function encodeQuotedPrintable(input: string): string { + const out: string[] = [] + for (const char of input) { + if (char === "\n") { + out.push("\r\n") + continue + } + if (char === "\r") continue + const code = char.codePointAt(0)! + if (code === 0x20 || code === 0x09 || (code >= 0x21 && code <= 0x7e && char !== "=")) { + out.push(char) + continue + } + for (const byte of new TextEncoder().encode(char)) { + out.push(`=${byte.toString(16).toUpperCase().padStart(2, "0")}`) + } + } + return softWrap(out.join(""), 76) +} + +/** Wrap with soft line breaks, never splitting an `=XX` escape. */ +function softWrap(input: string, max: number): string { + return input + .split("\r\n") + .map((line) => { + if (line.length <= max) return line + const out: string[] = [] + let rest = line + while (rest.length > max - 1) { + let cut = max - 1 + while (cut > 0 && (rest[cut - 1] === "=" || (cut >= 2 && rest[cut - 2] === "="))) cut-- + out.push(`${rest.slice(0, cut)}=`) + rest = rest.slice(cut) + } + out.push(rest) + return out.join("\r\n") + }) + .join("\r\n") +} + +function foldBase64(value: string, width = 76): string { + const chunks: string[] = [] + for (let i = 0; i < value.length; i += width) chunks.push(value.slice(i, i + width)) + return chunks.join("\r\n") +} + +function boundary(kind: string): string { + return `----unemail_${kind}_${Math.random().toString(36).slice(2, 12)}${Date.now().toString(36)}` +} diff --git a/src/driver/_ses/sigv4.ts b/src/drivers/_ses/sigv4.ts similarity index 100% rename from src/driver/_ses/sigv4.ts rename to src/drivers/_ses/sigv4.ts diff --git a/src/driver/_smtp/auth.ts b/src/drivers/_smtp/auth.ts similarity index 100% rename from src/driver/_smtp/auth.ts rename to src/drivers/_smtp/auth.ts diff --git a/src/driver/_smtp/connection.ts b/src/drivers/_smtp/connection.ts similarity index 99% rename from src/driver/_smtp/connection.ts rename to src/drivers/_smtp/connection.ts index c8b933d..2664713 100644 --- a/src/driver/_smtp/connection.ts +++ b/src/drivers/_smtp/connection.ts @@ -4,7 +4,7 @@ import type { AuthMethod, SmtpReply } from "./auth.ts" import { authCramMd5, authLogin, authPlain, authXoauth2, pickAuthMethod } from "./auth.ts" import { cancelledError, replyError, timeoutError, wrapNetworkError } from "./errors.ts" import { ReplyParser } from "./reply.ts" -import { dotStuff } from "./mime.ts" +import { dotStuff } from "../_mime.ts" /** Knobs mirror what's user-visible on `SmtpDriverOptions`. Kept narrow so * this module is easy to unit-test. */ diff --git a/src/driver/_smtp/dkim.ts b/src/drivers/_smtp/dkim.ts similarity index 100% rename from src/driver/_smtp/dkim.ts rename to src/drivers/_smtp/dkim.ts diff --git a/src/driver/_smtp/errors.ts b/src/drivers/_smtp/errors.ts similarity index 94% rename from src/driver/_smtp/errors.ts rename to src/drivers/_smtp/errors.ts index 0e6855c..c2a359d 100644 --- a/src/driver/_smtp/errors.ts +++ b/src/drivers/_smtp/errors.ts @@ -1,5 +1,5 @@ -import type { EmailErrorCode } from "../../types.ts" -import { createError, EmailError } from "../../errors.ts" +import type { EmailErrorCode } from "../../core/types.ts" +import { createError, EmailError } from "../../core/error.ts" const DRIVER = "smtp" diff --git a/src/driver/_smtp/pool.ts b/src/drivers/_smtp/pool.ts similarity index 100% rename from src/driver/_smtp/pool.ts rename to src/drivers/_smtp/pool.ts diff --git a/src/driver/_smtp/reply.ts b/src/drivers/_smtp/reply.ts similarity index 100% rename from src/driver/_smtp/reply.ts rename to src/drivers/_smtp/reply.ts diff --git a/src/drivers/fallback.ts b/src/drivers/fallback.ts new file mode 100644 index 0000000..7155c85 --- /dev/null +++ b/src/drivers/fallback.ts @@ -0,0 +1,119 @@ +import type { EmailError } from "../core/error.ts" +import type { + EmailDriver, + EmailResult, + NormalizedMessage, + Result, + SendContext, +} from "../core/types.ts" +import { driverHandler } from "../core/define.ts" +import { createError } from "../core/error.ts" +import { err } from "../core/result.ts" + +export interface FallbackOptions { + /** Which failures move to the next provider. Default: everything except + * `INVALID_OPTIONS` and `UNSUPPORTED` — a malformed message will be + * just as malformed at the next provider. */ + shouldFailover?: (error: EmailError) => boolean + /** Called when a leg is abandoned. */ + onFailover?: (from: string, to: string, error: EmailError) => void + /** Name reported on results and errors. Default: `fallback`. */ + name?: string +} + +/** + * Try each driver in order until one accepts the message. + * + * Failover is per message, not per batch: if 3 of 500 fail at the primary, + * only those 3 go to the secondary — the other 497 are not re-sent, so + * nobody receives the same mail twice. + * + * Compose with `wrap()` to retry inside a leg before moving on: + * + * ```ts + * fallback([wrap(resend({ apiKey }), withRetry()), ses({ region })]) + * ``` + */ +export function fallback( + drivers: readonly EmailDriver[], + options: FallbackOptions = {}, +): EmailDriver { + if (drivers.length === 0) throw createError("fallback", "INVALID_OPTIONS", "no drivers given") + const name = options.name ?? "fallback" + const shouldFailover = options.shouldFailover ?? defaultShouldFailover + const handlers = drivers.map((driver) => ({ driver, handle: driverHandler(driver) })) + + async function run( + msgs: readonly NormalizedMessage[], + ctx: SendContext, + ): Promise[]> { + const results = Array.from({ length: msgs.length }, () => err(noResult(name))) + let pending = msgs.map((_, index) => index) + + for (const [leg, { driver, handle }] of handlers.entries()) { + if (pending.length === 0) break + const legCtx: SendContext = { ...ctx, driver: driver.name } + await driver.initialize?.() + const produced = await handle( + pending.map((index) => msgs[index]!), + legCtx, + ) + + const next: number[] = [] + for (const [slot, index] of pending.entries()) { + const result = produced[slot] ?? err(noResult(driver.name)) + results[index] = result + if (!result.error) continue + const successor = handlers[leg + 1] + if (successor && shouldFailover(result.error)) { + options.onFailover?.(driver.name, successor.driver.name, result.error) + next.push(index) + } + } + pending = next + } + + return results + } + + return { + name, + features: mergeFeatures(drivers), + // Legs initialize lazily as each is reached: opening a connection to a + // standby provider that is never used is wasted work. + getInstance: () => drivers, + async dispose() { + await Promise.all(drivers.map((driver) => driver.dispose?.())) + }, + async isAvailable() { + const checks = await Promise.all( + drivers.map(async (driver) => { + try { + return (await driver.isAvailable?.()) ?? true + } catch { + return false + } + }), + ) + return checks.some(Boolean) + }, + async send(msg, ctx) { + return (await run([msg], ctx))[0]! + }, + sendBatch: (msgs, ctx) => run(msgs, ctx), + } +} + +function defaultShouldFailover(error: EmailError): boolean { + return error.code !== "INVALID_OPTIONS" && error.code !== "UNSUPPORTED" +} + +/** A capability is advertised when the leg most likely to run it has it — + * the first driver, since later legs only see what it could not send. */ +function mergeFeatures(drivers: readonly EmailDriver[]) { + return drivers[0]?.features +} + +function noResult(driver: string) { + return createError(driver, "PROVIDER", "no result for message") +} diff --git a/src/drivers/mock.ts b/src/drivers/mock.ts new file mode 100644 index 0000000..4852ee3 --- /dev/null +++ b/src/drivers/mock.ts @@ -0,0 +1,126 @@ +import type { DriverWithInstance, EmailResult, NormalizedMessage, Result } from "../core/types.ts" +import { defineDriver } from "../core/define.ts" +import { createError } from "../core/error.ts" +import { err, ok } from "../core/result.ts" + +/** The captured mailbox, also returned by `driver.getInstance()`. */ +export interface MockInbox { + /** Every message the driver accepted, in order, already normalized. */ + readonly messages: readonly NormalizedMessage[] + /** Messages whose recipients include `address`, case-insensitively. */ + find: (address: string) => readonly NormalizedMessage[] + /** The most recent message, or `undefined`. */ + last: () => NormalizedMessage | undefined + clear: () => void +} + +export interface MockDriverOptions { + /** Fail every send with this code. Use it to exercise retry, circuit + * breaker, and failover without a network. */ + fail?: boolean | { code?: "NETWORK" | "AUTH" | "RATE_LIMIT" | "PROVIDER"; message?: string } + /** Fail only the messages this predicate matches — for testing partial + * batch failures, which is where most batch bugs live. */ + failWhen?: (msg: NormalizedMessage, index: number) => boolean + /** Artificial delay per send, in milliseconds. */ + latencyMs?: number + /** Reuse an inbox across instances. */ + inbox?: MockInbox +} + +/** + * An in-memory driver that records instead of sending. + * + * ```ts + * const driver = mock() + * const email = createEmail({ driver, defaults: { from: "a@b.com" } }) + * await email.send({ to: "c@d.com", subject: "hi", text: "hello" }) + * expect(driver.getInstance().last()?.subject).toBe("hi") + * ``` + */ +const mock: (options?: MockDriverOptions) => DriverWithInstance = defineDriver< + MockDriverOptions | void, + MockInbox +>((options) => { + const opts = options || {} + const inbox = opts.inbox ?? createInbox() + let counter = 0 + + function failure(): Result { + const spec = typeof opts.fail === "object" ? opts.fail : {} + return err(createError("mock", spec.code ?? "PROVIDER", spec.message ?? "configured to fail")) + } + + return { + name: "mock", + features: { + attachments: true, + html: true, + text: true, + batch: true, + scheduling: true, + idempotency: true, + tagging: true, + templates: true, + tracking: true, + replyTo: true, + customHeaders: true, + sandbox: true, + }, + + getInstance: () => inbox, + isAvailable: () => opts.fail !== true, + + async send(msg, ctx) { + if (opts.latencyMs) await new Promise((resolve) => setTimeout(resolve, opts.latencyMs)) + if (opts.fail || opts.failWhen?.(msg, 0)) return failure() + ;(inbox.messages as NormalizedMessage[]).push(msg) + return ok({ + id: `mock_${++counter}`, + driver: "mock", + ...(ctx.stream ? { stream: ctx.stream } : {}), + at: new Date(), + }) + }, + + async sendBatch(msgs, ctx) { + const results: Result[] = [] + for (const [index, msg] of msgs.entries()) { + if (opts.latencyMs) await new Promise((resolve) => setTimeout(resolve, opts.latencyMs)) + if (opts.fail || opts.failWhen?.(msg, index)) { + results.push(failure()) + continue + } + ;(inbox.messages as NormalizedMessage[]).push(msg) + results.push( + ok({ + id: `mock_${++counter}`, + driver: "mock", + ...(ctx.stream ? { stream: ctx.stream } : {}), + at: new Date(), + }), + ) + } + return results + }, + } +}) + +export default mock + +/** Build a standalone inbox, for sharing one across several mock drivers. */ +export function createInbox(): MockInbox { + const messages: NormalizedMessage[] = [] + return { + messages, + find(address) { + const target = address.toLowerCase() + return messages.filter((msg) => + [...msg.to, ...msg.cc, ...msg.bcc].some((a) => a.email.toLowerCase() === target), + ) + }, + last: () => messages.at(-1), + clear: () => { + messages.length = 0 + }, + } +} diff --git a/src/drivers/postmark.ts b/src/drivers/postmark.ts new file mode 100644 index 0000000..8c85977 --- /dev/null +++ b/src/drivers/postmark.ts @@ -0,0 +1,200 @@ +import type { DriverFactory, EmailResult, NormalizedMessage, Result } from "../core/types.ts" +import { formatAddress, formatAddressList } from "../core/address.ts" +import { defineDriver } from "../core/define.ts" +import { createError, createRequiredError } from "../core/error.ts" +import { err, ok } from "../core/result.ts" +import { attachmentToBase64 } from "./_base64.ts" +import { classifyStatus, httpJson, resolveFetch } from "./_fetch.ts" + +export interface PostmarkOptions { + /** The per-server token, not the account token. */ + token: string + /** `MessageStream` for messages that do not set `stream` themselves. */ + messageStream?: string + /** Override the base URL — for a gateway or a test stub. */ + endpoint?: string + /** Injected fetch. Defaults to the global. */ + fetch?: typeof fetch +} + +const DRIVER = "postmark" +/** Postmark's "sender signature not confirmed" / bad-token family. */ +const AUTH_ERROR_CODES = new Set([10, 400, 401]) + +/** + * Postmark, over its REST API. The one mainstream provider with real + * transactional/broadcast stream isolation — route with `message.stream` + * or set `messageStream` as the instance default. + * + * ```ts + * const email = createEmail({ driver: postmark({ token }) }) + * await email.send({ ...msg, stream: "broadcast" }) + * ``` + */ +const postmark: DriverFactory = defineDriver((options) => { + if (!options?.token) throw createRequiredError(DRIVER, "token") + const endpoint = (options.endpoint ?? "https://api.postmarkapp.com").replace(/\/$/, "") + const fetchImpl = resolveFetch(DRIVER, options.fetch) + + function request(path: string, body: unknown) { + return httpJson({ + fetch: fetchImpl, + driver: DRIVER, + url: `${endpoint}${path}`, + headers: { "x-postmark-server-token": options.token }, + body, + classify(status, parsed) { + const code = (parsed as { ErrorCode?: number } | null)?.ErrorCode + if (code != null && AUTH_ERROR_CODES.has(code)) return { code: "AUTH" } + return { code: classifyStatus(status) } + }, + }) + } + + return { + name: DRIVER, + features: { + attachments: true, + html: true, + text: true, + batch: true, + tracking: true, + templates: true, + tagging: true, + replyTo: true, + customHeaders: true, + }, + + isAvailable: () => Boolean(options.token), + + async send(msg) { + const payload = toPayload(msg, options.messageStream) + const response = await request(msg.template ? "/email/withTemplate" : "/email", payload) + if (response.error) return err(response.error) + return toResult(response.data as PostmarkResponse, msg, options.messageStream) + }, + + async sendBatch(msgs) { + const withTemplate = msgs.some((msg) => msg.template) + // Postmark keeps templated and plain batches on separate endpoints, + // and will not mix them in one request. + if (withTemplate && msgs.some((msg) => !msg.template)) { + const conflict = err( + createError( + DRIVER, + "INVALID_OPTIONS", + "a batch must be all templated or all plain — Postmark has no mixed endpoint", + ), + ) + return msgs.map(() => conflict) + } + + const payload = msgs.map((msg) => toPayload(msg, options.messageStream)) + const response = await request( + withTemplate ? "/email/batchWithTemplates" : "/email/batch", + withTemplate ? { Messages: payload } : payload, + ) + if (response.error) return msgs.map(() => err(response.error)) + + const entries = (response.data ?? []) as PostmarkResponse[] + // Postmark reports per-message failures inside a 200 response, which + // is exactly the case an all-or-nothing batch used to lose. + return msgs.map((msg, index) => { + const entry = entries[index] + if (!entry) + return err(createError(DRIVER, "PROVIDER", "no result for message")) + return toResult(entry, msg, options.messageStream) + }) + }, + } +}) + +export default postmark + +interface PostmarkResponse { + MessageID?: string + SubmittedAt?: string + ErrorCode?: number + Message?: string +} + +function toResult( + entry: PostmarkResponse, + msg: NormalizedMessage, + defaultStream?: string, +): Result { + if (entry.ErrorCode) { + const code = AUTH_ERROR_CODES.has(entry.ErrorCode) ? "AUTH" : "PROVIDER" + return err( + createError(DRIVER, code, entry.Message ?? `ErrorCode ${entry.ErrorCode}`, { + status: entry.ErrorCode, + retryable: false, + cause: entry, + }), + ) + } + if (!entry.MessageID) { + return err( + createError(DRIVER, "PROVIDER", "response did not contain a MessageID", { cause: entry }), + ) + } + const at = entry.SubmittedAt ? new Date(entry.SubmittedAt) : null + const stream = msg.stream ?? defaultStream + return ok({ + id: entry.MessageID, + driver: DRIVER, + ...(stream ? { stream } : {}), + at: at && !Number.isNaN(at.getTime()) ? at : new Date(), + provider: entry as Record, + }) +} + +function toPayload(msg: NormalizedMessage, defaultStream?: string): Record { + const payload: Record = { + From: formatAddress(msg.from), + To: formatAddressList(msg.to), + Subject: msg.subject, + } + if (msg.cc.length > 0) payload.Cc = formatAddressList(msg.cc) + if (msg.bcc.length > 0) payload.Bcc = formatAddressList(msg.bcc) + if (msg.replyTo.length > 0) payload.ReplyTo = formatAddressList(msg.replyTo) + if (msg.text != null) payload.TextBody = msg.text + if (msg.html != null) payload.HtmlBody = msg.html + + const headers = Object.entries(msg.headers) + if (headers.length > 0) payload.Headers = headers.map(([Name, Value]) => ({ Name, Value })) + if (Object.keys(msg.metadata).length > 0) payload.Metadata = { ...msg.metadata } + // Postmark takes exactly one tag; the rest carry as metadata so nothing + // the caller set is silently dropped. + if (msg.tags.length > 0) { + payload.Tag = msg.tags[0]!.name + if (msg.tags.length > 1) { + payload.Metadata = { + ...(payload.Metadata as Record | undefined), + ...Object.fromEntries(msg.tags.slice(1).map((tag) => [tag.name, tag.value])), + } + } + } + if (msg.tracking?.opens != null) payload.TrackOpens = msg.tracking.opens + if (msg.tracking?.clicks != null) + payload.TrackLinks = msg.tracking.clicks ? "HtmlAndText" : "None" + if (msg.attachments.length > 0) { + payload.Attachments = msg.attachments.map((attachment) => ({ + Name: attachment.filename, + Content: attachmentToBase64(attachment.content), + ContentType: attachment.contentType ?? "application/octet-stream", + ...(attachment.cid ? { ContentID: `cid:${attachment.cid}` } : {}), + })) + } + if (msg.template) { + if (msg.template.id) { + const numeric = Number.parseInt(msg.template.id, 10) + payload.TemplateId = Number.isNaN(numeric) ? msg.template.id : numeric + } + if (msg.template.alias) payload.TemplateAlias = msg.template.alias + if (msg.template.variables) payload.TemplateModel = { ...msg.template.variables } + } + const stream = msg.stream ?? defaultStream + if (stream) payload.MessageStream = stream + return payload +} diff --git a/src/drivers/resend.ts b/src/drivers/resend.ts new file mode 100644 index 0000000..d61e407 --- /dev/null +++ b/src/drivers/resend.ts @@ -0,0 +1,186 @@ +import type { + DriverFactory, + EmailResult, + NormalizedMessage, + SendState, + SendStatus, +} from "../core/types.ts" +import { formatAddress } from "../core/address.ts" +import { defineDriver } from "../core/define.ts" +import { createError, createRequiredError } from "../core/error.ts" +import { err, ok } from "../core/result.ts" +import { attachmentToBase64 } from "./_base64.ts" +import { httpJson, resolveFetch } from "./_fetch.ts" + +export interface ResendOptions { + /** Server API key. Starts with `re_`. */ + apiKey: string + /** Override the base URL — for a gateway or a test stub. */ + endpoint?: string + /** Injected fetch. Defaults to the global. */ + fetch?: typeof fetch +} + +const DRIVER = "resend" + +/** + * Resend, over its REST API. + * + * ```ts + * createEmail({ driver: resend({ apiKey: process.env.RESEND_API_KEY! }) }) + * ``` + */ +const resend: DriverFactory = defineDriver((options) => { + if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey") + if (!options.apiKey.startsWith("re_")) { + throw createError(DRIVER, "INVALID_OPTIONS", "`apiKey` must start with 're_'") + } + const endpoint = (options.endpoint ?? "https://api.resend.com").replace(/\/$/, "") + const fetchImpl = resolveFetch(DRIVER, options.fetch) + + function request(path: string, method: string, body: unknown, idempotencyKey?: string) { + return httpJson({ + fetch: fetchImpl, + driver: DRIVER, + url: `${endpoint}${path}`, + method, + headers: { + authorization: `Bearer ${options.apiKey}`, + ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), + }, + ...(body === undefined ? {} : { body }), + }) + } + + return { + name: DRIVER, + features: { + attachments: true, + html: true, + text: true, + batch: true, + scheduling: true, + idempotency: true, + tagging: true, + replyTo: true, + customHeaders: true, + cancelable: true, + retrievable: true, + }, + + isAvailable: () => Boolean(options.apiKey), + + async send(msg) { + const response = await request("/emails", "POST", toPayload(msg), msg.idempotencyKey) + if (response.error) return err(response.error) + const body = (response.data ?? {}) as { id?: string } + if (!body.id) return err(missingId(response.data)) + return ok(toResult(body.id, msg, body)) + }, + + async sendBatch(msgs) { + const response = await request("/emails/batch", "POST", msgs.map(toPayload)) + if (response.error) return msgs.map(() => err(response.error)) + const body = (response.data ?? {}) as { data?: { id: string }[] } + const entries = body.data ?? [] + // Resend answers positionally; if it ever does not, the core's + // length check turns that into a loud failure rather than a + // silently mismatched set of ids. + return msgs.map((msg, index) => { + const entry = entries[index] + return entry?.id ? ok(toResult(entry.id, msg, entry)) : err(missingId(body)) + }) + }, + + async cancel(id) { + const response = await request(`/emails/${encodeURIComponent(id)}/cancel`, "POST", {}) + return response.error ? err(response.error) : ok(undefined) + }, + + async retrieve(id) { + const response = await request(`/emails/${encodeURIComponent(id)}`, "GET", undefined) + if (response.error) return err(response.error) + const body = (response.data ?? {}) as { + id?: string + last_event?: string + created_at?: string + } + const status: SendStatus = { + id: body.id ?? id, + driver: DRIVER, + state: toState(body.last_event), + ...(body.created_at ? { at: new Date(body.created_at) } : {}), + provider: body, + } + return ok(status) + }, + } +}) + +export default resend + +function toPayload(msg: NormalizedMessage): Record { + const headers: Record = { ...msg.headers } + // Resend has no metadata field of its own; custom headers are what come + // back on its webhook events. + for (const [key, value] of Object.entries(msg.metadata)) headers[`X-Metadata-${key}`] = value + + const payload: Record = { + from: formatAddress(msg.from), + to: msg.to.map(formatAddress), + subject: msg.subject, + } + if (msg.cc.length > 0) payload.cc = msg.cc.map(formatAddress) + if (msg.bcc.length > 0) payload.bcc = msg.bcc.map(formatAddress) + if (msg.replyTo.length > 0) payload.reply_to = msg.replyTo.map(formatAddress) + if (msg.text != null) payload.text = msg.text + if (msg.html != null) payload.html = msg.html + if (Object.keys(headers).length > 0) payload.headers = headers + if (msg.tags.length > 0) payload.tags = msg.tags.map((t) => ({ name: t.name, value: t.value })) + if (msg.scheduledAt) payload.scheduled_at = msg.scheduledAt.toISOString() + if (msg.attachments.length > 0) { + payload.attachments = msg.attachments.map((attachment) => ({ + filename: attachment.filename, + content: attachmentToBase64(attachment.content), + ...(attachment.contentType ? { content_type: attachment.contentType } : {}), + ...(attachment.cid ? { content_id: attachment.cid } : {}), + })) + } + return payload +} + +function toResult( + id: string, + msg: NormalizedMessage, + provider: Record, +): EmailResult { + return { + id, + driver: DRIVER, + ...(msg.stream ? { stream: msg.stream } : {}), + at: new Date(), + provider, + } +} + +function missingId(body: unknown) { + return createError(DRIVER, "PROVIDER", "response did not contain an email id", { cause: body }) +} + +function toState(event?: string): SendState { + switch (event) { + case "sent": + case "delivered": + case "complained": + case "opened": + case "clicked": + case "scheduled": + case "cancelled": + case "bounced": + return event + case "delivery_delayed": + return "queued" + default: + return "unknown" + } +} diff --git a/src/drivers/round-robin.ts b/src/drivers/round-robin.ts new file mode 100644 index 0000000..9b3c260 --- /dev/null +++ b/src/drivers/round-robin.ts @@ -0,0 +1,115 @@ +import type { EmailDriver, EmailResult, SendContext } from "../core/types.ts" +import { driverHandler } from "../core/define.ts" +import { createError } from "../core/error.ts" +import { err } from "../core/result.ts" + +export interface RoundRobinOptions { + /** Relative share per driver, positionally. `[3, 1]` sends three times + * as much through the first. Default: equal weights. */ + weights?: readonly number[] + /** Name reported on results and errors. Default: `round-robin`. */ + name?: string +} + +/** + * Spread sends across providers to stay under each one's rate limit, or to + * warm a second sending domain. + * + * A batch is partitioned and each partition goes to its driver in one + * request, so native batching survives the split. This does not fail over — + * wrap it in `fallback()` if you need that. + * + * ```ts + * roundRobin([resend({ apiKey }), ses({ region })], { weights: [3, 1] }) + * ``` + */ +export function roundRobin( + drivers: readonly EmailDriver[], + options: RoundRobinOptions = {}, +): EmailDriver { + if (drivers.length === 0) throw createError("round-robin", "INVALID_OPTIONS", "no drivers given") + const name = options.name ?? "round-robin" + const handlers = drivers.map((driver) => ({ driver, handle: driverHandler(driver) })) + // The weights are expanded into a fixed schedule once, so picking the + // next driver is an array index rather than a weighted draw per send. + const schedule = buildSchedule(drivers.length, options.weights) + let cursor = 0 + + function nextIndex(): number { + const index = schedule[cursor % schedule.length]! + cursor = (cursor + 1) % schedule.length + return index + } + + return { + name, + features: drivers[0]?.features, + getInstance: () => drivers, + async dispose() { + await Promise.all(drivers.map((driver) => driver.dispose?.())) + }, + async isAvailable() { + const checks = await Promise.all( + drivers.map(async (driver) => { + try { + return (await driver.isAvailable?.()) ?? true + } catch { + return false + } + }), + ) + return checks.some(Boolean) + }, + + async send(msg, ctx) { + const { driver, handle } = handlers[nextIndex()]! + await driver.initialize?.() + const produced = await handle([msg], { ...ctx, driver: driver.name }) + return produced[0] ?? err(noResult(driver.name)) + }, + + async sendBatch(msgs, ctx) { + const partitions = new Map() + for (const index of msgs.keys()) { + const target = nextIndex() + const bucket = partitions.get(target) + if (bucket) bucket.push(index) + else partitions.set(target, [index]) + } + + const results = Array.from({ length: msgs.length }, () => err(noResult(name))) + await Promise.all( + [...partitions].map(async ([target, indices]) => { + const { driver, handle } = handlers[target]! + const ctxForLeg: SendContext = { ...ctx, driver: driver.name } + await driver.initialize?.() + const produced = await handle( + indices.map((index) => msgs[index]!), + ctxForLeg, + ) + for (const [slot, index] of indices.entries()) { + results[index] = produced[slot] ?? err(noResult(driver.name)) + } + }), + ) + return results + }, + } +} + +function buildSchedule(count: number, weights?: readonly number[]): number[] { + if (!weights) return Array.from({ length: count }, (_, index) => index) + const schedule: number[] = [] + for (let index = 0; index < count; index++) { + const repeat = Math.max(0, Math.floor(weights[index] ?? 1)) + for (let n = 0; n < repeat; n++) schedule.push(index) + } + if (schedule.length === 0) { + throw createError("round-robin", "INVALID_OPTIONS", "every weight was zero") + } + return schedule +} + +function noResult(driver: string) { + return createError(driver, "PROVIDER", "no result for message") +} diff --git a/src/drivers/ses.ts b/src/drivers/ses.ts new file mode 100644 index 0000000..5a8a816 --- /dev/null +++ b/src/drivers/ses.ts @@ -0,0 +1,165 @@ +import type { DriverFactory, EmailResult, NormalizedMessage } from "../core/types.ts" +import type { AwsCredentials } from "./_ses/sigv4.ts" +import { defineDriver } from "../core/define.ts" +import { createError, createRequiredError, toEmailError } from "../core/error.ts" +import { err, ok } from "../core/result.ts" +import { stringToBase64 } from "./_base64.ts" +import { classifyStatus, httpJson, resolveFetch } from "./_fetch.ts" +import { buildMime, resolveMessageId, toMimeInput } from "./_mime.ts" +import { signRequest } from "./_ses/sigv4.ts" + +export interface SesOptions { + region: string + /** Falls back to `AWS_ACCESS_KEY_ID`. */ + accessKeyId?: string + /** Falls back to `AWS_SECRET_ACCESS_KEY`. */ + secretAccessKey?: string + /** Falls back to `AWS_SESSION_TOKEN`. */ + sessionToken?: string + /** Configuration set used for event publishing. */ + configurationSetName?: string + /** `FromEmailAddressIdentityArn`, for cross-account sending authority. */ + fromArn?: string + /** Override the endpoint — VPC endpoints, GovCloud, or a test stub. */ + endpoint?: string + /** Injected fetch. Defaults to the global. */ + fetch?: typeof fetch + /** Injected clock, for deterministic SigV4 signatures in tests. */ + now?: () => Date +} + +const DRIVER = "ses" +const AUTH_ERRORS = /InvalidClientTokenId|SignatureDoesNotMatch|AccessDenied|UnrecognizedClient/ +const THROTTLE_ERRORS = /Throttling|TooManyRequests|LimitExceeded/ + +/** + * Amazon SES v2 with no `@aws-sdk/*` dependency: SigV4 over Web Crypto and + * raw MIME from the shared builder, so attachments and inline images work + * and the whole driver runs in a Worker. + * + * ```ts + * createEmail({ driver: ses({ region: "eu-central-1" }) }) + * ``` + */ +const ses: DriverFactory = defineDriver((options) => { + if (!options?.region) throw createRequiredError(DRIVER, "region") + + const credentials = resolveCredentials(options) + if (!credentials) { + throw createError( + DRIVER, + "INVALID_OPTIONS", + "no credentials — pass accessKeyId + secretAccessKey, or set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY", + ) + } + + const endpoint = (options.endpoint ?? `https://email.${options.region}.amazonaws.com`).replace( + /\/$/, + "", + ) + const fetchImpl = resolveFetch(DRIVER, options.fetch) + + return { + name: DRIVER, + features: { + attachments: true, + html: true, + text: true, + tagging: true, + replyTo: true, + customHeaders: true, + }, + + isAvailable: () => Boolean(credentials.accessKeyId && credentials.secretAccessKey), + + async send(msg, ctx) { + const url = `${endpoint}/v2/email/outbound-emails` + const body = JSON.stringify(toPayload(msg, options)) + + let signed + try { + signed = await signRequest({ + method: "POST", + url, + body, + headers: { "content-type": "application/json" }, + region: options.region, + service: "ses", + credentials, + ...(options.now ? { now: options.now } : {}), + }) + } catch (error) { + return err(toEmailError(DRIVER, error)) + } + + const response = await httpJson({ + fetch: fetchImpl, + driver: DRIVER, + url: signed.url, + method: signed.method, + headers: signed.headers, + body: signed.body, + ...(ctx.signal ? { signal: ctx.signal } : {}), + classify(status, parsed) { + // SES puts the real reason in `__type`; the status alone would + // make an expired token look like an ordinary 400. + const type = (parsed as { __type?: string } | null)?.__type ?? "" + if (AUTH_ERRORS.test(type)) return { code: "AUTH", retryable: false } + if (THROTTLE_ERRORS.test(type)) return { code: "RATE_LIMIT", retryable: true } + return { code: classifyStatus(status) } + }, + }) + if (response.error) return err(response.error) + + const parsed = (response.data ?? {}) as { MessageId?: string } + if (!parsed.MessageId) { + return err( + createError(DRIVER, "PROVIDER", "response did not contain a MessageId", { + cause: response.data, + }), + ) + } + const result: EmailResult = { + id: parsed.MessageId, + driver: DRIVER, + ...(msg.stream ? { stream: msg.stream } : {}), + at: new Date(), + provider: parsed, + } + return ok(result) + }, + } +}) + +export default ses + +function toPayload(msg: NormalizedMessage, options: SesOptions): Record { + const mime = buildMime(toMimeInput(msg, resolveMessageId(msg, "ses.amazonaws.com"))) + const payload: Record = { + FromEmailAddress: mime.headers.From, + // SES reads recipients off the envelope, which is where bcc lives — + // passing the rendered headers would drop every blind recipient. + Destination: { ToAddresses: mime.envelope.rcpt }, + Content: { Raw: { Data: stringToBase64(mime.body) } }, + } + if (options.configurationSetName) payload.ConfigurationSetName = options.configurationSetName + if (options.fromArn) payload.FromEmailAddressIdentityArn = options.fromArn + if (msg.replyTo.length > 0) payload.ReplyToAddresses = msg.replyTo.map((a) => a.email) + if (msg.tags.length > 0) { + payload.EmailTags = msg.tags.map((tag) => ({ Name: tag.name, Value: tag.value })) + } + return payload +} + +function resolveCredentials(options: SesOptions): AwsCredentials | null { + const accessKeyId = options.accessKeyId ?? readEnv("AWS_ACCESS_KEY_ID") + const secretAccessKey = options.secretAccessKey ?? readEnv("AWS_SECRET_ACCESS_KEY") + if (!accessKeyId || !secretAccessKey) return null + const sessionToken = options.sessionToken ?? readEnv("AWS_SESSION_TOKEN") + return { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) } +} + +function readEnv(name: string): string | undefined { + const proc = (globalThis as { process?: { env?: Record } }).process + return proc?.env?.[name] +} diff --git a/src/drivers/smtp.ts b/src/drivers/smtp.ts new file mode 100644 index 0000000..820536d --- /dev/null +++ b/src/drivers/smtp.ts @@ -0,0 +1,181 @@ +import type { DriverWithInstance, NormalizedMessage } from "../core/types.ts" +import type { AuthMethod } from "./_smtp/auth.ts" +import type { ConnectionOptions } from "./_smtp/connection.ts" +import type { ConnectionPool } from "./_smtp/pool.ts" +import type { DkimSignerOptions } from "./_smtp/dkim.ts" +import { defineDriver } from "../core/define.ts" +import { createError, createRequiredError, toEmailError } from "../core/error.ts" +import { err, ok } from "../core/result.ts" +import { buildMime, resolveMessageId, toMimeInput } from "./_mime.ts" +import { createPool } from "./_smtp/pool.ts" +import { signDkim } from "./_smtp/dkim.ts" + +export type { DkimSignerOptions } + +export interface SmtpOptions { + host: string + /** Defaults to 465 when `secure`, 587 otherwise. */ + port?: number + /** Implicit TLS from the first byte (port 465). Default: false, which + * means plain connect then STARTTLS. */ + secure?: boolean + /** Refuse to send if STARTTLS is unavailable. */ + requireTLS?: boolean + user?: string + password?: string + /** Default `AUTO` picks the strongest method the server advertises. */ + authMethod?: AuthMethod | "AUTO" + /** Supply an OAuth2 bearer token for XOAUTH2. */ + getAccessToken?: () => Promise + /** Default: true. Turning it off accepts any certificate — only ever + * reasonable against a local test server. */ + rejectUnauthorized?: boolean + tls?: import("node:tls").ConnectionOptions + /** Name sent in EHLO. Defaults to the machine's hostname. */ + localName?: string + + /** Keep connections open between sends. Default: false. */ + pool?: boolean + maxConnections?: number + maxMessagesPerConnection?: number + idleTimeoutMs?: number + connectionTimeoutMs?: number + commandTimeoutMs?: number + disposeGraceMs?: number + + /** Sign outbound mail with DKIM (RFC 6376 / RFC 8463). Pass a function + * to select a key per message, for multi-tenant sending. */ + dkim?: DkimSignerOptions | ((msg: NormalizedMessage) => DkimSignerOptions | null) +} + +const DRIVER = "smtp" + +/** + * Speaks SMTP directly — no `nodemailer`, no transitive dependencies. + * Requires `node:net` and `node:tls`, so this is the one driver that does + * not run in a Worker. + * + * ```ts + * createEmail({ driver: smtp({ host: "smtp.acme.com", user, password, pool: true }) }) + * ``` + */ +const smtp: (options: SmtpOptions) => DriverWithInstance = defineDriver< + SmtpOptions, + ConnectionPool +>((options) => { + if (!options?.host) throw createRequiredError(DRIVER, "host") + + const secure = options.secure ?? false + const connection: ConnectionOptions = { + host: options.host, + port: options.port ?? (secure ? 465 : 587), + secure, + requireTLS: options.requireTLS, + user: options.user, + password: options.password, + authMethod: options.authMethod ?? "AUTO", + getAccessToken: options.getAccessToken, + rejectUnauthorized: options.rejectUnauthorized ?? true, + tls: options.tls, + localName: options.localName ?? resolveLocalName(), + connectionTimeoutMs: options.connectionTimeoutMs ?? 30_000, + commandTimeoutMs: options.commandTimeoutMs ?? 10_000, + } + + let pool: ConnectionPool | null = null + const getPool = () => + (pool ??= createPool({ + enabled: options.pool ?? false, + maxConnections: options.maxConnections ?? 5, + maxMessagesPerConnection: options.maxMessagesPerConnection ?? 0, + idleTimeoutMs: options.idleTimeoutMs ?? 60_000, + disposeGraceMs: options.disposeGraceMs ?? 10_000, + connection, + })) + + return { + name: DRIVER, + features: { + attachments: true, + html: true, + text: true, + replyTo: true, + customHeaders: true, + }, + + getInstance: getPool, + + async dispose() { + if (!pool) return + await pool.dispose() + pool = null + }, + + async send(msg) { + const messageId = resolveMessageId(msg, options.host) + + let envelope: { from: string; rcpt: string[] } + let document: string + try { + if (msg.raw != null) { + document = typeof msg.raw === "string" ? msg.raw : new TextDecoder().decode(msg.raw) + envelope = { + from: msg.from.email, + rcpt: [...new Set([...msg.to, ...msg.cc, ...msg.bcc].map((a) => a.email))], + } + } else { + const mime = buildMime(toMimeInput(msg, messageId)) + envelope = mime.envelope + document = mime.body + } + if (envelope.rcpt.length === 0) { + throw createError(DRIVER, "INVALID_OPTIONS", "at least one recipient is required") + } + const dkim = typeof options.dkim === "function" ? options.dkim(msg) : options.dkim + if (dkim) document = await signDkim(document, dkim) + } catch (error) { + return err(toEmailError(DRIVER, error)) + } + + const conn = await getPool().acquire() + let failed = false + try { + await conn.sendMessage(envelope, document) + return ok({ + id: messageId, + driver: DRIVER, + ...(msg.stream ? { stream: msg.stream } : {}), + at: new Date(), + provider: { authMethods: [...conn.capabilities.authMethods] }, + }) + } catch (error) { + failed = true + return err(toEmailError(DRIVER, error)) + } finally { + // A connection that failed mid-transaction is discarded rather + // than returned to the pool in an unknown protocol state. + await getPool() + .release(conn, failed) + .catch(() => {}) + } + }, + } +}) + +export default smtp + +/** EHLO wants a name the server can look up. `require` is reached + * indirectly so bundlers targeting a Worker do not try to resolve + * `node:os` at build time. */ +function resolveLocalName(): string { + const proc = (globalThis as { process?: { versions?: { node?: string } } }).process + if (!proc?.versions?.node) return "localhost" + try { + const req = (globalThis as { require?: (id: string) => unknown }).require + const os = req?.("node:os") as { hostname?: () => string } | undefined + const hostname = os?.hostname?.() + return hostname && /^[\w.-]+$/.test(hostname) ? hostname : "localhost.localdomain" + } catch { + return "localhost.localdomain" + } +} diff --git a/src/email.ts b/src/email.ts deleted file mode 100644 index a41fd9a..0000000 --- a/src/email.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { - EmailDriver, - EmailMessage, - EmailResult, - IdempotencyStore, - MaybePromise, - Middleware, - Result, - SendContext, - SendStatus, -} from "./types.ts" -import { memoryIdempotencyStore } from "./_idempotency.ts" -import { createError, toEmailError } from "./errors.ts" - -function createUnsupported(driver: string, op: string) { - return createError(driver, "UNSUPPORTED", `${op}() not supported by "${driver}"`) -} - -/** Options accepted by `createEmail()`. Only `driver` is required; the rest - * have sensible, zero-dependency defaults. */ -export interface CreateEmailOptions { - driver: EmailDriver - /** When set, enables idempotency-key deduplication backed by this store. - * Defaults to an in-memory TTL store when `idempotency` is `true`. */ - idempotency?: boolean | { store?: IdempotencyStore; ttlSeconds?: number } - /** Abort signal forwarded to drivers via `SendContext.signal`. */ - signal?: AbortSignal -} - -/** Public handle returned by `createEmail()`. Mirrors the unstorage-style - * mount API so callers can route by `message.stream`. */ -export interface Email { - readonly driver: EmailDriver - use: (middleware: Middleware) => Email - mount: (stream: string, driver: EmailDriver) => Email - unmount: (stream: string, dispose?: boolean) => Promise - getMount: (stream?: string) => EmailDriver - getMounts: () => ReadonlyArray<{ stream: string; driver: EmailDriver }> - isAvailable: (stream?: string) => Promise - send: (msg: EmailMessage) => Promise> - sendBatch: (msgs: ReadonlyArray) => Promise>> - /** Stream the results of `sendBatch` one at a time — useful for - * large (5k+) fan-outs where you don't want every `EmailResult` in - * memory. Unlike `sendBatch` it never short-circuits on the first - * error; each message yields its own Result. */ - sendBatchStream: (msgs: ReadonlyArray) => AsyncIterable> - /** Cancel a scheduled send on the active (or mounted) driver. Routes - * to `UNSUPPORTED` when the driver's `flags.cancelable` is unset. */ - cancel: (id: string, options?: { stream?: string }) => Promise> - /** Retrieve the state of a previously-sent message. Routes to - * `UNSUPPORTED` when the driver's `flags.retrievable` is unset. */ - retrieve: (id: string, options?: { stream?: string }) => Promise> - dispose: () => Promise -} - -/** Construct an `Email` instance. This is the single entry point — every - * transport (SMTP, Resend, SES, Postmark, Workers, …) is a `driver` plug. - * - * ```ts - * const email = createEmail({ driver: resend({ apiKey }) }) - * const { data, error } = await email.send({ from, to, subject, text }) - * ``` - */ -export function createEmail(options: CreateEmailOptions): Email { - const mounts = new Map() - const middleware: Middleware[] = [] - let initialized = false - - const idempotency = resolveIdempotency(options.idempotency) - - const api: Email = { - get driver() { - return options.driver - }, - - use(mw) { - middleware.push(mw) - return api - }, - - mount(stream, driver) { - mounts.set(stream, driver) - return api - }, - - async unmount(stream, dispose = true) { - const driver = mounts.get(stream) - if (!driver) return - mounts.delete(stream) - if (dispose) await driver.dispose?.() - }, - - getMount(stream) { - if (!stream) return options.driver - return mounts.get(stream) ?? options.driver - }, - - getMounts() { - return Array.from(mounts.entries(), ([stream, driver]) => ({ stream, driver })) - }, - - async isAvailable(stream) { - const driver = api.getMount(stream) - if (!driver.isAvailable) return true - try { - return await driver.isAvailable() - } catch { - return false - } - }, - - async send(msg) { - await ensureInitialized() - - if (msg.idempotencyKey && idempotency) { - const cached = await idempotency.store.get(msg.idempotencyKey) - if (cached) return { data: cached, error: null } - } - - const driver = api.getMount(msg.stream) - const ctx: SendContext = { - driver: driver.name, - stream: msg.stream, - attempt: 1, - signal: options.signal, - meta: {}, - } - - try { - msg = applyUnsubscribeHeaders(msg) - msg = fanOutPersonalizations(msg, driver) - await runHook("beforeSend", (mw) => mw.beforeSend?.(msg, ctx)) - - let result = await driver.send(msg, ctx) - - if (result.error) { - const recovered = await tryRecover(msg, ctx, result.error) - if (recovered) result = recovered - } - - if (result.data && msg.idempotencyKey && idempotency) { - await idempotency.store.set(msg.idempotencyKey, result.data, idempotency.ttlSeconds) - } - - await runHook("afterSend", (mw) => mw.afterSend?.(msg, ctx, result)) - return result - } catch (error) { - const emailError = toEmailError(driver.name, error) - const recovered = await tryRecover(msg, ctx, emailError) - if (recovered) return recovered - return { data: null, error: emailError } - } - }, - - async sendBatch(msgs) { - await ensureInitialized() - if (msgs.length === 0) return { data: [], error: null } - const driver = api.getMount(msgs[0]!.stream) - const ctx: SendContext = { - driver: driver.name, - stream: msgs[0]!.stream, - attempt: 1, - signal: options.signal, - meta: {}, - } - if (driver.sendBatch) return Promise.resolve(driver.sendBatch(msgs, ctx)).then(resultOrError) - // Fallback — sequential sends honoring individual idempotency keys. - const results: EmailResult[] = [] - for (const msg of msgs) { - const res = await api.send(msg) - if (res.error) return res as Result> - results.push(res.data) - } - return { data: results, error: null } - }, - - async cancel(id, opts = {}) { - const driver = api.getMount(opts.stream) - if (!driver.cancel) { - return { - data: null, - error: createUnsupported(driver.name, "cancel"), - } - } - return driver.cancel(id) - }, - - async retrieve(id, opts = {}) { - const driver = api.getMount(opts.stream) - if (!driver.retrieve) { - return { - data: null, - error: createUnsupported(driver.name, "retrieve"), - } - } - return driver.retrieve(id) - }, - - sendBatchStream(msgs) { - const api2 = api - return { - async *[Symbol.asyncIterator]() { - await ensureInitialized() - for (const msg of msgs) yield await api2.send(msg) - }, - } - }, - - async dispose() { - await options.driver.dispose?.() - for (const driver of mounts.values()) await driver.dispose?.() - mounts.clear() - }, - } - - async function ensureInitialized() { - if (initialized) return - initialized = true - await options.driver.initialize?.() - for (const driver of mounts.values()) await driver.initialize?.() - } - - async function runHook( - _kind: K, - apply: (mw: Middleware) => MaybePromise, - ) { - for (const mw of middleware) await apply(mw) - } - - async function tryRecover( - msg: EmailMessage, - ctx: SendContext, - error: Parameters["onError"]>[2], - ) { - for (const mw of middleware) { - const recovered = await mw.onError?.(msg, ctx, error) - if (recovered) return recovered - } - return null - } - - return api -} - -function fanOutPersonalizations(msg: EmailMessage, driver: EmailDriver): EmailMessage { - // Drivers with native personalization support get the array as-is. - if (driver.flags?.personalizations) return msg - if (!msg.personalizations?.length) return msg - // For drivers without native support, collapse into msg-level `to` - // so at least the first personalization reaches the provider. Users - // who need per-recipient personalization on a non-supporting driver - // should loop `email.send` themselves. - const first = msg.personalizations[0]! - const { personalizations: _omit, ...rest } = msg - return { - ...rest, - to: first.to, - cc: first.cc ?? rest.cc, - bcc: first.bcc ?? rest.bcc, - subject: first.subject ?? rest.subject, - } -} - -function applyUnsubscribeHeaders(msg: EmailMessage): EmailMessage { - if (!msg.unsubscribe) return msg - const { url, mailto, oneClick } = msg.unsubscribe - if (!url && !mailto) return msg - const parts: string[] = [] - if (url) parts.push(`<${url}>`) - if (mailto) parts.push(``) - const existing = msg.headers ?? {} - const headers: Record = { ...existing } - if (!hasHeader(existing, "list-unsubscribe")) { - headers["List-Unsubscribe"] = parts.join(", ") - } - const wantsOneClick = oneClick ?? Boolean(url) - if (wantsOneClick && url && !hasHeader(existing, "list-unsubscribe-post")) { - headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" - } - return { ...msg, headers } -} - -function hasHeader(headers: Record, name: string): boolean { - const lower = name.toLowerCase() - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === lower) return true - } - return false -} - -function resolveIdempotency( - input: CreateEmailOptions["idempotency"], -): { store: IdempotencyStore; ttlSeconds?: number } | null { - if (!input) return null - if (input === true) return { store: memoryIdempotencyStore() } - return { store: input.store ?? memoryIdempotencyStore(), ttlSeconds: input.ttlSeconds } -} - -function resultOrError(r: Result): Result { - return r -} diff --git a/src/errors.ts b/src/errors.ts deleted file mode 100644 index bc1f25e..0000000 --- a/src/errors.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { type EmailErrorCode, EmailError } from "./types.ts" - -/** Construct an `EmailError` with a consistent `[unemail] [driver] ...` - * prefix so users can grep logs for a single provider. */ -export function createError( - driver: string, - code: EmailErrorCode, - message: string, - init?: { status?: number; retryable?: boolean; cause?: unknown }, -): EmailError { - return new EmailError({ - driver, - code, - message: `[unemail] [${driver}] ${message}`, - status: init?.status, - retryable: init?.retryable, - cause: init?.cause, - }) -} - -/** Error for missing required options — surfaced at driver initialization - * so misconfiguration fails fast. */ -export function createRequiredError(driver: string, name: string | readonly string[]): EmailError { - const names = Array.isArray(name) ? name.join(", ") : String(name) - return createError(driver, "INVALID_OPTIONS", `Missing required option(s): ${names}`) -} - -/** Normalize any thrown value into a typed `EmailError`. Preserves an - * existing `EmailError` unchanged so retry/status info survives. */ -export function toEmailError(driver: string, error: unknown): EmailError { - if (error instanceof EmailError) return error - if (error instanceof Error) - return createError(driver, "PROVIDER", error.message, { cause: error }) - return createError(driver, "PROVIDER", String(error), { cause: error }) -} - -export { EmailError } diff --git a/src/events/index.ts b/src/events/index.ts deleted file mode 100644 index fc5fc53..0000000 --- a/src/events/index.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Unified `EmailEvent` stream that merges send-side events (queued, - * attempt, success, error) with webhook-side events (delivered, opened, - * clicked, bounced, complained, unsubscribed). - * - * Feeds observability dashboards and audit logs without the consumer - * having to stitch webhook + send sources by hand. - * - * @module - */ - -import type { EmailDriver, Middleware } from "../types.ts" - -export type EmailEventType = - | "send.queued" - | "send.attempt" - | "send.success" - | "send.error" - | "delivered" - | "opened" - | "clicked" - | "bounced" - | "complained" - | "unsubscribed" - | "spam_reported" - -export interface EmailEvent { - type: EmailEventType - messageId?: string - recipient?: string - provider: string - at: Date - meta?: Record -} - -export interface EventStore { - append: (event: EmailEvent) => void | Promise - list?: (messageId: string) => EmailEvent[] | Promise -} - -export interface MemoryEventStoreOptions { - capacity?: number -} - -export function memoryEventStore(opts: MemoryEventStoreOptions = {}): EventStore { - const capacity = opts.capacity ?? 10_000 - const events: EmailEvent[] = [] - return { - append(event) { - events.push(event) - if (events.length > capacity) events.shift() - }, - list(messageId) { - return events.filter((e) => e.messageId === messageId) - }, - } -} - -/** Emit `send.*` events around a driver's send call. Pair with webhook - * ingestion (which already emits delivered/opened/bounced etc.) by - * piping both into the same store. */ -export function withEvents( - driver: EmailDriver, - bus: { emit: (event: EmailEvent) => void }, -): EmailDriver { - return { - ...driver, - async send(msg, ctx) { - const recipient = typeof msg.to === "string" ? msg.to : undefined - bus.emit({ - type: "send.queued", - recipient, - provider: driver.name, - at: new Date(), - meta: { attempt: ctx.attempt }, - }) - bus.emit({ - type: "send.attempt", - recipient, - provider: driver.name, - at: new Date(), - meta: { attempt: ctx.attempt }, - }) - const result = await driver.send(msg, ctx) - bus.emit({ - type: result.error ? "send.error" : "send.success", - messageId: result.data?.id, - recipient, - provider: driver.name, - at: new Date(), - meta: { error: result.error?.code }, - }) - return result - }, - } -} - -/** Tiny event bus: emit → listeners. Plug a store as a listener. */ -export class EventBus { - private listeners: Array<(event: EmailEvent) => void> = [] - emit(event: EmailEvent): void { - for (const l of this.listeners) l(event) - } - on(listener: (event: EmailEvent) => void): () => void { - this.listeners.push(listener) - return () => { - this.listeners = this.listeners.filter((l) => l !== listener) - } - } -} - -/** Observability middleware — wires `EmailMessage` beforeSend/afterSend - * into a user-supplied event bus. Alternative to `withEvents` when - * you want a Middleware shape. */ -export function eventsMiddleware(bus: EventBus): Middleware { - return { - name: "events", - beforeSend(msg, ctx) { - bus.emit({ - type: "send.queued", - provider: ctx.driver, - recipient: typeof msg.to === "string" ? msg.to : undefined, - at: new Date(), - }) - }, - afterSend(msg, ctx, result) { - bus.emit({ - type: result.error ? "send.error" : "send.success", - messageId: result.data?.id, - provider: ctx.driver, - recipient: typeof msg.to === "string" ? msg.to : undefined, - at: new Date(), - meta: { error: result.error?.code }, - }) - }, - } -} diff --git a/src/ics/index.ts b/src/ics/index.ts deleted file mode 100644 index c3ad565..0000000 --- a/src/ics/index.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Minimal, dependency-free builder for iCalendar (RFC 5545) event - * invites. Produces an `Attachment` you can hand to `email.send()`. - * - * Scope: single VEVENT with optional organizer + attendees + alarms. - * That covers the meeting-invite use case, which is the 95% that - * nodemailer's `icalEvent` shipped and every user needs. - * - * @module - */ - -import type { Attachment } from "../types.ts" - -export type IcsMethod = "REQUEST" | "PUBLISH" | "CANCEL" | "REPLY" -export type IcsStatus = "CONFIRMED" | "TENTATIVE" | "CANCELLED" -export type IcsRole = "REQ-PARTICIPANT" | "OPT-PARTICIPANT" | "CHAIR" | "NON-PARTICIPANT" -export type IcsPartStat = "ACCEPTED" | "DECLINED" | "TENTATIVE" | "NEEDS-ACTION" - -export interface IcsAttendee { - email: string - name?: string - role?: IcsRole - partstat?: IcsPartStat - rsvp?: boolean -} - -export interface IcsAlarm { - /** Minutes before the event start (positive). */ - triggerMinutesBefore: number - description?: string -} - -export interface IcsEvent { - /** Stable unique id — required by RFC 5545. */ - uid: string - /** Local or UTC Date. */ - start: Date - /** Local or UTC Date. */ - end: Date - summary: string - description?: string - location?: string - url?: string - status?: IcsStatus - organizer?: { email: string; name?: string } - attendees?: ReadonlyArray - alarms?: ReadonlyArray - /** 0 for new invites, incremented on updates. Default 0. */ - sequence?: number -} - -export interface IcsOptions { - method?: IcsMethod - /** PRODID identifier. Default: `-//unemail//ics//EN`. */ - prodId?: string - /** Filename for the attachment. Default: `invite.ics`. */ - filename?: string -} - -/** Build an iCalendar `VEVENT` attachment for an email. Content-Type is - * set with the canonical `method=` parameter so Outlook / Gmail render - * the invite inline. */ -export function icalEvent(event: IcsEvent, options: IcsOptions = {}): Attachment { - const method = options.method ?? "REQUEST" - const prodId = options.prodId ?? "-//unemail//ics//EN" - const filename = options.filename ?? "invite.ics" - const content = buildIcs(event, method, prodId) - return { - filename, - content, - contentType: `text/calendar; charset=UTF-8; method=${method}`, - disposition: "attachment", - } -} - -function buildIcs(event: IcsEvent, method: IcsMethod, prodId: string): string { - const lines: string[] = [ - "BEGIN:VCALENDAR", - "VERSION:2.0", - `PRODID:${prodId}`, - `METHOD:${method}`, - "CALSCALE:GREGORIAN", - "BEGIN:VEVENT", - `UID:${event.uid}`, - `DTSTAMP:${formatUtc(new Date())}`, - `DTSTART:${formatUtc(event.start)}`, - `DTEND:${formatUtc(event.end)}`, - `SUMMARY:${escapeText(event.summary)}`, - `SEQUENCE:${event.sequence ?? 0}`, - `STATUS:${event.status ?? "CONFIRMED"}`, - ] - if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`) - if (event.location) lines.push(`LOCATION:${escapeText(event.location)}`) - if (event.url) lines.push(`URL:${event.url}`) - if (event.organizer) { - const cn = event.organizer.name ? `CN=${escapeText(event.organizer.name)}:` : "" - lines.push(`ORGANIZER;${cn}mailto:${event.organizer.email}`) - } - for (const a of event.attendees ?? []) lines.push(formatAttendee(a)) - for (const alarm of event.alarms ?? []) { - lines.push( - "BEGIN:VALARM", - "ACTION:DISPLAY", - `TRIGGER:-PT${Math.round(alarm.triggerMinutesBefore)}M`, - `DESCRIPTION:${escapeText(alarm.description ?? event.summary)}`, - "END:VALARM", - ) - } - lines.push("END:VEVENT", "END:VCALENDAR") - return lines.map(foldLine).join("\r\n") + "\r\n" -} - -function formatUtc(d: Date): string { - const pad = (n: number) => String(n).padStart(2, "0") - return ( - d.getUTCFullYear().toString() + - pad(d.getUTCMonth() + 1) + - pad(d.getUTCDate()) + - "T" + - pad(d.getUTCHours()) + - pad(d.getUTCMinutes()) + - pad(d.getUTCSeconds()) + - "Z" - ) -} - -function escapeText(value: string): string { - return value - .replace(/\\/g, "\\\\") - .replace(/\n/g, "\\n") - .replace(/;/g, "\\;") - .replace(/,/g, "\\,") -} - -function formatAttendee(a: IcsAttendee): string { - const parts: string[] = [] - if (a.name) parts.push(`CN=${escapeText(a.name)}`) - if (a.role) parts.push(`ROLE=${a.role}`) - if (a.partstat) parts.push(`PARTSTAT=${a.partstat}`) - if (a.rsvp !== undefined) parts.push(`RSVP=${a.rsvp ? "TRUE" : "FALSE"}`) - const suffix = parts.length ? `;${parts.join(";")}` : "" - return `ATTENDEE${suffix}:mailto:${a.email}` -} - -/** RFC 5545 requires lines ≤ 75 octets, continuation lines start with a - * single space. We approximate by chars since all our content is ASCII - * after escaping — if you need UTF-8 display names this still works - * because the folded continuation is decoded identically. */ -function foldLine(line: string): string { - if (line.length <= 75) return line - const parts: string[] = [] - let start = 0 - while (start < line.length) { - const chunk = line.slice(start, start + 75) - parts.push(start === 0 ? chunk : ` ${chunk}`) - start += 75 - } - return parts.join("\r\n") -} diff --git a/src/inbound/cloudflare.ts b/src/inbound/cloudflare.ts deleted file mode 100644 index 8e748c8..0000000 --- a/src/inbound/cloudflare.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { InboundAdapter } from "./index.ts" -import { parseEmail } from "../parse/index.ts" - -/** Cloudflare Email Workers inbound adapter. - * - * The CF Email Worker handler gets a \`message\` object; the route adapter - * here also accepts a plain \`Request\` whose \`x-cf-email-raw\` header - * signals that the body is the raw MIME. Use it when proxying CF Email - * Workers through a normal \`fetch\` handler — otherwise call - * \`parseEmail(await message.raw())\` directly in your Worker. */ -export interface CloudflareInboundOptions { - /** Header name that carries a pre-agreed shared secret. Default: none — - * verification is disabled unless set. */ - secretHeader?: string - secret?: string -} - -export default function cloudflareInbound(options: CloudflareInboundOptions = {}): InboundAdapter { - return { - name: "cloudflare", - accepts(request) { - return request.headers.get("x-cf-email-raw") != null - }, - verify(request) { - if (!options.secretHeader || !options.secret) return true - return request.headers.get(options.secretHeader) === options.secret - }, - async parse(request) { - const buffer = await request.arrayBuffer() - return parseEmail(new Uint8Array(buffer)) - }, - } -} diff --git a/src/inbound/index.ts b/src/inbound/index.ts deleted file mode 100644 index a60123b..0000000 --- a/src/inbound/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ParsedEmail } from "../parse/index.ts" - -/** Contract every inbound adapter implements. Each provider knows how to: - * - tell whether a request belongs to it (\`accepts\`), - * - optionally verify its signature (\`verify\`), - * - turn the request body into a \`ParsedEmail\` (\`parse\`). */ -export interface InboundAdapter { - readonly name: string - accepts: (request: Request) => boolean - verify?: (request: Request) => Promise | boolean - parse: (request: Request) => Promise -} - -/** Route handler returned by \`defineInboundHandler\`. Drop it into a Nitro - * route, a Cloudflare Worker, a Hono app, or raw \`fetch\` handler. */ -export type InboundHandler = (request: Request) => Promise - -export interface DefineInboundHandlerOptions { - providers: ReadonlyArray - onEmail: (mail: ParsedEmail, context: InboundContext) => void | Promise - onUnknown?: (request: Request) => Promise | Response - onVerificationFailure?: (request: Request, provider: string) => Promise | Response -} - -export interface InboundContext { - provider: string - request: Request -} - -/** Builds a fetch-style handler that accepts inbound webhooks from any - * registered provider and yields a unified \`ParsedEmail\` via \`onEmail\`. - * - * ```ts - * import { defineInboundHandler } from "unemail/inbound" - * import sesInbound from "unemail/inbound/ses" - * import cfInbound from "unemail/inbound/cloudflare" - * - * export default defineInboundHandler({ - * providers: [sesInbound(), cfInbound()], - * onEmail(mail) { console.log(mail.subject) } - * }) - * ``` - */ -export function defineInboundHandler(options: DefineInboundHandlerOptions): InboundHandler { - return async (request: Request) => { - for (const provider of options.providers) { - if (!provider.accepts(request.clone())) continue - if (provider.verify && !(await provider.verify(request.clone()))) { - return options.onVerificationFailure - ? options.onVerificationFailure(request, provider.name) - : new Response("invalid signature", { status: 401 }) - } - const mail = await provider.parse(request.clone()) - await options.onEmail(mail, { provider: provider.name, request }) - return new Response("ok", { status: 200 }) - } - return options.onUnknown - ? options.onUnknown(request) - : new Response("no matching inbound provider", { status: 404 }) - } -} diff --git a/src/inbound/mailgun.ts b/src/inbound/mailgun.ts deleted file mode 100644 index 0654caf..0000000 --- a/src/inbound/mailgun.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { InboundAdapter } from "./index.ts" -import type { ParsedEmail } from "../parse/index.ts" -import { parseEmail } from "../parse/index.ts" -import { webCryptoHmacHex } from "../webhook/_crypto.ts" - -/** Mailgun inbound-route adapter. Mailgun sends \`multipart/form-data\` - * with \`body-mime\` carrying the raw message (store mode). \`token + - * timestamp + signature\` fields authenticate the request. */ -export interface MailgunInboundOptions { - /** Mailgun API signing key — used to verify the HMAC. */ - signingKey?: string -} - -export default function mailgunInbound(options: MailgunInboundOptions = {}): InboundAdapter { - return { - name: "mailgun-inbound", - accepts(request) { - const ct = request.headers.get("content-type") ?? "" - return request.method === "POST" && ct.startsWith("multipart/form-data") - }, - async verify(request) { - if (!options.signingKey) return true - const form = await request.formData() - const timestamp = form.get("timestamp") - const token = form.get("token") - const signature = form.get("signature") - if ( - typeof timestamp !== "string" || - typeof token !== "string" || - typeof signature !== "string" - ) - return false - const expected = await webCryptoHmacHex("SHA-256", options.signingKey, `${timestamp}${token}`) - return timingSafeEquals(expected, signature) - }, - async parse(request): Promise { - const form = await request.formData() - const raw = form.get("body-mime") - if (typeof raw !== "string") - throw new Error( - "[unemail/inbound/mailgun] no `body-mime` field — did you enable store action on the route?", - ) - return parseEmail(raw) - }, - } -} - -function timingSafeEquals(a: string, b: string): boolean { - if (a.length !== b.length) return false - let mismatch = 0 - for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i) - return mismatch === 0 -} diff --git a/src/inbound/postmark.ts b/src/inbound/postmark.ts deleted file mode 100644 index 52a5bc1..0000000 --- a/src/inbound/postmark.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { ParsedEmail } from "../parse/index.ts" -import type { InboundAdapter } from "./index.ts" -import type { EmailAddress } from "../types.ts" - -/** Postmark inbound-webhook adapter. Postmark delivers JSON, not raw MIME, - * so we translate its schema straight to \`ParsedEmail\` without touching - * postal-mime. */ -export interface PostmarkInboundOptions { - /** Postmark's inbound URL can be protected by HTTP Basic auth — pass - * the expected \`"user:pass"\` here to enable verification. */ - basicAuth?: string -} - -interface PostmarkInboundPayload { - MessageID?: string - Date?: string - Subject?: string - From?: string - FromFull?: { Email?: string; Name?: string } - To?: string - ToFull?: Array<{ Email?: string; Name?: string }> - Cc?: string - CcFull?: Array<{ Email?: string; Name?: string }> - Bcc?: string - BccFull?: Array<{ Email?: string; Name?: string }> - ReplyTo?: string - TextBody?: string - HtmlBody?: string - Headers?: Array<{ Name?: string; Value?: string }> - Attachments?: Array<{ - Name?: string - Content?: string - ContentType?: string - ContentID?: string - }> -} - -export default function postmarkInbound(options: PostmarkInboundOptions = {}): InboundAdapter { - return { - name: "postmark-inbound", - accepts(request) { - if (request.method !== "POST") return false - return (request.headers.get("user-agent") ?? "").toLowerCase().includes("postmark") - }, - verify(request) { - if (!options.basicAuth) return true - const auth = request.headers.get("authorization") ?? "" - if (!auth.startsWith("Basic ")) return false - const decoded = atobSafe(auth.slice(6)) - return decoded === options.basicAuth - }, - async parse(request) { - const body = (await request.json()) as PostmarkInboundPayload - return mapPayload(body) - }, - } -} - -function mapPayload(body: PostmarkInboundPayload): ParsedEmail { - const from = body.FromFull - ? toAddress(body.FromFull) - : body.From - ? parseSimple(body.From) - : undefined - return { - messageId: body.MessageID, - date: body.Date ? new Date(body.Date) : undefined, - subject: body.Subject, - from, - to: (body.ToFull ?? []) - .map(toAddress) - .concat(body.To && !body.ToFull ? [parseSimple(body.To)] : []), - cc: (body.CcFull ?? []) - .map(toAddress) - .concat(body.Cc && !body.CcFull ? [parseSimple(body.Cc)] : []), - bcc: (body.BccFull ?? []) - .map(toAddress) - .concat(body.Bcc && !body.BccFull ? [parseSimple(body.Bcc)] : []), - replyTo: body.ReplyTo ? parseSimple(body.ReplyTo) : undefined, - references: [], - text: body.TextBody, - html: body.HtmlBody, - headers: Object.fromEntries( - (body.Headers ?? []) - .filter((h): h is { Name: string; Value: string } => Boolean(h.Name && h.Value)) - .map((h) => [h.Name.toLowerCase(), h.Value]), - ), - attachments: (body.Attachments ?? []).map((a) => ({ - filename: a.Name ?? "attachment", - contentType: a.ContentType, - content: b64ToBytes(a.Content ?? ""), - cid: a.ContentID?.replace(/[<>]/g, ""), - disposition: "attachment" as const, - })), - } -} - -function toAddress(a: { Email?: string; Name?: string }): EmailAddress { - return { email: a.Email ?? "", name: a.Name || undefined } -} - -function parseSimple(value: string): EmailAddress { - const match = /^\s*(.*?)\s*<([^>]+)>\s*$/.exec(value) - if (match) return { email: match[2]!.trim(), name: match[1]?.trim() || undefined } - return { email: value.trim() } -} - -function b64ToBytes(value: string): Uint8Array { - const g = globalThis as { - Buffer?: { from: (v: string, enc: string) => Uint8Array } - } - if (g.Buffer) return g.Buffer.from(value, "base64") - const binary = atob(value) - const out = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) - return out -} - -function atobSafe(value: string): string { - try { - return atob(value) - } catch { - return "" - } -} diff --git a/src/inbound/reply.ts b/src/inbound/reply.ts deleted file mode 100644 index 94eaed1..0000000 --- a/src/inbound/reply.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Extract the "new content" from a reply email — strip quoted previous - * messages and trailing signatures. - * - * The world moved on from the Ruby `email_reply_parser` heuristics - * around 2018 so we ship the same approach: look for canonical - * header-bodied replies ("On ... wrote:") and signature dashes. - * Covers English, Turkish, German, French, Spanish. - * - * @module - */ - -export interface ReplyParseResult { - /** Just the new content the author wrote. */ - text: string - /** Everything after the new content (quoted previous message + sig). */ - quoted: string -} - -const HEADER_PATTERNS: ReadonlyArray = [ - // English: "On Mon, Jan 1, 2026 at 3:00 PM Name wrote:" - /^[ \t]*On\b.*\bwrote:\s*$/im, - // Turkish: "1 Ocak 2026 Pazartesi tarihinde Name şunları yazdı:" - /^[ \t]*.*tarihinde\b.*(yazd\u0131|\u015funlar\u0131 yazd\u0131):\s*$/im, - // German: "Am 1. Januar 2026 um 15:00 schrieb Name :" - /^[ \t]*Am\b.*\bschrieb\b.*:\s*$/im, - // French: "Le 1 janvier 2026 à 15:00, Name a écrit :" - /^[ \t]*Le\b.*\ba [eé]crit\b.*:\s*$/im, - // Spanish: "El 1 de enero de 2026, Name escribió:" - /^[ \t]*El\b.*\bescribi[oó]\b.*:\s*$/im, - // Outlook-style forwarded header block - /^[ \t]*-----\s*Original Message\s*-----\s*$/im, - /^[ \t]*From:\s/m, -] - -/** Strip quoted history and signature; keep just the new content. */ -export function stripReply(rawText: string): ReplyParseResult { - const normalized = rawText.replace(/\r\n/g, "\n") - - // 1. Cut at the earliest header-bodied quote marker. - let cutIndex = normalized.length - for (const pattern of HEADER_PATTERNS) { - const match = pattern.exec(normalized) - if (match && match.index < cutIndex) cutIndex = match.index - } - - // 2. Cut at the first line starting with one or more ">" chars after - // content, preceded by a blank line. - const quoteMatch = /\n\s*\n(?:>[^\n]*\n?)+/g.exec(normalized) - if (quoteMatch && quoteMatch.index < cutIndex) cutIndex = quoteMatch.index - - let text = normalized.slice(0, cutIndex).replace(/[\s\n]+$/g, "") - const quoted = normalized.slice(cutIndex).replace(/^[\s\n]+/, "") - - // 3. Strip trailing signature block introduced by "-- \n" or common - // sign-offs on their own line. - text = stripSignature(text) - - return { text, quoted } -} - -const SIGN_OFF_PATTERNS: ReadonlyArray = [ - /\n--\s*\n[\s\S]*$/, // canonical RFC 3676 - /\n[ \t]*(Thanks|Regards|Cheers|Best|Sincerely|Yours|Sent from my [A-Za-z]+)[^\n]*\n[\s\S]*$/i, - /\n[ \t]*(Te\u015fekk\u00fcrler|Sayg\u0131lar\u0131mla|Selamlar)[^\n]*\n[\s\S]*$/i, -] - -function stripSignature(text: string): string { - for (const pattern of SIGN_OFF_PATTERNS) { - const match = pattern.exec(text) - if (match) return text.slice(0, match.index).replace(/[\s\n]+$/g, "") - } - return text -} diff --git a/src/inbound/sendgrid.ts b/src/inbound/sendgrid.ts deleted file mode 100644 index 3ba55d7..0000000 --- a/src/inbound/sendgrid.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { InboundAdapter } from "./index.ts" -import { parseEmail } from "../parse/index.ts" - -/** SendGrid Inbound Parse adapter. SG posts \`multipart/form-data\`; the - * \`email\` field is the raw MIME message. */ -export interface SendGridInboundOptions { - /** Optional shared secret verified via a custom header. */ - secret?: string - secretHeader?: string -} - -export default function sendgridInbound(options: SendGridInboundOptions = {}): InboundAdapter { - return { - name: "sendgrid-inbound", - accepts(request) { - const ct = request.headers.get("content-type") ?? "" - return request.method === "POST" && ct.startsWith("multipart/form-data") - }, - verify(request) { - if (!options.secret || !options.secretHeader) return true - return request.headers.get(options.secretHeader) === options.secret - }, - async parse(request) { - const form = await request.formData() - const raw = form.get("email") - if (typeof raw !== "string") - throw new Error("[unemail/inbound/sendgrid] no `email` field in multipart body") - return parseEmail(raw) - }, - } -} diff --git a/src/inbound/ses.ts b/src/inbound/ses.ts deleted file mode 100644 index 270e8a4..0000000 --- a/src/inbound/ses.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * SES → SNS → webhook inbound adapter. Verifies the SNS signature, - * handles `SubscriptionConfirmation` auto-confirm, and normalizes SES - * Bounce/Complaint/Delivery/Received notifications. - * - * @module - */ - -import type { ParsedEmail } from "../parse/index.ts" -import { parseEmail } from "../parse/index.ts" - -export type SesInboundEvent = - | { type: "subscription-confirm"; subscribeUrl: string } - | { type: "bounce"; bounce: Record; raw: Record } - | { type: "complaint"; complaint: Record; raw: Record } - | { type: "delivery"; delivery: Record; raw: Record } - | { type: "received"; email: ParsedEmail; raw: Record } - | { type: "unknown"; raw: Record } - -/** Parse a raw SNS envelope body (the bytes POST'd to your webhook). - * The SNS signature is NOT verified here — combine with - * `unemail/webhook/ses` if you want verification. */ -export async function defineSesInboundHandler(opts?: { - autoConfirm?: (url: string) => void | Promise -}): Promise<(body: string) => Promise> { - return async (body: string) => { - let outer: Record - try { - outer = JSON.parse(body) as Record - } catch { - return { type: "unknown", raw: { error: "invalid JSON" } } - } - if (outer.Type === "SubscriptionConfirmation") { - const url = outer.SubscribeURL as string - if (opts?.autoConfirm) await opts.autoConfirm(url) - return { type: "subscription-confirm", subscribeUrl: url } - } - const messageStr = outer.Message as string | undefined - if (!messageStr) return { type: "unknown", raw: outer } - let message: Record - try { - message = JSON.parse(messageStr) as Record - } catch { - return { type: "unknown", raw: outer } - } - const notificationType = message.notificationType ?? message.eventType - if (notificationType === "Bounce") - return { type: "bounce", bounce: message.bounce as Record, raw: message } - if (notificationType === "Complaint") - return { - type: "complaint", - complaint: message.complaint as Record, - raw: message, - } - if (notificationType === "Delivery") - return { - type: "delivery", - delivery: message.delivery as Record, - raw: message, - } - if (notificationType === "Received" || message.content) { - const raw = message.content as string | undefined - if (raw) { - const email = await parseEmail(raw) - return { type: "received", email, raw: message } - } - } - return { type: "unknown", raw: message } - } -} diff --git a/src/inbound/thread.ts b/src/inbound/thread.ts deleted file mode 100644 index 28d0567..0000000 --- a/src/inbound/thread.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Thread-key derivation from RFC 5322 `Message-ID`, `In-Reply-To`, - * and `References` headers. Given a parsed email we return a stable - * identifier that groups messages in the same conversation. - * - * @module - */ - -import type { ParsedEmail } from "../parse/index.ts" - -export interface ThreadKeyInput { - messageId?: string - inReplyTo?: string - references?: ReadonlyArray -} - -/** Pick the canonical root Message-ID for a parsed email. */ -export function threadKey(input: ThreadKeyInput | ParsedEmail): string { - const msg = input as ThreadKeyInput - const refs = msg.references ?? [] - const candidates: string[] = [] - if (refs[0]) candidates.push(refs[0]) - if (msg.inReplyTo) candidates.push(msg.inReplyTo) - if (msg.messageId) candidates.push(msg.messageId) - const first = candidates.find(Boolean) - if (!first) return "__no_thread__" - return normalizeMessageId(first) -} - -/** Build a deterministic adjacency list `{ root -> [member-ids] }` from - * a batch of parsed messages. Useful for UI grouping. */ -export function buildThreads(messages: ReadonlyArray): Map { - const out = new Map() - for (const m of messages) { - const key = threadKey(m) - const id = m.messageId ? normalizeMessageId(m.messageId) : key - const list = out.get(key) ?? [] - list.push(id) - out.set(key, list) - } - return out -} - -function normalizeMessageId(id: string): string { - return id.trim().replace(/^<|>$/g, "") -} diff --git a/src/index.ts b/src/index.ts index 47c9beb..2ae5f01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,59 +1,104 @@ /** - * Public entry point for `unemail` — a driver-based, cross-runtime - * TypeScript email library inspired by `unjs/unstorage`. + * `unemail` — a driver-based email library for TypeScript. * - * Transports (SMTP, Resend, SES, Postmark, …) live under - * `unemail/driver/`. Rendering and inbound adapters live under - * their own sub-paths (shipped incrementally). + * The core is this module: a message normalizer, a middleware pipeline, and + * a driver contract. Transports live under `unemail/drivers/`, + * middleware under `unemail/middleware`, rendering under `unemail/render`. + * Nothing here imports a Node built-in, so the core runs unchanged on Node, + * Bun, Deno, Cloudflare Workers, and in a browser. + * + * ```ts + * import { createEmail } from "unemail" + * import resend from "unemail/drivers/resend" + * import { withRetry } from "unemail/middleware" + * + * const email = createEmail({ + * driver: resend({ apiKey: process.env.RESEND_API_KEY! }), + * defaults: { from: "Acme " }, + * use: [withRetry()], + * }) + * + * const { data, error } = await email.send({ + * to: "ada@example.com", + * subject: "Welcome", + * html: "

Glad you are here.

", + * }) + * ``` * * @module */ -export { createEmail, type CreateEmailOptions, type Email } from "./email.ts" -export { defineDriver } from "./_define.ts" -export { memoryIdempotencyStore } from "./_idempotency.ts" -export { formatAddress, isValidEmail, normalizeAddresses, parseAddress } from "./_normalize.ts" -export { createError, createRequiredError, EmailError, toEmailError } from "./errors.ts" + export { - type CircuitBreakerOptions, - type CircuitState, - type LogEntry, - type LoggerOptions, - type OtelSpan, - type OtelTracer, - type RateLimitOptions, - type RetryOptions, - type TelemetryOptions, - withCircuitBreaker, - withLogger, - withRateLimit, - withRetry, - withTelemetry, -} from "./middleware/index.ts" + createEmail, + type CreateEmailOptions, + type Email, + type SendStreamOptions, +} from "./core/email.ts" + +export { + compose, + defineDriver, + defineMiddleware, + driverHandler, + perMessage, + wrap, +} from "./core/define.ts" + export { - defineTemplate, - htmlToText, - type Renderer, - type TemplateFn, - withRender, - type WithRenderOptions, -} from "./render/index.ts" + createError, + createRequiredError, + createUnsupportedError, + EmailError, + toEmailError, +} from "./core/error.ts" + +export { err, isOk, ok, toBatchResult, unwrap } from "./core/result.ts" + +export { + dedupeAddresses, + formatAddress, + formatAddressList, + isValidEmail, + parseAddress, + toAddressList, +} from "./core/address.ts" + +export { + getHeader, + hasHeader, + type MessageDefaults, + normalizeMessage, + patchMessage, +} from "./core/message.ts" + export type { + AddressInput, Attachment, + BatchResult, DriverFactory, - DriverFlags, + DriverFeatures, + DriverOf, + DriverWithInstance, EmailAddress, - EmailAddressInput, EmailDriver, EmailErrorCode, EmailMessage, EmailResult, EmailTag, - IdempotencyStore, MaybePromise, + MessageContent, Middleware, + NormalizedMessage, Result, SendContext, -} from "./types.ts" + SendHandler, + SendState, + SendStatus, + TemplateOptions, + TrackingOptions, + UnsubscribeOptions, +} from "./core/types.ts" -/** Library version string — bumped automatically on release. */ -export const version = "1.0.0-alpha.0" +/** The package version. Checked against `package.json` and `jsr.json` by + * `scripts/check-version.mjs`, which CI runs — the three cannot drift. */ +export const version = "1.0.0" diff --git a/src/middleware/circuit-breaker.ts b/src/middleware/circuit-breaker.ts index 8ef41ca..d354d39 100644 --- a/src/middleware/circuit-breaker.ts +++ b/src/middleware/circuit-breaker.ts @@ -1,71 +1,84 @@ -import type { EmailDriver } from "../types.ts" -import { createError } from "../errors.ts" +import type { EmailError } from "../core/error.ts" +import type { Middleware } from "../core/types.ts" +import { defineMiddleware } from "../core/define.ts" +import { createError } from "../core/error.ts" +import { err } from "../core/result.ts" -/** Circuit-breaker states: - * - `closed` — requests pass through - * - `open` — requests short-circuit with a CANCELLED error - * - `half-open` — a probe request is allowed; success closes, failure re-opens */ +/** `closed` passes traffic, `open` rejects it, `half-open` lets a single + * probe through to see whether the provider recovered. */ export type CircuitState = "closed" | "open" | "half-open" export interface CircuitBreakerOptions { - /** Consecutive failures that trip the breaker. Default: 5. */ + /** Consecutive failures that trip the circuit. Default: 5. */ threshold?: number - /** How long to stay `open` before transitioning to `half-open`. Default: 30s. */ - cooldownMs?: number - /** Called on state transitions — useful for telemetry. */ - onStateChange?: (state: CircuitState) => void - /** Injected for tests. */ + /** How long to stay open before probing, in milliseconds. Default: 30_000. */ + resetTimeoutMs?: number + /** Which failures count. Default: everything except `INVALID_OPTIONS` + * and `UNSUPPORTED` — a malformed message says nothing about the + * provider's health. */ + isFailure?: (error: EmailError) => boolean + /** Called on every state change. */ + onStateChange?: (state: CircuitState, driver: string) => void + /** Injected for deterministic tests. */ now?: () => number } -/** Wrap a driver in a circuit breaker. Prevents cascading failures when a - * provider is down by short-circuiting after `threshold` consecutive - * errors. */ -export function withCircuitBreaker( - driver: EmailDriver, - options: CircuitBreakerOptions = {}, -): EmailDriver { +/** + * Stop hammering a provider that is down. + * + * ```ts + * email.use(withCircuitBreaker({ threshold: 5 })) + * ``` + */ +export function withCircuitBreaker(options: CircuitBreakerOptions = {}): Middleware { const threshold = options.threshold ?? 5 - const cooldownMs = options.cooldownMs ?? 30_000 + const resetTimeoutMs = options.resetTimeoutMs ?? 30_000 + const isFailure = options.isFailure ?? defaultIsFailure const now = options.now ?? Date.now let state: CircuitState = "closed" let failures = 0 let openedAt = 0 - const transition = (next: CircuitState) => { - if (state === next) return - state = next - options.onStateChange?.(next) + function transition(to: CircuitState, driver: string) { + if (state === to) return + state = to + options.onStateChange?.(to, driver) } - return { - ...driver, - async send(msg, ctx) { - if (state === "open") { - if (now() - openedAt >= cooldownMs) transition("half-open") - else { - return { - data: null, - error: createError(driver.name, "CANCELLED", "circuit breaker open", { - retryable: false, - }), - } - } + return defineMiddleware("circuit-breaker", (next) => async (msgs, ctx) => { + if (state === "open") { + if (now() - openedAt < resetTimeoutMs) { + const failure = err( + createError(ctx.driver, "NETWORK", "circuit is open — provider is failing", { + retryable: true, + }), + ) + return msgs.map(() => failure) } + transition("half-open", ctx.driver) + } - const result = await driver.send(msg, ctx) - if (result.error) { - failures++ - if (state === "half-open" || failures >= threshold) { - openedAt = now() - transition("open") - } - } else { - failures = 0 - transition("closed") - } - return result - }, - } + const results = await next(msgs, ctx) + const counted = results.filter((result) => result.error && isFailure(result.error)) + + if (counted.length === 0) { + failures = 0 + transition("closed", ctx.driver) + return results + } + + // A half-open probe that fails at all goes straight back to open; + // waiting for the full threshold again would replay the outage. + failures = state === "half-open" ? threshold : failures + counted.length + if (failures >= threshold) { + openedAt = now() + transition("open", ctx.driver) + } + return results + }) +} + +function defaultIsFailure(error: EmailError): boolean { + return error.code !== "INVALID_OPTIONS" && error.code !== "UNSUPPORTED" } diff --git a/src/middleware/dedupe.ts b/src/middleware/dedupe.ts deleted file mode 100644 index dbd2a1c..0000000 --- a/src/middleware/dedupe.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { EmailDriver, EmailMessage, EmailResult, IdempotencyStore } from "../types.ts" -import { memoryIdempotencyStore } from "../_idempotency.ts" -import { normalizeAddresses } from "../_normalize.ts" - -/** Strategy for computing the dedupe key. - * - `"idempotencyKey"` — use `msg.idempotencyKey` only. - * - `"contentHash"` — hash subject + body + recipient list. - * - `"recipient+subject"` — lightweight: hash recipient + subject - * (good enough for "don't double-send this notification"). */ -export type DedupeStrategy = "idempotencyKey" | "contentHash" | "recipient+subject" - -export interface DedupeOptions { - store?: IdempotencyStore - strategy?: DedupeStrategy - ttlSeconds?: number - /** Custom key resolver. Overrides `strategy`. */ - keyFn?: (msg: EmailMessage) => string | null -} - -/** Wrap a driver so repeated sends within `ttlSeconds` return the - * cached success instead of hitting the provider again. */ -export function withDedupe(driver: EmailDriver, options: DedupeOptions = {}): EmailDriver { - const store = options.store ?? memoryIdempotencyStore() - const strategy = options.strategy ?? "idempotencyKey" - const ttl = options.ttlSeconds ?? 300 - const keyFn = options.keyFn ?? defaultKeyFn(strategy) - return { - ...driver, - async send(msg, ctx) { - const key = keyFn(msg) - if (!key) return driver.send(msg, ctx) - const cached = await store.get(key) - if (cached) return { data: cached, error: null } - const result = await driver.send(msg, ctx) - if (result.data) await store.set(key, result.data, ttl) - return result - }, - } -} - -function defaultKeyFn(strategy: DedupeStrategy): (msg: EmailMessage) => string | null { - switch (strategy) { - case "idempotencyKey": - return (m) => m.idempotencyKey ?? null - case "recipient+subject": - return (m) => { - const rcpts = normalizeAddresses(m.to) - .map((a) => a.email.toLowerCase()) - .sort() - .join(",") - return `rcpt:${rcpts}|subj:${m.subject}` - } - case "contentHash": - return (m) => { - const rcpts = normalizeAddresses(m.to) - .map((a) => a.email.toLowerCase()) - .sort() - .join(",") - return `rcpt:${rcpts}|subj:${m.subject}|body:${hash(m.text ?? "") ^ hash(m.html ?? "")}` - } - } -} - -function hash(s: string): number { - let h = 0 - for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0 - return h -} - -// Re-export so consumers get a single place to find it. -export type { EmailDriver, EmailResult } diff --git a/src/middleware/idempotency.ts b/src/middleware/idempotency.ts new file mode 100644 index 0000000..9ccb910 --- /dev/null +++ b/src/middleware/idempotency.ts @@ -0,0 +1,106 @@ +import type { EmailResult, MaybePromise, Middleware, Result, SendContext } from "../core/types.ts" +import { defineMiddleware } from "../core/define.ts" +import { createError } from "../core/error.ts" +import { err, ok } from "../core/result.ts" + +/** Minimal KV contract — small enough that an `unstorage` driver, a Redis + * client, or a Workers KV namespace all fit in a few lines. */ +export interface IdempotencyStore { + get: (key: string) => MaybePromise + set: (key: string, value: EmailResult, ttlSeconds?: number) => MaybePromise +} + +export interface IdempotencyOptions { + /** Where results are remembered. Default: an in-process TTL map, which + * only deduplicates within one process — pass a shared store if you + * run more than one instance. */ + store?: IdempotencyStore + /** How long a result is remembered. Default: 86_400 (24h). */ + ttlSeconds?: number +} + +/** + * Return the previous result instead of sending again when a message + * carries an `idempotencyKey` that has already succeeded. + * + * Only successes are remembered: a failed send is not a decision, and + * retrying it is the whole point of the key. + * + * ```ts + * email.use(withIdempotency({ store: redisStore })) + * await email.send({ ...msg, idempotencyKey: `welcome:${userId}` }) + * ``` + */ +export function withIdempotency(options: IdempotencyOptions = {}): Middleware { + const store = options.store ?? memoryIdempotencyStore() + const ttlSeconds = options.ttlSeconds ?? 86_400 + + return defineMiddleware("idempotency", (next) => async (msgs, ctx) => { + const results: (Result | undefined)[] = Array.from(msgs, () => undefined) + const pending: number[] = [] + + await Promise.all( + msgs.map(async (msg, index) => { + if (!msg.idempotencyKey) { + pending.push(index) + return + } + const cached = await store.get(cacheKey(ctx, msg.idempotencyKey)) + if (cached) results[index] = ok(cached) + else pending.push(index) + }), + ) + + if (pending.length > 0) { + pending.sort((a, b) => a - b) + const produced = await next( + pending.map((index) => msgs[index]!), + ctx, + ) + await Promise.all( + pending.map(async (index, slot) => { + const result = produced[slot] + if (!result) return + results[index] = result + const key = msgs[index]!.idempotencyKey + if (key && result.data) { + await store.set(cacheKey(ctx, key), result.data, ttlSeconds) + } + }), + ) + } + + return results.map( + (result) => + result ?? err(createError(ctx.driver, "PROVIDER", "no result for message")), + ) + }) +} + +/** In-process TTL map. Fine for a single instance; not shared across + * processes, so it does not survive a restart or reach a sibling pod. */ +export function memoryIdempotencyStore(): IdempotencyStore { + const entries = new Map() + return { + get(key) { + const entry = entries.get(key) + if (!entry) return null + if (entry.expiresAt <= Date.now()) { + entries.delete(key) + return null + } + return entry.value + }, + set(key, value, ttlSeconds = 86_400) { + entries.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 }) + }, + } +} + +// Keyed by destination, not by key alone: the same logical key sent to two +// providers — or to two streams of one provider — is two different +// deliveries, and returning one's id for the other would make `retrieve()` +// lie about which message it is reporting on. +function cacheKey(ctx: SendContext, key: string): string { + return `${ctx.driver}:${ctx.stream ?? ""}:${key}` +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 76d3b90..a0ee68f 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,32 +1,32 @@ +/** + * Middleware shipped with `unemail`. Every one is built with + * `defineMiddleware` — there is nothing privileged about them, and yours + * composes exactly the same way. + * + * Order matters: the first registered is the outermost. + * + * ```ts + * email + * .use(withLogger()) // measures everything below, retries included + * .use(withCircuitBreaker()) // stops calling a provider that is down + * .use(withRetry()) // re-sends only the failures + * .use(withRateLimit(rateLimitPresets.resend)) + * ``` + * + * @module + */ + export { - withCircuitBreaker, type CircuitBreakerOptions, type CircuitState, + withCircuitBreaker, } from "./circuit-breaker.ts" -export { type LogEntry, type LoggerOptions, withLogger } from "./logger.ts" -export { type DedupeOptions, type DedupeStrategy, withDedupe } from "./dedupe.ts" export { - oauth2Gmail, - oauth2Microsoft, - type OAuth2Options, - type OAuth2TokenCache, - type OAuth2TokenResponse, - withOAuth2, -} from "./oauth2.ts" -export { - createMetricsRegistry, - type MetricsMiddlewareOptions, - type MetricsRegistry, - withMetrics, -} from "./metrics.ts" -export { type PiiScrubberOptions, type ScrubStrategy, scrubPii, withPiiLogging } from "./pii.ts" -export { type PreferencesMiddlewareOptions, withPreferences } from "./preferences.ts" -export { rateLimitPresets, withRateLimit, type RateLimitOptions } from "./rate-limit.ts" -export { withRetry, type RetryOptions } from "./retry.ts" -export { type SuppressionOptions, type SuppressionPolicy, withSuppression } from "./suppression.ts" -export { - type OtelSpan, - type OtelTracer, - type TelemetryOptions, - withTelemetry, -} from "./telemetry.ts" + type IdempotencyOptions, + type IdempotencyStore, + memoryIdempotencyStore, + withIdempotency, +} from "./idempotency.ts" +export { type LogEntry, type LoggerOptions, withLogger } from "./logger.ts" +export { rateLimitPresets, type RateLimitOptions, withRateLimit } from "./rate-limit.ts" +export { type RetryBackoff, type RetryOptions, withRetry } from "./retry.ts" diff --git a/src/middleware/logger.ts b/src/middleware/logger.ts index 1ab3f9c..81c0cba 100644 --- a/src/middleware/logger.ts +++ b/src/middleware/logger.ts @@ -1,146 +1,72 @@ -import type { EmailMessage, Middleware, Result } from "../types.ts" -import type { EmailError } from "../errors.ts" -import type { EmailResult, SendContext } from "../types.ts" -import { normalizeAddresses } from "../_normalize.ts" +import type { Middleware, NormalizedMessage } from "../core/types.ts" +import { defineMiddleware } from "../core/define.ts" -/** Structured log entry emitted by `withLogger`. Consumers can pipe this - * into Pino, Winston, Logflare, Axiom, or \`console\`. */ +/** One structured record per pipeline trip. */ export interface LogEntry { - event: "send.start" | "send.success" | "send.error" - at: string - driver: string - stream?: string - attempt: number - messageId?: string - durationMs?: number - recipient?: string - subject?: string - error?: { - code: string - message: string - status?: number - retryable: boolean - } - /** User-extensible metadata forwarded from \`ctx.meta\`. */ - meta?: Record + readonly level: "info" | "error" + readonly driver: string + readonly stream?: string + readonly attempt: number + readonly count: number + readonly sent: number + readonly failed: number + readonly durationMs: number + /** Present when at least one message failed. */ + readonly errors?: readonly { readonly code: string; readonly message: string }[] + /** Omitted unless `redact` is set to `"none"`. */ + readonly messages?: readonly { readonly to: string; readonly subject: string }[] } export interface LoggerOptions { - /** Sink for log entries. Default: \`console.info\` for start/success, - * \`console.error\` for errors. */ - sink?: (entry: LogEntry) => void - /** Include the first 120 chars of the subject in logs. Default: true. */ - includeSubject?: boolean - /** Include the first recipient's email address. Default: true. */ - includeRecipient?: boolean - /** Redact the local-part of emails (ada@acme.com → a***@acme.com). - * Default: false. */ - redactLocalPart?: boolean + /** Where entries go. Default: `console.log` / `console.error`. */ + log?: (entry: LogEntry) => void + /** `addresses` (default) logs counts only. `none` includes recipients + * and subjects — do not enable it where logs leave your control. */ + redact?: "addresses" | "none" } -/** Middleware that emits structured log entries around every send. Zero - * runtime dependencies — use \`sink\` to plug in any logger. */ +/** + * Structured logging around the whole pipeline. + * + * Register it first so it measures everything inside it, retries included: + * + * ```ts + * email.use(withLogger()).use(withRetry()) + * ``` + */ export function withLogger(options: LoggerOptions = {}): Middleware { - const sink = options.sink ?? defaultSink - const includeSubject = options.includeSubject ?? true - const includeRecipient = options.includeRecipient ?? true - const redact = options.redactLocalPart ?? false + const redact = options.redact ?? "addresses" + const log = options.log ?? defaultLog - return { - name: "logger", - beforeSend(msg, ctx) { - ctx.meta.__loggerStart = Date.now() - sink(baseEntry("send.start", msg, ctx, { includeSubject, includeRecipient, redact })) - }, - afterSend(msg, ctx, result) { - const entry = baseEntry("send.success", msg, ctx, { - includeSubject, - includeRecipient, - redact, - }) - const start = ctx.meta.__loggerStart - if (typeof start === "number") entry.durationMs = Date.now() - start - attachResult(entry, result) - sink(entry) - }, - onError(msg, ctx, error) { - const entry = baseEntry("send.error", msg, ctx, { includeSubject, includeRecipient, redact }) - const start = ctx.meta.__loggerStart - if (typeof start === "number") entry.durationMs = Date.now() - start - entry.error = serializeError(error) - sink(entry) - }, - } -} - -interface LogFieldOptions { - includeSubject: boolean - includeRecipient: boolean - redact: boolean -} - -function baseEntry( - event: LogEntry["event"], - msg: EmailMessage, - ctx: SendContext, - fields: LogFieldOptions, -): LogEntry { - const entry: LogEntry = { - event, - at: new Date().toISOString(), - driver: ctx.driver, - attempt: ctx.attempt, - } - if (ctx.stream) entry.stream = ctx.stream - if (fields.includeSubject && msg.subject) entry.subject = truncate(msg.subject, 120) - if (fields.includeRecipient) { - const first = normalizeAddresses(msg.to)[0]?.email - if (first) entry.recipient = fields.redact ? redactEmail(first) : first - } - const userMeta = dropPrefixed(ctx.meta, "__logger") - if (userMeta) entry.meta = userMeta - return entry -} + return defineMiddleware("logger", (next) => async (msgs, ctx) => { + const startedAt = Date.now() + const results = await next(msgs, ctx) + const errors = results.flatMap((result) => + result.error ? [{ code: result.error.code, message: result.error.message }] : [], + ) -function attachResult(entry: LogEntry, result: Result): void { - if (result.data) entry.messageId = result.data.id - if (result.error) entry.error = serializeError(result.error) -} - -function serializeError(err: EmailError): LogEntry["error"] { - return { - code: err.code, - message: err.message, - status: err.status, - retryable: err.retryable, - } -} - -function defaultSink(entry: LogEntry): void { - const fn = entry.event === "send.error" ? console.error : console.info - fn(JSON.stringify(entry)) -} + log({ + level: errors.length > 0 ? "error" : "info", + driver: ctx.driver, + ...(ctx.stream ? { stream: ctx.stream } : {}), + attempt: ctx.attempt, + count: msgs.length, + sent: results.length - errors.length, + failed: errors.length, + durationMs: Date.now() - startedAt, + ...(errors.length > 0 ? { errors } : {}), + ...(redact === "none" ? { messages: msgs.map(describe) } : {}), + }) -function truncate(value: string, max: number): string { - return value.length > max ? `${value.slice(0, max - 1)}…` : value + return results + }) } -function redactEmail(email: string): string { - const at = email.indexOf("@") - if (at < 2) return email - return `${email[0]}***${email.slice(at)}` +function describe(msg: NormalizedMessage) { + return { to: msg.to.map((address) => address.email).join(", "), subject: msg.subject } } -function dropPrefixed( - meta: Record, - prefix: string, -): Record | undefined { - const out: Record = {} - let has = false - for (const [k, v] of Object.entries(meta)) { - if (k.startsWith(prefix)) continue - out[k] = v - has = true - } - return has ? out : undefined +function defaultLog(entry: LogEntry) { + if (entry.level === "error") console.error("[unemail]", entry) + else console.log("[unemail]", entry) } diff --git a/src/middleware/metrics.ts b/src/middleware/metrics.ts deleted file mode 100644 index ba28f56..0000000 --- a/src/middleware/metrics.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Prometheus-style counters + histograms for `email.send`. Exposes a - * `.expose()` helper that produces plaintext in the Prometheus - * exposition format — hand this to a `/metrics` endpoint in any - * framework. - * - * OTLP export is covered by the existing `withTelemetry` middleware. - * - * @module - */ - -import type { Middleware } from "../types.ts" - -export interface MetricsRegistry { - readonly sends: number - readonly errors: Record - readonly durationsMs: number[] - incSend: (driver: string) => void - incError: (driver: string, code: string) => void - observeDuration: (driver: string, ms: number) => void - expose: () => string -} - -export function createMetricsRegistry(): MetricsRegistry { - let sends = 0 - const byDriver = new Map() - const errors = new Map() - const durations: number[] = [] - const driverDurations = new Map() - return { - get sends() { - return sends - }, - get errors() { - return Object.fromEntries(errors) - }, - get durationsMs() { - return [...durations] - }, - incSend(driver) { - sends++ - byDriver.set(driver, (byDriver.get(driver) ?? 0) + 1) - }, - incError(driver, code) { - const key = `${driver}|${code}` - errors.set(key, (errors.get(key) ?? 0) + 1) - }, - observeDuration(driver, ms) { - durations.push(ms) - const arr = driverDurations.get(driver) ?? [] - arr.push(ms) - driverDurations.set(driver, arr) - }, - expose() { - const lines: string[] = [] - lines.push("# HELP unemail_sends_total Total sends by driver") - lines.push("# TYPE unemail_sends_total counter") - for (const [driver, n] of byDriver) lines.push(`unemail_sends_total{driver="${driver}"} ${n}`) - lines.push("# HELP unemail_errors_total Total errors by driver and code") - lines.push("# TYPE unemail_errors_total counter") - for (const [key, n] of errors) { - const [driver, code] = key.split("|") - lines.push(`unemail_errors_total{driver="${driver}",code="${code}"} ${n}`) - } - lines.push("# HELP unemail_send_duration_ms Send durations in milliseconds") - lines.push("# TYPE unemail_send_duration_ms summary") - for (const [driver, arr] of driverDurations) { - arr.sort((a, b) => a - b) - const pct = (p: number) => arr[Math.min(arr.length - 1, Math.floor(arr.length * p))] ?? 0 - lines.push( - `unemail_send_duration_ms{driver="${driver}",quantile="0.5"} ${pct(0.5)}`, - `unemail_send_duration_ms{driver="${driver}",quantile="0.9"} ${pct(0.9)}`, - `unemail_send_duration_ms{driver="${driver}",quantile="0.99"} ${pct(0.99)}`, - `unemail_send_duration_ms_sum{driver="${driver}"} ${arr.reduce((a, b) => a + b, 0)}`, - `unemail_send_duration_ms_count{driver="${driver}"} ${arr.length}`, - ) - } - return lines.join("\n") + "\n" - }, - } -} - -export interface MetricsMiddlewareOptions { - registry: MetricsRegistry - now?: () => number -} - -/** Middleware that records counters + durations into a registry. */ -export function withMetrics(options: MetricsMiddlewareOptions): Middleware { - const now = options.now ?? Date.now - const started = new WeakMap() - return { - name: "metrics", - beforeSend(msg) { - started.set(msg as object, now()) - }, - afterSend(msg, ctx, result) { - const t0 = started.get(msg as object) ?? now() - options.registry.observeDuration(ctx.driver, now() - t0) - if (result.error) options.registry.incError(ctx.driver, result.error.code) - else options.registry.incSend(ctx.driver) - }, - } -} diff --git a/src/middleware/oauth2.ts b/src/middleware/oauth2.ts deleted file mode 100644 index 3734f48..0000000 --- a/src/middleware/oauth2.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * OAuth2 access-token refresh middleware. Keeps a fresh bearer token - * in memory and exposes it via `msg.headers.authorization` so drivers - * (SMTP XOAUTH2, Gmail REST, Microsoft Graph) see a valid token every - * send. - * - * Ships Gmail + Microsoft 365 presets; other IdPs are one line of - * config. - * - * @module - */ - -import type { Middleware } from "../types.ts" - -export interface OAuth2TokenCache { - get: () => { accessToken: string; expiresAt: number } | null - set: (accessToken: string, expiresAt: number) => void -} - -export interface OAuth2Options { - tokenEndpoint: string - clientId: string - clientSecret: string - refreshToken: string - /** Extra form fields (e.g. `scope`). */ - extraParams?: Record - /** Seconds to subtract from `expires_in` so we refresh before expiry. - * Default: 30s. */ - skewSeconds?: number - /** Injected for tests. */ - now?: () => number - fetch?: typeof fetch - cache?: OAuth2TokenCache -} - -export interface OAuth2TokenResponse { - access_token: string - expires_in: number - token_type?: string -} - -/** Generic OAuth2 refresh-token → access-token middleware. */ -export function withOAuth2(options: OAuth2Options): Middleware { - const cache = options.cache ?? memoryCache() - const skew = (options.skewSeconds ?? 30) * 1000 - const now = options.now ?? Date.now - const fetchImpl = options.fetch ?? globalThis.fetch - return { - name: "oauth2", - async beforeSend(msg) { - const token = await ensureToken() - const headers: Record = { ...msg.headers } - headers.authorization = `Bearer ${token}` - ;(msg as { headers?: Record }).headers = headers - }, - } - - async function ensureToken(): Promise { - const cached = cache.get() - if (cached && now() < cached.expiresAt - skew) return cached.accessToken - const body = new URLSearchParams({ - grant_type: "refresh_token", - client_id: options.clientId, - client_secret: options.clientSecret, - refresh_token: options.refreshToken, - ...options.extraParams, - }) - const res = await fetchImpl(options.tokenEndpoint, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }) - if (!res.ok) { - throw new Error(`[unemail/oauth2] refresh failed: ${res.status} ${await res.text()}`) - } - const payload = (await res.json()) as OAuth2TokenResponse - cache.set(payload.access_token, now() + payload.expires_in * 1000) - return payload.access_token - } -} - -/** Gmail OAuth2 preset. Pass `{ clientId, clientSecret, refreshToken }`. */ -export function oauth2Gmail( - config: Omit & { - extraParams?: Record - }, -): Middleware { - return withOAuth2({ - tokenEndpoint: "https://oauth2.googleapis.com/token", - extraParams: { scope: "https://mail.google.com/" }, - ...config, - }) -} - -/** Microsoft 365 / Outlook.com OAuth2 preset. */ -export function oauth2Microsoft( - config: Omit & { - tenantId?: string - extraParams?: Record - }, -): Middleware { - const { tenantId = "common", ...rest } = config - return withOAuth2({ - tokenEndpoint: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, - extraParams: { scope: "https://outlook.office.com/.default" }, - ...rest, - }) -} - -function memoryCache(): OAuth2TokenCache { - let cached: { accessToken: string; expiresAt: number } | null = null - return { - get: () => cached, - set: (accessToken, expiresAt) => { - cached = { accessToken, expiresAt } - }, - } -} diff --git a/src/middleware/pii.ts b/src/middleware/pii.ts deleted file mode 100644 index b76ff04..0000000 --- a/src/middleware/pii.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { EmailMessage, Middleware } from "../types.ts" - -export type ScrubStrategy = "hash" | "mask" | "drop" - -export interface PiiScrubberOptions { - /** Which fields to scrub from observable events (logger, telemetry, - * event stream). The actual outgoing email is never mutated. */ - redact?: ReadonlyArray<"recipient" | "subject" | "body" | "attachments"> - strategy?: ScrubStrategy - /** Sink that receives the scrubbed message. Replace the default - * `logger.sink` or `telemetry.attributes` extractor with - * `(msg) => scrubbed(msg)` to opt in. */ - sink?: (scrubbed: Record) => void -} - -/** Return a sanitized shape of an EmailMessage. Not a middleware in - * the Middleware shape — intended to be used inside your logger / - * telemetry sink, so the actual send pipeline is untouched. */ -export function scrubPii( - msg: EmailMessage, - options: PiiScrubberOptions = {}, -): Record { - const redact = new Set(options.redact ?? ["recipient", "subject", "body"]) - const strategy = options.strategy ?? "mask" - const out: Record = { - stream: msg.stream, - from: anonAddress(getEmail(msg.from), strategy), - } - if (redact.has("recipient")) { - out.to = anonAddressList(msg.to, strategy) - if (msg.cc) out.cc = anonAddressList(msg.cc, strategy) - if (msg.bcc) out.bcc = anonAddressList(msg.bcc, strategy) - } else { - out.to = msg.to - } - out.subject = redact.has("subject") ? apply(msg.subject, strategy) : msg.subject - if (msg.text) out.text = redact.has("body") ? apply(msg.text, strategy) : msg.text - if (msg.html) out.html = redact.has("body") ? apply(msg.html, strategy) : msg.html - if (msg.attachments?.length) { - out.attachments = msg.attachments.map((a) => - redact.has("attachments") - ? { filename: apply(a.filename, strategy), contentType: a.contentType } - : { filename: a.filename, contentType: a.contentType }, - ) - } - return out -} - -function getEmail(input: EmailMessage["from"]): string { - if (typeof input === "string") return input - if (input && typeof input === "object" && "email" in input) return input.email - return "unknown" -} - -function anonAddressList(input: unknown, strategy: ScrubStrategy): string[] { - const list = Array.isArray(input) ? input : [input] - return list.map((v) => { - if (typeof v === "string") return anonAddress(extractEmail(v), strategy) - if (v && typeof v === "object" && "email" in (v as Record)) - return anonAddress((v as { email: string }).email, strategy) - return "unknown" - }) -} - -function extractEmail(value: string): string { - const match = /<([^>]+)>/.exec(value) - return match ? match[1]! : value -} - -function anonAddress(address: string, strategy: ScrubStrategy): string { - const at = address.lastIndexOf("@") - if (at < 0) return apply(address, strategy) - const local = address.slice(0, at) - const domain = address.slice(at + 1) - return `${apply(local, strategy)}@${domain}` -} - -function apply(value: string, strategy: ScrubStrategy): string { - if (strategy === "drop") return "***" - if (strategy === "mask") return value.length <= 2 ? "*" : value[0] + "***" - return hash32(value).toString(36) -} - -function hash32(s: string): number { - let h = 0 - for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0 - return h >>> 0 -} - -/** Optional plugin middleware — logs a scrubbed view of each message - * via the provided sink. For wrapping your existing logger/telemetry - * prefer calling `scrubPii(msg, opts)` directly. */ -export function withPiiLogging( - options: Required> & PiiScrubberOptions, -): Middleware { - return { - name: "pii-log", - beforeSend(msg) { - options.sink(scrubPii(msg, options)) - }, - } -} diff --git a/src/middleware/preferences.ts b/src/middleware/preferences.ts deleted file mode 100644 index 1e89300..0000000 --- a/src/middleware/preferences.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { EmailDriver, EmailMessage } from "../types.ts" -import type { PreferenceStore } from "../preferences/index.ts" -import { createError } from "../errors.ts" -import { normalizeAddresses } from "../_normalize.ts" - -export interface PreferencesMiddlewareOptions { - store: PreferenceStore - /** How to pick the category for a message. By default reads the - * first `EmailTag` named `"category"`, then falls back to - * `msg.stream`. Messages without a category pass through. */ - categoryFor?: (msg: EmailMessage) => string | null - /** When true, block the entire send if ANY recipient has opted out. - * Default: false — drop the opt-outs and continue. */ - strict?: boolean -} - -/** Check the preference store before `driver.send`. Recipients who - * opted out of the resolved category are removed. */ -export function withPreferences( - driver: EmailDriver, - options: PreferencesMiddlewareOptions, -): EmailDriver { - const resolveCategory = options.categoryFor ?? defaultCategoryFor - return { - ...driver, - async send(msg, ctx) { - const category = resolveCategory(msg) - if (!category) return driver.send(msg, ctx) - const recipients = [ - ...normalizeAddresses(msg.to), - ...normalizeAddresses(msg.cc), - ...normalizeAddresses(msg.bcc), - ] - const allowed = new Set() - const blocked: string[] = [] - for (const r of recipients) { - const ok = await options.store.allows(r.email, category) - if (ok) allowed.add(r.email.toLowerCase()) - else blocked.push(r.email) - } - if (blocked.length === 0) return driver.send(msg, ctx) - if (options.strict || allowed.size === 0) { - return { - data: null, - error: createError( - driver.name, - "PROVIDER", - `opted out of category "${category}": ${blocked.join(", ")}`, - { retryable: false }, - ), - } - } - return driver.send(keep(msg, allowed), ctx) - }, - } -} - -function defaultCategoryFor(msg: EmailMessage): string | null { - const tag = msg.tags?.find((t) => t.name.toLowerCase() === "category") - if (tag) return tag.value - return msg.stream ?? null -} - -function keep(msg: EmailMessage, allowed: Set): EmailMessage { - const filter = (input: EmailMessage["to"] | undefined) => { - if (!input) return undefined - const list = normalizeAddresses(input).filter((a) => allowed.has(a.email.toLowerCase())) - return list.length ? list : undefined - } - return { ...msg, to: filter(msg.to) ?? msg.to, cc: filter(msg.cc), bcc: filter(msg.bcc) } -} diff --git a/src/middleware/rate-limit.ts b/src/middleware/rate-limit.ts index d6d8e2f..14ec435 100644 --- a/src/middleware/rate-limit.ts +++ b/src/middleware/rate-limit.ts @@ -1,97 +1,104 @@ -import type { EmailDriver } from "../types.ts" -import { createError } from "../errors.ts" +import type { Middleware } from "../core/types.ts" +import { defineMiddleware } from "../core/define.ts" +import { createError } from "../core/error.ts" +import { err } from "../core/result.ts" -/** Sliding-window rate limiter — queues calls until they fit in the - * per-second budget. Intentionally simple: single-process only, no - * cross-instance coordination. For distributed limits, plug a driver - * that delegates to Redis/QStash. */ export interface RateLimitOptions { - /** Maximum calls per `windowMs`. */ - perSecond?: number - /** Alternative window in milliseconds — defaults to 1000. */ - windowMs?: number - /** Hard cap on queued sends before rejecting fast. Default: 1000. */ - maxQueue?: number - /** When true, the limiter also honours `Retry-After` from 429 - * responses by delaying the next attempt by that long. */ - respectRetryAfter?: boolean - /** Injected for tests. */ + /** Sustained sends allowed per `intervalMs`. */ + limit: number + /** Window the limit applies to, in milliseconds. Default: 1000. */ + intervalMs?: number + /** Extra capacity for a cold start. Default: `limit`. */ + burst?: number + /** What to do when the bucket is empty. `wait` blocks until tokens are + * available; `reject` fails fast with `RATE_LIMIT`. Default: `wait`. */ + onLimit?: "wait" | "reject" + /** Give up waiting after this long. Default: 30_000. */ + maxWaitMs?: number + /** Injected for deterministic tests. */ now?: () => number + /** Injected for deterministic tests. */ sleep?: (ms: number) => Promise } -/** Provider-aware preset limits. Numbers are conservative — override - * when your tier is higher. */ -export const rateLimitPresets = { - sendgrid: (): RateLimitOptions => ({ perSecond: 30, respectRetryAfter: true }), - mailgun: (): RateLimitOptions => ({ perSecond: 10, respectRetryAfter: true }), - resend: (): RateLimitOptions => ({ perSecond: 10, respectRetryAfter: true }), - postmark: (): RateLimitOptions => ({ perSecond: 50, respectRetryAfter: true }), - ses: (): RateLimitOptions => ({ perSecond: 14, respectRetryAfter: true }), - brevo: (): RateLimitOptions => ({ perSecond: 5, respectRetryAfter: true }), +/** Provider defaults, so callers do not have to go looking them up. These + * are the documented free-tier limits; raise them to match your plan. */ +export const rateLimitPresets: Record<"resend" | "postmark" | "ses" | "smtp", RateLimitOptions> = { + resend: { limit: 2, intervalMs: 1000 }, + postmark: { limit: 300, intervalMs: 1000 }, + ses: { limit: 14, intervalMs: 1000 }, + smtp: { limit: 10, intervalMs: 1000 }, } -/** Wrap a driver so `send()` respects a rate limit. */ -export function withRateLimit(driver: EmailDriver, options: RateLimitOptions): EmailDriver { - const perSecond = options.perSecond ?? 10 - const windowMs = options.windowMs ?? 1000 - const maxQueue = options.maxQueue ?? 1000 - const respectRetryAfter = options.respectRetryAfter ?? false +/** + * Token bucket in front of the driver. + * + * A batch takes as many tokens as it has messages, so a 500-message + * `sendBatch` is throttled like 500 sends rather than like one. + * + * ```ts + * email.use(withRateLimit(rateLimitPresets.resend)) + * ``` + */ +export function withRateLimit(options: RateLimitOptions): Middleware { + const intervalMs = options.intervalMs ?? 1000 + const capacity = options.burst ?? options.limit + const refillPerMs = options.limit / intervalMs + const onLimit = options.onLimit ?? "wait" + const maxWaitMs = options.maxWaitMs ?? 30_000 const now = options.now ?? Date.now - const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))) + const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) - const timestamps: number[] = [] - let queued = 0 - let blockedUntil = 0 + let tokens = capacity + let lastRefill = now() + // Serializes waiters so they drain in arrival order instead of all + // waking on the same refill and stampeding the provider. + let queue: Promise = Promise.resolve() - return { - ...driver, - async send(msg, ctx) { - if (queued >= maxQueue) { - return { - data: null, - error: createError(driver.name, "RATE_LIMIT", "rate-limit queue full", { - status: 429, - retryable: true, - }), - } - } - queued++ - try { - while (true) { - const ts = now() - if (ts < blockedUntil) { - await sleep(blockedUntil - ts) - continue - } - const cutoff = ts - windowMs - while (timestamps.length && timestamps[0]! <= cutoff) timestamps.shift() - if (timestamps.length < perSecond) { - timestamps.push(ts) - break - } - const wait = timestamps[0]! + windowMs - ts - await sleep(Math.max(wait, 1)) - } - const result = await driver.send(msg, ctx) - if (respectRetryAfter && result.error?.status === 429) { - const after = extractRetryAfter(result.error.cause) - if (after != null) blockedUntil = now() + after * 1000 - } - return result - } finally { - queued-- + function refill() { + const at = now() + tokens = Math.min(capacity, tokens + (at - lastRefill) * refillPerMs) + lastRefill = at + } + + async function acquire(cost: number): Promise { + const deadline = now() + maxWaitMs + for (;;) { + refill() + if (tokens >= cost) { + tokens -= cost + return true } - }, + if (onLimit === "reject") return false + const waitMs = Math.ceil((cost - tokens) / refillPerMs) + if (now() + waitMs > deadline) return false + await sleep(waitMs) + } } -} -function extractRetryAfter(cause: unknown): number | null { - if (!cause || typeof cause !== "object") return null - const rec = cause as Record - const headers = rec.headers as { get?: (name: string) => string | null } | undefined - const raw = headers?.get?.("retry-after") ?? (rec["retry-after"] as string | undefined) - if (!raw) return null - const seconds = Number(raw) - return Number.isFinite(seconds) ? seconds : null + return defineMiddleware("rate-limit", (next) => async (msgs, ctx) => { + const cost = Math.min(msgs.length, capacity) + const turn = queue.then(() => acquire(cost)) + queue = turn.then( + () => undefined, + () => undefined, + ) + const admitted = await turn + + if (!admitted) { + const failure = err( + createError( + ctx.driver, + "RATE_LIMIT", + `local rate limit reached (${options.limit}/${intervalMs}ms)`, + { + retryable: true, + }, + ), + ) + return msgs.map(() => failure) + } + + return next(msgs, ctx) + }) } diff --git a/src/middleware/retry.ts b/src/middleware/retry.ts index 1c5d875..9b4a9e4 100644 --- a/src/middleware/retry.ts +++ b/src/middleware/retry.ts @@ -1,12 +1,13 @@ -import type { EmailDriver, EmailResult, Result } from "../types.ts" -import { toEmailError } from "../errors.ts" +import type { EmailError } from "../core/error.ts" +import type { EmailResult, Middleware, Result, SendContext } from "../core/types.ts" +import { defineMiddleware } from "../core/define.ts" -/** Backoff strategies. - * - `exponential` — `initialDelay * 2^attempt` (default). - * - `constant` — `initialDelay` every time. - * - `exponential-jitter` — exponential with ±50% uniform noise. - * - `full-jitter` — random in `[0, exponential]` (AWS recommendation). - * - `decorrelated-jitter` — `random(initialDelay, prev * 3)`. */ +/** How the delay grows between attempts. + * - `exponential` — `initialDelay * 2^attempt` + * - `constant` — `initialDelay` every time + * - `exponential-jitter` — exponential ±50% + * - `full-jitter` — uniform in `[0, exponential]` (AWS's recommendation) + * - `decorrelated-jitter` — uniform in `[initialDelay, previous * 3]` */ export type RetryBackoff = | "exponential" | "constant" @@ -14,103 +15,88 @@ export type RetryBackoff = | "full-jitter" | "decorrelated-jitter" -/** Options for `withRetry`. All numeric values are milliseconds unless - * noted. `respectRetryAfter` honors `error.status === 429` with the - * matching `Retry-After` surfaced via `error.cause`. */ export interface RetryOptions { - /** Number of *retry* attempts on top of the initial send. Default: 3. */ + /** Attempts *after* the first. Default: 3. */ retries?: number - /** Initial backoff delay. Default: 250ms. */ + /** Milliseconds before the first retry. Default: 250. */ initialDelay?: number - /** Maximum backoff delay between attempts. Default: 10_000ms. */ + /** Ceiling for any single delay, in milliseconds. Default: 10_000. */ maxDelay?: number - /** Backoff strategy. See `RetryBackoff`. */ + /** Default: `exponential-jitter` — plain exponential synchronizes every + * client that failed at the same moment into the same retry wave. */ backoff?: RetryBackoff - /** Honor a `Retry-After` seconds value when present on 429. Default: true. */ + /** Honor a `Retry-After` header on 429. Default: true. */ respectRetryAfter?: boolean - /** Override default retryability — by default only `error.retryable === true`. */ - shouldRetry?: (error: NonNullable["error"]>, attempt: number) => boolean - /** Route exhausted sends to this driver (dead-letter). Original error - * is preserved on `ctx.meta.deadLetterReason`. */ - deadLetter?: EmailDriver - /** Injected for tests. Default: `setTimeout`. */ + /** Override which failures are retried. Default: `error.retryable`. */ + shouldRetry?: (error: EmailError, attempt: number) => boolean + /** Injected for deterministic tests. */ sleep?: (ms: number, signal?: AbortSignal) => Promise - /** Injected for deterministic jitter in tests. Default: `Math.random`. */ + /** Injected for deterministic tests. */ random?: () => number } -/** Wrap a driver so every send is retried on transient failures. Returns a - * regular `EmailDriver` — compose it with `fallback`, `roundRobin`, etc. +/** + * Re-send what failed, leave what succeeded. * - * ```ts - * const driver = withRetry(resend({ apiKey }), { retries: 3 }) - * ``` + * Because the pipeline's unit of work is the whole list, this retries only + * the failed indices — including when the driver reached the provider in a + * single batched request. A partial batch failure costs one small retry, + * not a full re-send with duplicate deliveries. + * + * ```ts + * email.use(withRetry({ retries: 5 })) + * ``` */ -export function withRetry(driver: EmailDriver, options: RetryOptions = {}): EmailDriver { +export function withRetry(options: RetryOptions = {}): Middleware { const retries = options.retries ?? 3 const initialDelay = options.initialDelay ?? 250 const maxDelay = options.maxDelay ?? 10_000 - const backoff = options.backoff ?? "exponential" + const backoff = options.backoff ?? "exponential-jitter" const respectRetryAfter = options.respectRetryAfter ?? true + const shouldRetry = options.shouldRetry ?? ((error) => error.retryable) const sleep = options.sleep ?? defaultSleep - const shouldRetry = options.shouldRetry ?? ((err) => err.retryable) const random = options.random ?? Math.random - const deadLetter = options.deadLetter - return { - ...driver, - name: driver.name, - async send(msg, ctx) { - let lastError: NonNullable["error"]> | null = null - let lastDelay = initialDelay - for (let attempt = 0; attempt <= retries; attempt++) { - ctx.attempt = attempt + 1 - if (ctx.signal?.aborted) { - return { - data: null, - error: toEmailError(driver.name, ctx.signal.reason ?? new Error("aborted")), - } - } - let result: Result - try { - result = await driver.send(msg, ctx) - } catch (thrown) { - result = { data: null, error: toEmailError(driver.name, thrown) } - } - if (result.data) return result - lastError = result.error - if (attempt === retries || !shouldRetry(result.error, attempt + 1)) { - return deadLetter ? routeToDeadLetter(deadLetter, msg, ctx, result.error) : result - } - const delay = computeDelay({ - attempt, - initialDelay, - maxDelay, - backoff, - respectRetryAfter, - error: result.error, - random, - previousDelay: lastDelay, - }) - lastDelay = delay + return defineMiddleware("retry", (next) => async (msgs, ctx) => { + const results = [...(await next(msgs, ctx))] + let previousDelay = initialDelay + + for (let attempt = 1; attempt <= retries; attempt++) { + const pending = results.flatMap((result, index) => + result.error && shouldRetry(result.error, attempt) ? [index] : [], + ) + if (pending.length === 0) break + if (ctx.signal?.aborted) break + + const delay = computeDelay({ + attempt: attempt - 1, + initialDelay, + maxDelay, + backoff, + random, + previousDelay, + error: respectRetryAfter ? results[pending[0]!]!.error : null, + }) + previousDelay = delay + try { await sleep(delay, ctx.signal) + } catch { + break } - return deadLetter && lastError - ? routeToDeadLetter(deadLetter, msg, ctx, lastError) - : { data: null, error: lastError! } - }, - } -} -async function routeToDeadLetter( - dlq: EmailDriver, - msg: Parameters[0], - ctx: Parameters[1], - error: NonNullable["error"]>, -): Promise> { - ctx.meta.deadLetterReason = error.message - ctx.meta.deadLetterCode = error.code - return dlq.send(msg, ctx) + const retryCtx: SendContext = { ...ctx, attempt: attempt + 1 } + const redo = await next( + pending.map((index) => msgs[index]!), + retryCtx, + ) + for (const [slot, index] of pending.entries()) { + const replacement = redo[slot] + if (replacement) results[index] = replacement + } + } + + return results as readonly Result[] + }) } interface DelayInput { @@ -118,29 +104,26 @@ interface DelayInput { initialDelay: number maxDelay: number backoff: RetryBackoff - respectRetryAfter: boolean - error: NonNullable["error"]> random: () => number previousDelay: number + error: EmailError | null } function computeDelay(input: DelayInput): number { - if (input.respectRetryAfter && input.error.status === 429) { + if (input.error?.status === 429) { const retryAfter = extractRetryAfter(input.error.cause) if (retryAfter != null) return Math.min(retryAfter * 1000, input.maxDelay) } - const exp = input.initialDelay * 2 ** input.attempt + const exponential = input.initialDelay * 2 ** input.attempt switch (input.backoff) { case "constant": return Math.min(input.initialDelay, input.maxDelay) case "exponential": - return Math.min(exp, input.maxDelay) - case "exponential-jitter": { - const jitter = 0.5 + input.random() - return Math.min(Math.floor(exp * jitter), input.maxDelay) - } + return Math.min(exponential, input.maxDelay) + case "exponential-jitter": + return Math.min(Math.floor(exponential * (0.5 + input.random())), input.maxDelay) case "full-jitter": - return Math.min(Math.floor(input.random() * exp), input.maxDelay) + return Math.min(Math.floor(input.random() * exponential), input.maxDelay) case "decorrelated-jitter": { const high = Math.max(input.previousDelay * 3, input.initialDelay) const value = input.initialDelay + input.random() * (high - input.initialDelay) @@ -149,18 +132,24 @@ function computeDelay(input: DelayInput): number { } } +/** Drivers stash the response headers on `error.cause`, so a provider's + * own backoff advice survives the trip out of the driver. */ function extractRetryAfter(cause: unknown): number | null { if (!cause || typeof cause !== "object") return null const record = cause as Record const headers = record.headers as { get?: (name: string) => string | null } | undefined - const raw = headers?.get?.("retry-after") ?? (record["retry-after"] as string | undefined) - if (!raw) return null + const raw = headers?.get?.("retry-after") ?? record["retry-after"] + if (typeof raw !== "string" && typeof raw !== "number") return null const seconds = Number(raw) - return Number.isFinite(seconds) ? seconds : null + return Number.isFinite(seconds) && seconds >= 0 ? seconds : null } function defaultSleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("aborted")) + return + } const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort) resolve() @@ -169,13 +158,6 @@ function defaultSleep(ms: number, signal?: AbortSignal): Promise { clearTimeout(timer) reject(signal!.reason ?? new Error("aborted")) } - if (signal) { - if (signal.aborted) { - clearTimeout(timer) - reject(signal.reason ?? new Error("aborted")) - return - } - signal.addEventListener("abort", onAbort, { once: true }) - } + signal?.addEventListener("abort", onAbort, { once: true }) }) } diff --git a/src/middleware/suppression.ts b/src/middleware/suppression.ts deleted file mode 100644 index ecf76e1..0000000 --- a/src/middleware/suppression.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { EmailDriver, EmailMessage } from "../types.ts" -import type { SuppressionStore } from "../suppression/index.ts" -import { createError } from "../errors.ts" -import { normalizeAddresses } from "../_normalize.ts" - -/** Behavior when a recipient is suppressed. - * - `"error"` — return an `EmailError` with code `PROVIDER` and - * suppression metadata. - * - `"drop"` — strip the suppressed recipients and continue. If - * every recipient is suppressed, behaves like `"error"`. */ -export type SuppressionPolicy = "error" | "drop" - -export interface SuppressionOptions { - store: SuppressionStore - policy?: SuppressionPolicy - /** Hook fired for each suppressed recipient. */ - onBlocked?: (recipient: string, reason: string) => void -} - -/** Wrap a driver so `send()` checks the suppression store before the - * request leaves the process. */ -export function withSuppression(driver: EmailDriver, options: SuppressionOptions): EmailDriver { - const policy = options.policy ?? "error" - return { - ...driver, - async send(msg, ctx) { - const all = [ - ...normalizeAddresses(msg.to), - ...normalizeAddresses(msg.cc), - ...normalizeAddresses(msg.bcc), - ] - const blocked: Array<{ recipient: string; reason: string }> = [] - const allowed = new Set() - for (const addr of all) { - const rec = await options.store.has(addr.email) - if (rec) { - blocked.push({ recipient: addr.email, reason: String(rec.reason) }) - options.onBlocked?.(addr.email, String(rec.reason)) - } else { - allowed.add(addr.email.toLowerCase()) - } - } - if (blocked.length === 0) return driver.send(msg, ctx) - - if (policy === "error" || allowed.size === 0) { - return { - data: null, - error: createError( - driver.name, - "PROVIDER", - `recipient suppressed: ${blocked.map((b) => b.recipient).join(", ")}`, - { retryable: false }, - ), - } - } - - return driver.send(filterRecipients(msg, allowed), ctx) - }, - } -} - -function filterRecipients(msg: EmailMessage, allowed: Set): EmailMessage { - const keep = (input: EmailMessage["to"] | undefined) => { - if (!input) return undefined - const list = normalizeAddresses(input).filter((a) => allowed.has(a.email.toLowerCase())) - return list.length ? list : undefined - } - return { ...msg, to: keep(msg.to) ?? msg.to, cc: keep(msg.cc), bcc: keep(msg.bcc) } -} diff --git a/src/middleware/telemetry.ts b/src/middleware/telemetry.ts deleted file mode 100644 index 21d3b2d..0000000 --- a/src/middleware/telemetry.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { Middleware } from "../types.ts" -import { normalizeAddresses } from "../_normalize.ts" - -/** Minimal OpenTelemetry \`Tracer\` surface we need — matches - * \`@opentelemetry/api\` but typed locally so the middleware doesn't - * require the peer at build time. */ -export interface OtelTracer { - startActiveSpan: ( - name: string, - options: { attributes?: Record }, - fn: (span: OtelSpan) => T | Promise, - ) => T | Promise -} - -export interface OtelSpan { - setAttribute: (key: string, value: unknown) => void - recordException: (err: unknown) => void - setStatus: (status: { code: 1 | 2; message?: string }) => void - end: () => void -} - -export interface TelemetryOptions { - /** Tracer to drive. Usually \`trace.getTracer("unemail")\` from - * \`@opentelemetry/api\`. When omitted the middleware is a no-op. */ - tracer?: OtelTracer - /** Sampling hook — return \`false\` to skip span creation for a given - * send (useful to avoid tracing health-check emails). */ - sample?: (attributes: Record) => boolean -} - -/** Middleware that wraps each send in an OpenTelemetry span. - * - * ```ts - * import { trace } from "@opentelemetry/api" - * email.use(withTelemetry({ tracer: trace.getTracer("unemail") })) - * ``` - * - * Attributes emitted: - * - \`email.driver\`, \`email.stream\`, \`email.attempt\` - * - \`email.to\`, \`email.subject.length\` - * - \`email.message_id\` (set on success) - * - \`email.error.code\` (set on failure) - * - * The full recipient is emitted — strip it by wrapping \`tracer\` yourself - * if you have stricter PII rules. */ -export function withTelemetry(options: TelemetryOptions = {}): Middleware { - const tracer = options.tracer - if (!tracer) { - // No-op middleware when OTel isn't wired up. - return { name: "telemetry" } - } - return { - name: "telemetry", - async beforeSend(msg, ctx) { - const attrs: Record = { - "email.driver": ctx.driver, - "email.attempt": ctx.attempt, - "email.subject.length": msg.subject.length, - } - if (ctx.stream) attrs["email.stream"] = ctx.stream - const recipient = normalizeAddresses(msg.to)[0]?.email - if (recipient) attrs["email.to"] = recipient - if (options.sample && !options.sample(attrs)) return - - // We can't wrap the whole send around startActiveSpan from a hook, - // so we open a span here and close it in afterSend/onError via meta. - let resolveSpan!: (value: OtelSpan) => void - const spanPromise = new Promise((resolve) => { - resolveSpan = resolve - }) - // We don't await this — fire-and-forget so the span's lifetime - // spans the whole send. - void tracer.startActiveSpan("email.send", { attributes: attrs }, async (span) => { - resolveSpan(span) - // Keep the active context open until afterSend/onError closes it. - await new Promise((r) => { - ctx.meta.__telemetryEnd = r - }) - }) - ctx.meta.__telemetrySpan = await spanPromise - }, - afterSend(_msg, ctx, result) { - const span = ctx.meta.__telemetrySpan as OtelSpan | undefined - if (!span) return - if (result.data) { - span.setAttribute("email.message_id", result.data.id) - span.setStatus({ code: 1 }) // OK - } else if (result.error) { - span.setAttribute("email.error.code", result.error.code) - span.recordException(result.error) - span.setStatus({ code: 2, message: result.error.message }) - } - span.end() - ;(ctx.meta.__telemetryEnd as (() => void) | undefined)?.() - }, - onError(_msg, ctx, error) { - const span = ctx.meta.__telemetrySpan as OtelSpan | undefined - if (!span) return - span.setAttribute("email.error.code", error.code) - span.recordException(error) - span.setStatus({ code: 2, message: error.message }) - span.end() - ;(ctx.meta.__telemetryEnd as (() => void) | undefined)?.() - }, - } -} diff --git a/src/mta-sts/index.ts b/src/mta-sts/index.ts deleted file mode 100644 index 9d4f968..0000000 --- a/src/mta-sts/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * MTA-STS (RFC 8461) + TLS-RPT (RFC 8460) helpers. Two parts: - * - * - `generateMtaStsPolicy(...)` produces the text file you serve at - * `https://mta-sts./.well-known/mta-sts.txt`. - * - `parseTlsRpt(json)` normalizes TLS-RPT JSON reports into a typed - * record. - * - * DNS side (the TXT records at `_mta-sts.` and `_smtp._tls.`) - * stays your responsibility — it's one line per domain. - * - * @module - */ - -export interface MtaStsPolicyOptions { - mode: "enforce" | "testing" | "none" - /** Authorized MX patterns. */ - mx: ReadonlyArray - /** Policy lifetime in seconds. RFC 8461 recommends ≤ 604800 (7 days). */ - maxAgeSeconds: number -} - -/** Produce the RFC 8461 policy body. */ -export function generateMtaStsPolicy(options: MtaStsPolicyOptions): string { - const lines = [`version: STSv1`, `mode: ${options.mode}`] - for (const mx of options.mx) lines.push(`mx: ${mx}`) - lines.push(`max_age: ${options.maxAgeSeconds}`) - return lines.join("\r\n") + "\r\n" -} - -export interface TlsRptReport { - organizationName?: string - dateRange?: { start: Date; end: Date } - contactInfo?: string - reportId?: string - policies: ReadonlyArray -} - -export interface TlsRptPolicy { - policyType?: "tlsa" | "sts" | "no-policy-found" - policyDomain?: string - totalSuccessful?: number - totalFailure?: number - failureDetails?: ReadonlyArray<{ resultType?: string; sendingMtaIp?: string; count?: number }> -} - -/** Parse a TLS-RPT JSON report. Accepts a string or already-parsed - * object; normalizes camelCase fields. */ -export function parseTlsRpt(input: string | Record): TlsRptReport { - const raw = typeof input === "string" ? (JSON.parse(input) as Record) : input - const policies: TlsRptPolicy[] = [] - const rawPolicies = raw.policies as Array> | undefined - if (Array.isArray(rawPolicies)) { - for (const p of rawPolicies) { - const policy = (p.policy ?? {}) as Record - const summary = (p.summary ?? {}) as Record - policies.push({ - policyType: policy["policy-type"] as TlsRptPolicy["policyType"], - policyDomain: policy["policy-domain"] as string | undefined, - totalSuccessful: summary["total-successful-session-count"] as number | undefined, - totalFailure: summary["total-failure-session-count"] as number | undefined, - failureDetails: (p["failure-details"] as Array> | undefined)?.map( - (d) => ({ - resultType: d["result-type"] as string | undefined, - sendingMtaIp: d["sending-mta-ip"] as string | undefined, - count: d["failed-session-count"] as number | undefined, - }), - ), - }) - } - } - const dateRange = raw["date-range"] as Record | undefined - return { - organizationName: raw["organization-name"] as string | undefined, - dateRange: - dateRange && dateRange["start-datetime"] - ? { - start: new Date(dateRange["start-datetime"]), - end: new Date(dateRange["end-datetime"]!), - } - : undefined, - contactInfo: raw["contact-info"] as string | undefined, - reportId: raw["report-id"] as string | undefined, - policies, - } -} diff --git a/src/parse/arf.ts b/src/parse/arf.ts deleted file mode 100644 index b9136dd..0000000 --- a/src/parse/arf.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * RFC 5965 (Abuse Reporting Format) parser — turn an FBL complaint - * into a structured record. Zero-dep. - * - * @module - */ - -export interface ArfReport { - feedbackType?: string - userAgent?: string - version?: string - originalMailFrom?: string - originalRcptTo?: string - reportedDomain?: string - arrivalDate?: Date - sourceIp?: string - reportedMessageId?: string - reportedHeaders?: Record -} - -/** Parse a raw ARF message (multipart/report with - * `report-type=feedback-report`). We look for the `message/feedback-report` - * part and the embedded original headers. */ -export function parseArf(raw: string): ArfReport { - const report = extractPart(raw, "message/feedback-report") - const fields = parseKv(report) - const headers = extractOriginalHeaders(raw) - return { - feedbackType: fields["feedback-type"], - userAgent: fields["user-agent"], - version: fields.version, - originalMailFrom: fields["original-mail-from"], - originalRcptTo: fields["original-rcpt-to"], - reportedDomain: fields["reported-domain"], - arrivalDate: fields["arrival-date"] ? new Date(fields["arrival-date"]) : undefined, - sourceIp: fields["source-ip"], - reportedMessageId: headers["message-id"], - reportedHeaders: headers, - } -} - -function extractPart(raw: string, contentType: string): string { - const re = new RegExp( - `content-type:\\s*${contentType}[^]*?\\r?\\n\\r?\\n([\\s\\S]*?)(?=\\r?\\n--|$)`, - "i", - ) - const m = re.exec(raw) - return m ? m[1]!.trim() : "" -} - -function parseKv(block: string): Record { - const out: Record = {} - for (const line of block.split(/\r?\n/)) { - const m = /^([\w-]+):\s*(.*)$/.exec(line) - if (m) out[m[1]!.toLowerCase()] = m[2]!.trim() - } - return out -} - -function extractOriginalHeaders(raw: string): Record { - const part = extractPart(raw, "message/rfc822-headers") || extractPart(raw, "message/rfc822") - const out: Record = {} - for (const line of part.split(/\r?\n/)) { - const m = /^([\w-]+):\s*(.*)$/.exec(line) - if (m) out[m[1]!.toLowerCase()] = m[2]!.trim() - } - return out -} diff --git a/src/parse/index.ts b/src/parse/index.ts deleted file mode 100644 index 48bbe31..0000000 --- a/src/parse/index.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { Attachment, EmailAddress } from "../types.ts" - -/** Unified shape every parser and inbound adapter produces. Mirrors the - * shape of \`postal-mime\` with the addresses normalized into our own - * \`EmailAddress\` struct. */ -export interface ParsedEmail { - messageId?: string - date?: Date - subject?: string - from?: EmailAddress - to: EmailAddress[] - cc: EmailAddress[] - bcc: EmailAddress[] - replyTo?: EmailAddress - inReplyTo?: string - references: string[] - text?: string - html?: string - headers: Record - attachments: ParsedAttachment[] -} - -/** Attachment discovered during parsing. \`content\` is the raw bytes. */ -export interface ParsedAttachment extends Omit { - content: Uint8Array -} - -export interface ParseEmailOptions { - /** Override the parser for tests. Defaults to the `postal-mime` peer. */ - parse?: (raw: unknown) => Promise -} - -/** A subset of \`postal-mime\`'s result shape we actually read. */ -interface PostalMimeLike { - messageId?: string - date?: string | Date - subject?: string - from?: { address?: string; name?: string } - to?: Array<{ address?: string; name?: string }> - cc?: Array<{ address?: string; name?: string }> - bcc?: Array<{ address?: string; name?: string }> - replyTo?: Array<{ address?: string; name?: string }> | { address?: string; name?: string } - inReplyTo?: string - references?: string | string[] - text?: string - html?: string - headers?: Array<{ key: string; value: string }> | Record - attachments?: Array<{ - filename?: string - mimeType?: string - contentType?: string - content?: Uint8Array | ArrayBuffer | string - contentId?: string - disposition?: string - }> -} - -/** Parse a raw MIME message into a \`ParsedEmail\`. Works on every runtime - * \`postal-mime\` supports (Node, Bun, Deno, browsers, Cloudflare Workers). - * - * Accepts the same inputs \`postal-mime\`'s \`parse\` does: \`string\`, - * \`ArrayBuffer\`, \`Uint8Array\`, \`Blob\`, or a \`ReadableStream\`. */ -export async function parseEmail( - raw: unknown, - options: ParseEmailOptions = {}, -): Promise { - const parse = options.parse ?? (await resolvePostalMime()) - const mail = await parse(raw) - return normalizeParsed(mail) -} - -async function resolvePostalMime(): Promise<(raw: unknown) => Promise> { - try { - const mod = await import("postal-mime" as string) - const PostalMime = (mod.default ?? mod.PostalMime ?? mod) as - | { parse?: (raw: unknown) => Promise } - | (new () => { parse: (raw: unknown) => Promise }) - // Static form (newer versions): `PostalMime.parse(raw)`. - if (typeof (PostalMime as { parse?: unknown }).parse === "function") { - return (PostalMime as { parse: (raw: unknown) => Promise }).parse.bind( - PostalMime, - ) - } - // Instance form: `new PostalMime().parse(raw)`. - if (typeof PostalMime === "function") { - return async (raw) => { - const instance = new (PostalMime as new () => { - parse: (r: unknown) => Promise - })() - return instance.parse(raw) - } - } - throw new Error("unsupported postal-mime export shape") - } catch (err) { - throw new Error( - "[unemail/parse] requires `postal-mime` as a peer dependency. " + - `Install it or pass \`parse\` via options. Original error: ${(err as Error).message}`, - ) - } -} - -export function normalizeParsed(mail: PostalMimeLike): ParsedEmail { - const replyTo = Array.isArray(mail.replyTo) ? mail.replyTo[0] : mail.replyTo - return { - messageId: mail.messageId, - date: mail.date ? new Date(mail.date) : undefined, - subject: mail.subject, - from: mail.from ? toAddress(mail.from) : undefined, - to: (mail.to ?? []).map(toAddress), - cc: (mail.cc ?? []).map(toAddress), - bcc: (mail.bcc ?? []).map(toAddress), - replyTo: replyTo ? toAddress(replyTo) : undefined, - inReplyTo: mail.inReplyTo, - references: normalizeRefs(mail.references), - text: mail.text, - html: mail.html, - headers: normalizeHeaders(mail.headers), - attachments: (mail.attachments ?? []).map(toAttachment), - } -} - -function toAddress(a: { address?: string; name?: string }): EmailAddress { - return { email: a.address ?? "", name: a.name || undefined } -} - -function normalizeRefs(refs: string | string[] | undefined): string[] { - if (!refs) return [] - if (Array.isArray(refs)) return refs - return refs.split(/\s+/).filter(Boolean) -} - -function normalizeHeaders( - headers: Array<{ key: string; value: string }> | Record | undefined, -): Record { - if (!headers) return {} - if (Array.isArray(headers)) { - const out: Record = {} - for (const { key, value } of headers) out[key.toLowerCase()] = value - return out - } - const out: Record = {} - for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v - return out -} - -function toAttachment(a: { - filename?: string - mimeType?: string - contentType?: string - content?: Uint8Array | ArrayBuffer | string - contentId?: string - disposition?: string -}): ParsedAttachment { - const content = normalizeContent(a.content) - return { - filename: a.filename ?? "attachment", - contentType: a.mimeType ?? a.contentType, - content, - cid: a.contentId?.replace(/[<>]/g, ""), - disposition: a.disposition === "inline" ? "inline" : "attachment", - } -} - -function normalizeContent(content: Uint8Array | ArrayBuffer | string | undefined): Uint8Array { - if (!content) return new Uint8Array() - if (content instanceof Uint8Array) return content - if (content instanceof ArrayBuffer) return new Uint8Array(content) - return new TextEncoder().encode(content) -} diff --git a/src/preferences/index.ts b/src/preferences/index.ts deleted file mode 100644 index 3309990..0000000 --- a/src/preferences/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Preference store — decides whether `(recipient, category)` is - * allowed to receive a message. Mirrors the Novu/Knock/Courier - * primitive, scoped to the narrow "preference center" use case. - * - * @module - */ - -import type { MaybePromise } from "../types.ts" - -export interface PreferenceRecord { - recipient: string - category: string - allowed: boolean - updatedAt: Date -} - -export interface PreferenceStore { - /** Defaults to allowed=true when no record exists. */ - allows: (recipient: string, category: string) => MaybePromise - set: (recipient: string, category: string, allowed: boolean) => MaybePromise - list?: (recipient: string) => MaybePromise> -} - -export interface MemoryPreferenceStoreOptions { - now?: () => number - /** Default value when no record exists. Defaults to true (allow). */ - defaultAllowed?: boolean -} - -export function memoryPreferenceStore(opts: MemoryPreferenceStoreOptions = {}): PreferenceStore { - const now = opts.now ?? Date.now - const def = opts.defaultAllowed ?? true - const map = new Map() - const key = (r: string, c: string) => `${r.toLowerCase().trim()}|${c}` - return { - allows(recipient, category) { - const rec = map.get(key(recipient, category)) - return rec ? rec.allowed : def - }, - set(recipient, category, allowed) { - map.set(key(recipient, category), { - recipient, - category, - allowed, - updatedAt: new Date(now()), - }) - }, - list(recipient) { - const prefix = `${recipient.toLowerCase().trim()}|` - return Array.from(map.values()).filter((r) => key(r.recipient, r.category).startsWith(prefix)) - }, - } -} diff --git a/src/queue/bullmq.ts b/src/queue/bullmq.ts deleted file mode 100644 index 51530b8..0000000 --- a/src/queue/bullmq.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * BullMQ queue adapter. Kept structural so we don't force `bullmq` as - * a peer dep — pass any object that exposes the three methods we use. - * - * ```ts - * import { Queue } from "bullmq" - * import { bullmqQueue } from "unemail/queue/bullmq" - * - * const queue = bullmqQueue({ - * bull: new Queue("email", { connection: { host: "redis" } }), - * }) - * ``` - * - * Notes: - * - `scheduledAt` / `delayMs` map to BullMQ's `delay` job option. - * - `pull`/`ack`/`fail` are no-ops here because BullMQ drives its own - * worker loop; use the BullMQ Worker class with `email.send` as the - * processor and skip our `startWorker`. - * - * @module - */ - -import type { EmailMessage } from "../types.ts" -import type { EmailQueue, QueueEnqueueOptions, QueueItem } from "./index.ts" - -export interface BullmqLike { - add: ( - name: string, - data: unknown, - opts?: { delay?: number; jobId?: string }, - ) => Promise<{ id?: string }> - getJobCounts?: () => Promise<{ waiting?: number; delayed?: number; active?: number }> -} - -export interface BullmqQueueOptions { - bull: BullmqLike - name?: string -} - -export function bullmqQueue(options: BullmqQueueOptions): EmailQueue { - const jobName = options.name ?? "email" - return { - name: "bullmq", - async enqueue(msg: EmailMessage, opts: QueueEnqueueOptions = {}) { - const scheduled = msg.scheduledAt ? new Date(msg.scheduledAt).getTime() : 0 - const visible = Math.max(Date.now() + (opts.delayMs ?? 0), scheduled) - const delay = Math.max(0, visible - Date.now()) - const job = await options.bull.add(jobName, msg, { delay, jobId: opts.id }) - return { - id: job.id ?? opts.id ?? `bull_${Date.now().toString(36)}`, - msg, - attempts: 0, - nextAttemptAt: visible, - createdAt: Date.now(), - } - }, - async pull(): Promise { - // BullMQ drives its own loop via `Worker`. Nothing to pull here. - return [] - }, - async ack() {}, - async fail() {}, - async size() { - const counts = await options.bull.getJobCounts?.() - return (counts?.waiting ?? 0) + (counts?.delayed ?? 0) + (counts?.active ?? 0) - }, - } -} - -export default bullmqQueue diff --git a/src/queue/index.ts b/src/queue/index.ts deleted file mode 100644 index b61e5c0..0000000 --- a/src/queue/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { EmailMessage, MaybePromise } from "../types.ts" - -/** A persisted queue record. Producers enqueue; workers pull + process. */ -export interface QueueItem { - id: string - msg: EmailMessage - attempts: number - nextAttemptAt: number - createdAt: number - lastError?: string -} - -export interface QueueEnqueueOptions { - /** Delay (ms) before the item becomes eligible. Default: 0. */ - delayMs?: number - /** Force a specific id (default: random). */ - id?: string -} - -/** Minimal contract a queue driver needs to satisfy. A queue is pluggable - * so users can swap the in-memory default for an unstorage-backed queue - * (Redis, Upstash, FS) or a SaaS worker (QStash, SQS) without rewriting - * their producers. */ -export interface EmailQueue { - readonly name: string - enqueue: (msg: EmailMessage, options?: QueueEnqueueOptions) => MaybePromise - /** Pull up to \`limit\` items whose \`nextAttemptAt\` has passed. Called - * by the built-in worker loop; advanced drivers (SQS long-polling, - * QStash push) can implement their own transport instead. */ - pull: (limit: number, now: number) => MaybePromise - /** Mark an item done (removes it from the queue). */ - ack: (id: string) => MaybePromise - /** Schedule an item for another attempt. The driver decides whether to - * park, retry, or move to dead-letter based on \`attempts\`. */ - fail: (id: string, error: Error, nextAttemptAt: number) => MaybePromise - /** Current queue size — useful in tests and metrics. */ - size: () => MaybePromise -} - -/** Options for the built-in worker loop. */ -export interface WorkerOptions { - concurrency?: number - pollIntervalMs?: number - maxAttempts?: number - backoff?: (attempt: number) => number - onError?: (item: QueueItem, error: Error) => void - /** Injected for tests. */ - now?: () => number -} diff --git a/src/queue/memory.ts b/src/queue/memory.ts deleted file mode 100644 index 5416036..0000000 --- a/src/queue/memory.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { EmailMessage } from "../types.ts" -import type { EmailQueue, QueueEnqueueOptions, QueueItem } from "./index.ts" - -export interface MemoryQueueOptions { - /** Maximum items allowed in the queue. Defaults to \`Infinity\`. */ - maxSize?: number - /** Clock source — injected for tests. Default: \`Date.now\`. */ - now?: () => number -} - -/** Simple in-memory queue. Fine for single-instance servers and tests. - * For multi-process deployments use the unstorage adapter or plug a - * SaaS driver (QStash, SQS). */ -export function memoryQueue(options: MemoryQueueOptions = {}): EmailQueue { - const maxSize = options.maxSize ?? Number.POSITIVE_INFINITY - const now = options.now ?? Date.now - const items: QueueItem[] = [] - let counter = 0 - - return { - name: "memory", - enqueue(msg: EmailMessage, opts: QueueEnqueueOptions = {}) { - if (items.length >= maxSize) - throw new Error(`[unemail/queue/memory] max size ${maxSize} reached`) - const stamp = now() - const scheduled = msg.scheduledAt ? new Date(msg.scheduledAt).getTime() : 0 - const visible = Math.max(stamp + (opts.delayMs ?? 0), scheduled) - const item: QueueItem = { - id: opts.id ?? `mq_${++counter}_${stamp.toString(36)}`, - msg, - attempts: 0, - nextAttemptAt: visible, - createdAt: stamp, - } - items.push(item) - return item - }, - pull(limit: number, now: number) { - const eligible: QueueItem[] = [] - for (const item of items) { - if (item.nextAttemptAt <= now) eligible.push(item) - if (eligible.length >= limit) break - } - return eligible - }, - ack(id: string) { - const idx = items.findIndex((i) => i.id === id) - if (idx >= 0) items.splice(idx, 1) - }, - fail(id: string, error: Error, nextAttemptAt: number) { - const item = items.find((i) => i.id === id) - if (!item) return - item.attempts++ - item.nextAttemptAt = nextAttemptAt - item.lastError = error.message - }, - size() { - return items.length - }, - } -} - -export default memoryQueue diff --git a/src/queue/pg-boss.ts b/src/queue/pg-boss.ts deleted file mode 100644 index f692121..0000000 --- a/src/queue/pg-boss.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * pg-boss queue adapter. Uses pg-boss's native delay + attempt - * semantics. Structural so we don't force the peer dep. - * - * @module - */ - -import type { EmailMessage } from "../types.ts" -import type { EmailQueue, QueueEnqueueOptions, QueueItem } from "./index.ts" - -export interface PgBossLike { - send: ( - name: string, - data: unknown, - opts?: { startAfter?: Date; singletonKey?: string }, - ) => Promise - fetch?: (name: string, limit?: number) => Promise | null> - complete?: (id: string) => Promise - fail?: (id: string, error?: unknown) => Promise - getQueueSize?: (name: string) => Promise -} - -export interface PgBossQueueOptions { - boss: PgBossLike - name?: string -} - -export function pgBossQueue(options: PgBossQueueOptions): EmailQueue { - const name = options.name ?? "email" - return { - name: "pg-boss", - async enqueue(msg: EmailMessage, opts: QueueEnqueueOptions = {}) { - const scheduled = msg.scheduledAt ? new Date(msg.scheduledAt) : null - const delayed = opts.delayMs ? new Date(Date.now() + opts.delayMs) : null - const startAfter = - scheduled && delayed - ? scheduled > delayed - ? scheduled - : delayed - : (scheduled ?? delayed ?? undefined) - const id = await options.boss.send(name, msg, { - startAfter, - singletonKey: opts.id, - }) - return { - id: id ?? opts.id ?? `pgb_${Date.now().toString(36)}`, - msg, - attempts: 0, - nextAttemptAt: startAfter ? startAfter.getTime() : Date.now(), - createdAt: Date.now(), - } - }, - async pull(limit = 10) { - const rows = await options.boss.fetch?.(name, limit) - if (!rows) return [] - return rows.map( - (r): QueueItem => ({ - id: r.id, - msg: r.data as EmailMessage, - attempts: 0, - nextAttemptAt: Date.now(), - createdAt: Date.now(), - }), - ) - }, - async ack(id: string) { - await options.boss.complete?.(id) - }, - async fail(id: string, err: Error) { - await options.boss.fail?.(id, err.message) - }, - async size() { - return (await options.boss.getQueueSize?.(name)) ?? 0 - }, - } -} - -export default pgBossQueue diff --git a/src/queue/sqs.ts b/src/queue/sqs.ts deleted file mode 100644 index ac359e1..0000000 --- a/src/queue/sqs.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * AWS SQS queue adapter. Structural — pass any SQS-compatible client - * with `SendMessage` / `ReceiveMessage` / `DeleteMessage` commands. - * - * @module - */ - -import type { EmailMessage } from "../types.ts" -import type { EmailQueue, QueueEnqueueOptions, QueueItem } from "./index.ts" - -export interface SqsLike { - sendMessage: (input: { - QueueUrl: string - MessageBody: string - DelaySeconds?: number - MessageDeduplicationId?: string - }) => Promise<{ MessageId?: string }> - receiveMessage: (input: { - QueueUrl: string - MaxNumberOfMessages?: number - WaitTimeSeconds?: number - }) => Promise<{ Messages?: Array<{ MessageId?: string; ReceiptHandle?: string; Body?: string }> }> - deleteMessage: (input: { QueueUrl: string; ReceiptHandle: string }) => Promise -} - -export interface SqsQueueOptions { - sqs: SqsLike - queueUrl: string -} - -export function sqsQueue(options: SqsQueueOptions): EmailQueue { - const receipts = new Map() - return { - name: "sqs", - async enqueue(msg: EmailMessage, opts: QueueEnqueueOptions = {}) { - const scheduled = msg.scheduledAt ? new Date(msg.scheduledAt).getTime() : 0 - const visible = Math.max(Date.now() + (opts.delayMs ?? 0), scheduled) - const delaySeconds = Math.min(900, Math.max(0, Math.floor((visible - Date.now()) / 1000))) - const res = await options.sqs.sendMessage({ - QueueUrl: options.queueUrl, - MessageBody: JSON.stringify(msg), - DelaySeconds: delaySeconds, - MessageDeduplicationId: opts.id, - }) - return { - id: res.MessageId ?? opts.id ?? `sqs_${Date.now().toString(36)}`, - msg, - attempts: 0, - nextAttemptAt: visible, - createdAt: Date.now(), - } - }, - async pull(limit = 10) { - const out = await options.sqs.receiveMessage({ - QueueUrl: options.queueUrl, - MaxNumberOfMessages: Math.min(10, limit), - WaitTimeSeconds: 0, - }) - const items: QueueItem[] = [] - for (const m of out.Messages ?? []) { - if (!m.MessageId || !m.ReceiptHandle || !m.Body) continue - receipts.set(m.MessageId, m.ReceiptHandle) - items.push({ - id: m.MessageId, - msg: JSON.parse(m.Body) as EmailMessage, - attempts: 0, - nextAttemptAt: Date.now(), - createdAt: Date.now(), - }) - } - return items - }, - async ack(id: string) { - const handle = receipts.get(id) - if (!handle) return - await options.sqs.deleteMessage({ QueueUrl: options.queueUrl, ReceiptHandle: handle }) - receipts.delete(id) - }, - async fail() { - // SQS re-delivers messages whose ReceiptHandle expires — we - // simply drop the handle so the message returns to the queue - // after its visibility timeout. - }, - async size() { - return -1 // SQS doesn't expose size cheaply; use CloudWatch. - }, - } -} - -export default sqsQueue diff --git a/src/queue/unstorage.ts b/src/queue/unstorage.ts deleted file mode 100644 index bf37890..0000000 --- a/src/queue/unstorage.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { EmailMessage } from "../types.ts" -import type { EmailQueue, QueueEnqueueOptions, QueueItem } from "./index.ts" - -/** A minimal subset of the unstorage \`Storage\` interface — we only need - * these four methods, so we don't force a peer dep. Users pass any - * unstorage instance (redis, upstash, fs, mongodb, etc.). */ -export interface UnstorageLike { - getItem: (key: string) => Promise - setItem: (key: string, value: unknown) => Promise - removeItem: (key: string) => Promise - getKeys: (base?: string) => Promise -} - -export interface UnstorageQueueOptions { - storage: UnstorageLike - /** Key prefix. Default: \`"unemail:queue:"\`. */ - prefix?: string -} - -/** Queue backed by any unstorage driver — turn the in-memory queue into a - * durable one by swapping this in. Each item lives under - * \`\${prefix}\${id}\`. */ -export function unstorageQueue(options: UnstorageQueueOptions): EmailQueue { - const prefix = options.prefix ?? "unemail:queue:" - const key = (id: string) => `${prefix}${id}` - let counter = 0 - - return { - name: "unstorage", - async enqueue(msg: EmailMessage, opts: QueueEnqueueOptions = {}) { - const stamp = Date.now() - const scheduled = msg.scheduledAt ? new Date(msg.scheduledAt).getTime() : 0 - const visible = Math.max(stamp + (opts.delayMs ?? 0), scheduled) - const item: QueueItem = { - id: opts.id ?? `uq_${++counter}_${stamp.toString(36)}`, - msg, - attempts: 0, - nextAttemptAt: visible, - createdAt: stamp, - } - await options.storage.setItem(key(item.id), item) - return item - }, - async pull(limit: number, now: number) { - const keys = await options.storage.getKeys(prefix) - const out: QueueItem[] = [] - for (const k of keys) { - if (out.length >= limit) break - const raw = (await options.storage.getItem(k)) as QueueItem | null - if (!raw) continue - if (raw.nextAttemptAt <= now) out.push(raw) - } - return out - }, - async ack(id: string) { - await options.storage.removeItem(key(id)) - }, - async fail(id: string, error: Error, nextAttemptAt: number) { - const item = (await options.storage.getItem(key(id))) as QueueItem | null - if (!item) return - item.attempts++ - item.nextAttemptAt = nextAttemptAt - item.lastError = error.message - await options.storage.setItem(key(id), item) - }, - async size() { - const keys = await options.storage.getKeys(prefix) - return keys.length - }, - } -} - -export default unstorageQueue diff --git a/src/queue/worker.ts b/src/queue/worker.ts deleted file mode 100644 index 9a61a83..0000000 --- a/src/queue/worker.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Email } from "../email.ts" -import type { EmailQueue, QueueItem, WorkerOptions } from "./index.ts" - -/** A running worker. Call \`stop()\` to halt the loop; any in-flight sends - * finish first. \`waitForIdle()\` resolves when the queue is empty and no - * send is in flight. */ -export interface QueueWorker { - start: () => void - stop: () => Promise - waitForIdle: () => Promise - /** Run a single tick manually — useful in tests. */ - tick: () => Promise -} - -/** Build a worker that drains \`queue\` by sending each item through - * \`email\`. Keep the loop simple: pull → send → ack/fail. Advanced - * drivers can skip this worker and drive \`email.send\` directly from - * their own transport. */ -export function startWorker( - email: Email, - queue: EmailQueue, - options: WorkerOptions = {}, -): QueueWorker { - const concurrency = options.concurrency ?? 1 - const pollIntervalMs = options.pollIntervalMs ?? 250 - const maxAttempts = options.maxAttempts ?? 5 - const backoff = options.backoff ?? ((attempt) => Math.min(30_000, 500 * 2 ** attempt)) - const now = options.now ?? Date.now - - let running = false - let inFlight = 0 - let pollTimer: ReturnType | null = null - const idleWaiters: Array<() => void> = [] - - async function processItem(item: QueueItem): Promise { - inFlight++ - try { - const result = await email.send(item.msg) - if (result.error) throw result.error - await queue.ack(item.id) - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)) - options.onError?.(item, error) - if (item.attempts + 1 >= maxAttempts) { - // Exhausted — ack to remove from the queue. Dead-letter handling - // is driver-specific; memory queue just drops. - await queue.ack(item.id) - } else { - await queue.fail(item.id, error, now() + backoff(item.attempts)) - } - } finally { - inFlight-- - if (inFlight === 0) drainIdleWaiters() - } - } - - async function tickInternal(): Promise { - const slots = Math.max(0, concurrency - inFlight) - if (slots === 0) return - const items = await queue.pull(slots, now()) - await Promise.all(items.map((i) => processItem(i))) - } - - function schedule(): void { - if (!running) return - pollTimer = setTimeout(async () => { - await tickInternal() - schedule() - }, pollIntervalMs) - } - - function drainIdleWaiters(): void { - while (idleWaiters.length > 0) idleWaiters.shift()!() - } - - return { - start() { - if (running) return - running = true - schedule() - }, - async stop() { - running = false - if (pollTimer) clearTimeout(pollTimer) - pollTimer = null - if (inFlight > 0) await new Promise((r) => idleWaiters.push(r)) - }, - async waitForIdle() { - if (inFlight === 0 && (await queue.size()) === 0) return - await new Promise((r) => idleWaiters.push(r)) - }, - tick: tickInternal, - } -} diff --git a/src/render/_middleware.ts b/src/render/_middleware.ts deleted file mode 100644 index 9404eac..0000000 --- a/src/render/_middleware.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { EmailMessage, Middleware } from "../types.ts" -import { htmlToText } from "./html.ts" - -/** Renderer contract. Each adapter (`react`, `jsx-email`, `mjml`) ships a - * `Renderer` that knows which message field it owns and how to turn it - * into HTML. Users register renderers via `withRender(...renderers)`. */ -export interface Renderer { - readonly name: string - /** Return `true` if this renderer can handle the message (e.g. `msg.react` - * is non-nullish). */ - match: (msg: EmailMessage) => boolean - /** Render the relevant field to HTML. May be async (React's renderAsync). */ - render: (msg: EmailMessage) => Promise | string -} - -export interface WithRenderOptions { - /** Auto-derive `msg.text` from the rendered HTML when `text` is missing. - * Default: true. */ - autoText?: boolean -} - -/** Middleware that resolves `msg.react`, `msg.jsx`, or `msg.mjml` into - * `msg.html` before the driver sees the message. Registered once per - * `createEmail` instance: - * - * ```ts - * import reactRenderer from "unemail/render/react" - * - * email.use(withRender(reactRenderer())) - * ``` - */ -export function withRender(...renderers: Renderer[]): Middleware & { options: WithRenderOptions } { - const options: WithRenderOptions = { autoText: true } - return { - name: "render", - options, - async beforeSend(msg) { - for (const renderer of renderers) { - if (!renderer.match(msg)) continue - const html = await renderer.render(msg) - // `msg` is treated as mutable here: the middleware contract allows - // mutating the message before the driver reads it. - ;(msg as { html?: string }).html = html - if (!msg.text && options.autoText) { - ;(msg as { text?: string }).text = htmlToText(html) - } - return - } - }, - } -} diff --git a/src/render/define-template.ts b/src/render/define-template.ts deleted file mode 100644 index d5a63c7..0000000 --- a/src/render/define-template.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { EmailMessage } from "../types.ts" - -/** A compiled template — a function that takes typed variables and - * returns a partial `EmailMessage` ready to splat into `email.send()`. */ -export type TemplateFn> = (vars: Vars) => Output - -/** Declare a template with compile-time-checked variables. - * - * ```ts - * const welcome = defineTemplate<{ name: string }>(({ name }) => ({ - * subject: `Welcome, ${name}!`, - * react: , - * })) - * - * await email.send({ from, to, ...welcome({ name: "Ada" }) }) - * ``` - * - * Pass `render` as a function that produces whichever shape you want - * (`{ react }`, `{ jsx }`, `{ mjml }`, or direct `{ html }`) — all of - * them land as a typed `Partial`. - */ -export function defineTemplate( - render: (vars: Vars) => Partial, -): TemplateFn> { - return render -} diff --git a/src/render/handlebars.ts b/src/render/handlebars.ts deleted file mode 100644 index 4e06f4f..0000000 --- a/src/render/handlebars.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Handlebars renderer — lazy-loads the `handlebars` peer dep so the - * core bundle stays clean. - * - * ```ts - * import { handlebarsRenderer } from "unemail/render/handlebars" - * email.use(withRender(handlebarsRenderer())) - * - * await email.send({ - * from, to, subject, - * handlebars: "Hello {{name}}", - * handlebarsVars: { name: "Ada" }, - * }) - * ``` - * - * @module - */ - -import type { Renderer } from "./_middleware.ts" - -export interface HandlebarsRendererOptions { - helpers?: Record unknown> - partials?: Record -} - -export function handlebarsRenderer(options: HandlebarsRendererOptions = {}): Renderer { - const cache = new Map string>() - return { - name: "handlebars", - match: (msg) => Boolean((msg as { handlebars?: string }).handlebars), - async render(msg) { - const source = (msg as { handlebars?: string }).handlebars! - const Handlebars = await loadHandlebars() - if (options.helpers) { - for (const [n, fn] of Object.entries(options.helpers)) Handlebars.registerHelper(n, fn) - } - if (options.partials) { - for (const [n, p] of Object.entries(options.partials)) Handlebars.registerPartial(n, p) - } - let fn = cache.get(source) - if (!fn) { - fn = Handlebars.compile(source) - cache.set(source, fn) - } - const vars = (msg as { handlebarsVars?: Record }).handlebarsVars ?? {} - return fn(vars) - }, - } -} - -interface HandlebarsLike { - compile: (source: string) => (ctx: unknown) => string - registerHelper: (name: string, fn: (...args: unknown[]) => unknown) => void - registerPartial: (name: string, source: string) => void -} - -const dynamicImport: (specifier: string) => Promise = new Function( - "s", - "return import(s)", -) as (s: string) => Promise - -async function loadHandlebars(): Promise { - const mod = (await dynamicImport("handlebars").catch(() => null)) as - | { default?: HandlebarsLike } - | HandlebarsLike - | null - if (!mod) throw new Error("[unemail/render/handlebars] install `handlebars` as a peer dep") - return ( - "default" in mod ? (mod.default as HandlebarsLike) : (mod as HandlebarsLike) - ) as HandlebarsLike -} diff --git a/src/render/html.ts b/src/render/html.ts index 29b3e01..a0549af 100644 --- a/src/render/html.ts +++ b/src/render/html.ts @@ -1,10 +1,13 @@ -/** Lightweight HTML → plain-text fallback for the text alternative of an - * HTML email. Not a full DOM parser — handles the patterns email clients - * actually care about (line breaks, block tags, links, entities). +/** + * HTML → plain text, for the `text/plain` alternative every HTML email + * should carry. Not a DOM parser: it handles the constructs that matter in + * a mail body and nothing else, which is what keeps it dependency-free and + * usable inside a Worker. * - * Intentionally zero-dep: text fallback is nice-to-have and keeps the - * render entries Workers-parseable. If you need perfect fidelity, set - * `text` explicitly on the message. */ + * Set `text` yourself when the fidelity matters. + * + * @module + */ const BLOCK_TAGS = new Set([ "p", @@ -33,46 +36,43 @@ const BLOCK_TAGS = new Set([ "hr", ]) -/** Convert an HTML string into a reasonable plain-text equivalent. */ export function htmlToText(html: string): string { - // Strip scripts + styles entirely (case-insensitive). let out = html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, "") - - //
→ newline. out = out.replace(//gi, "\n") - // inner → "inner (href)" if link text ≠ href. - out = out.replace(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (_, href, inner) => { - const stripped = stripTags(inner).trim() - return stripped && stripped !== href ? `${stripped} (${href})` : href - }) + // Keep the destination of a link — a plain-text reader that cannot see + // the anchor still needs somewhere to go. + out = out.replace( + /]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, + (_, href: string, inner: string) => { + const label = inner.replace(/<[^>]+>/g, "").trim() + return label && label !== href ? `${label} (${href})` : href + }, + ) - // Block tags → wrap with newlines. - out = out.replace(/<\/?([a-z0-9]+)[^>]*>/gi, (match, tag: string) => { - const name = tag.toLowerCase() - if (BLOCK_TAGS.has(name)) return "\n" - return "" - }) - - out = decodeEntities(out) - out = out.replace(/[ \t]+\n/g, "\n") - out = out.replace(/\n{3,}/g, "\n\n") - return out.trim() -} + out = out.replace(/<\/?([a-z0-9]+)[^>]*>/gi, (_, tag: string) => + BLOCK_TAGS.has(tag.toLowerCase()) ? "\n" : "", + ) -function stripTags(value: string): string { - return value.replace(/<[^>]+>/g, "") + return decodeEntities(out) + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim() } function decodeEntities(value: string): string { - return value - .replace(/ /g, " ") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&(?:apos);/g, "'") - .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code))) - .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16))) + return ( + value + .replace(/ /g, " ") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/&(?:#39|apos);/g, "'") + .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => + String.fromCodePoint(Number.parseInt(hex, 16)), + ) + // Last, so an escaped `&lt;` does not decode twice into `<`. + .replace(/&/g, "&") + ) } diff --git a/src/render/i18n.ts b/src/render/i18n.ts deleted file mode 100644 index c3578eb..0000000 --- a/src/render/i18n.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Locale-aware renderer wrapper. Picks one of the registered - * sub-renderers based on `msg.locale` and falls through to the - * default. Combine with Handlebars / Liquid / React for a simple - * i18n-first pipeline. - * - * ```ts - * email.use(withRender(i18nRenderer({ - * fallback: handlebarsRenderer(), - * byLocale: { tr: handlebarsRenderer({ ... }), en: handlebarsRenderer({ ... }) }, - * }))) - * ``` - * - * @module - */ - -import type { EmailMessage } from "../types.ts" -import type { Renderer } from "./_middleware.ts" - -export interface I18nRendererOptions { - byLocale: Record - fallback: Renderer - /** Override how the locale is resolved. Defaults to - * `msg.locale || msg.template?.locale || msg.headers?.["accept-language"]`. */ - resolveLocale?: (msg: EmailMessage) => string | undefined -} - -export function i18nRenderer(options: I18nRendererOptions): Renderer { - const resolve = - options.resolveLocale ?? - ((msg: EmailMessage) => { - const anyMsg = msg as { - locale?: string - template?: { locale?: string } - headers?: Record - } - return ( - anyMsg.locale ?? - anyMsg.template?.locale ?? - anyMsg.headers?.["accept-language"]?.split(",")[0] - ) - }) - return { - name: "i18n", - match: (msg) => pick(msg, options, resolve).match(msg), - render: (msg) => pick(msg, options, resolve).render(msg), - } -} - -function pick( - msg: EmailMessage, - options: I18nRendererOptions, - resolve: (msg: EmailMessage) => string | undefined, -): Renderer { - const loc = resolve(msg) - if (loc && options.byLocale[loc]) return options.byLocale[loc] - if (loc) { - const lang = loc.split("-")[0] - if (lang && options.byLocale[lang]) return options.byLocale[lang] - } - return options.fallback -} diff --git a/src/render/index.ts b/src/render/index.ts index 6a9d5e4..28d0d91 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -1,3 +1,112 @@ -export { defineTemplate, type TemplateFn } from "./define-template.ts" +import type { EmailMessage, MessageContent, Middleware, NormalizedMessage } from "../core/types.ts" +import { defineMiddleware } from "../core/define.ts" +import { createError } from "../core/error.ts" +import { patchMessage } from "../core/message.ts" +import { htmlToText } from "./html.ts" + export { htmlToText } from "./html.ts" -export { withRender, type Renderer, type WithRenderOptions } from "./_middleware.ts" + +/** What a renderer produces. Returning only `html` is normal; `text` is + * derived for you unless you supply a better one. */ +export interface RenderOutput { + html: string + text?: string +} + +/** + * Turns a `content` block into HTML. A renderer claims a message by + * matching `content.type`, so adding React, MJML, or your own template + * language is a package — the core never learns about it. + * + * ```ts + * const markdown: Renderer = { + * name: "markdown", + * type: "markdown", + * render: (content) => ({ html: toHtml(content.source as string) }), + * } + * ``` + */ +export interface Renderer { + readonly name: string + /** The `content.type` this renderer handles. */ + readonly type: string + readonly render: ( + content: MessageContent, + msg: NormalizedMessage, + ) => RenderOutput | Promise +} + +export interface RenderOptions { + /** Derive `text` from the rendered HTML when the renderer and the + * message both leave it unset. Default: true — an HTML-only message + * scores worse with spam filters and is unreadable in a text client. */ + autoText?: boolean +} + +/** + * Resolve `message.content` into `html` before the driver sees it. + * + * The message is replaced, never mutated: a template object stays clean + * and reusable across sends. + * + * ```ts + * email.use(withRender(reactRenderer())) + * await email.send({ to, subject, content: { type: "react", element: } }) + * ``` + */ +export function withRender(...args: [...Renderer[], RenderOptions] | Renderer[]): Middleware { + const last = args.at(-1) + const hasOptions = last != null && !isRenderer(last) + const options = (hasOptions ? last : {}) as RenderOptions + const renderers = (hasOptions ? args.slice(0, -1) : args) as Renderer[] + const autoText = options.autoText ?? true + const byType = new Map(renderers.map((renderer) => [renderer.type, renderer])) + + return defineMiddleware("render", (next) => async (msgs, ctx) => { + const rendered = await Promise.all( + msgs.map(async (msg) => { + if (!msg.content) return msg + const renderer = byType.get(msg.content.type) + if (!renderer) { + throw createError( + ctx.driver, + "INVALID_OPTIONS", + `no renderer registered for content type ${JSON.stringify(msg.content.type)}`, + ) + } + const output = await renderer.render(msg.content, msg) + const text = msg.text ?? output.text ?? (autoText ? htmlToText(output.html) : undefined) + return patchMessage(msg, { + html: output.html, + ...(text == null ? {} : { text }), + content: undefined, + }) + }), + ) + return next(rendered, ctx) + }) +} + +/** + * Declare a reusable message with typed variables. Returns a partial + * message to spread into `send()`, so the call site stays one line and the + * variables are checked at compile time. + * + * ```ts + * const welcome = defineTemplate<{ name: string }>(({ name }) => ({ + * subject: `Welcome, ${name}`, + * content: { type: "react", element: }, + * })) + * + * await email.send({ to, ...welcome({ name: "Ada" }) }) + * ``` + */ +export function defineTemplate( + build: (vars: Vars) => Partial, +): (vars: Vars) => Partial { + return build +} + +function isRenderer(value: Renderer | RenderOptions): value is Renderer { + return typeof (value as Renderer).render === "function" +} diff --git a/src/render/jsx-email.ts b/src/render/jsx-email.ts deleted file mode 100644 index be58cf2..0000000 --- a/src/render/jsx-email.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { EmailMessage } from "../types.ts" -import type { Renderer } from "./_middleware.ts" - -/** jsx-email adapter. Accepts a `jsx:` prop on `email.send()`. - * - * Uses the optional peer `jsx-email`. Loaded lazily so zero-dep users - * don't pay for it. */ -export interface JsxEmailRenderOptions { - /** Override the renderer for tests. */ - render?: (element: unknown) => Promise | string - /** Inline CSS in the output. Default: true. */ - inlineCss?: boolean -} - -export function jsxEmailRenderer(options: JsxEmailRenderOptions = {}): Renderer { - let cached: ((element: unknown) => Promise) | null = null - const resolveRender = async () => { - if (cached) return cached - if (options.render) { - const user = options.render - cached = async (el) => user(el) - return cached - } - try { - const mod = await import("jsx-email" as string) - const render = mod.render as (el: unknown, opts?: unknown) => Promise - if (!render) throw new Error("jsx-email has no `render` export") - cached = async (el) => render(el, { inlineCss: options.inlineCss ?? true }) - return cached - } catch (err) { - throw new Error( - "[unemail/render/jsx-email] requires `jsx-email` as a peer dependency. " + - `Install it or pass \`render\` via options. Original error: ${(err as Error).message}`, - ) - } - } - - return { - name: "jsx-email", - match: (msg: EmailMessage) => msg.jsx != null, - async render(msg) { - const r = await resolveRender() - return r(msg.jsx) - }, - } -} - -export default jsxEmailRenderer diff --git a/src/render/liquid.ts b/src/render/liquid.ts deleted file mode 100644 index b934f6e..0000000 --- a/src/render/liquid.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * LiquidJS renderer — lazy-loads the `liquidjs` peer. - * - * ```ts - * import { liquidRenderer } from "unemail/render/liquid" - * email.use(withRender(liquidRenderer())) - * - * await email.send({ - * ..., liquid: "Hello {{ name }}", liquidVars: { name: "Ada" }, - * }) - * ``` - * - * @module - */ - -import type { Renderer } from "./_middleware.ts" - -export interface LiquidRendererOptions { - /** LiquidJS engine options passed through to `new Liquid(options)`. */ - engineOptions?: Record -} - -interface LiquidLike { - parseAndRender: (tpl: string, ctx?: Record) => Promise -} - -interface LiquidCtor { - new (options?: Record): LiquidLike -} - -const dynamicImport: (specifier: string) => Promise = new Function( - "s", - "return import(s)", -) as (s: string) => Promise - -export function liquidRenderer(options: LiquidRendererOptions = {}): Renderer { - let engine: LiquidLike | null = null - return { - name: "liquid", - match: (msg) => Boolean((msg as { liquid?: string }).liquid), - async render(msg) { - if (!engine) { - const mod = (await dynamicImport("liquidjs").catch(() => null)) as { - Liquid?: LiquidCtor - } | null - if (!mod?.Liquid) - throw new Error("[unemail/render/liquid] install `liquidjs` as a peer dep") - engine = new mod.Liquid(options.engineOptions) - } - const source = (msg as { liquid?: string }).liquid! - const vars = (msg as { liquidVars?: Record }).liquidVars ?? {} - return engine.parseAndRender(source, vars) - }, - } -} diff --git a/src/render/mjml.ts b/src/render/mjml.ts deleted file mode 100644 index 26c87cb..0000000 --- a/src/render/mjml.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { EmailMessage } from "../types.ts" -import type { Renderer } from "./_middleware.ts" - -/** MJML adapter. Accepts a `mjml:` string on `email.send()`. - * - * Uses the optional peer `mjml` (or `mjml-browser` in the browser). - * Compiled output goes into `msg.html`. */ -export interface MjmlRenderOptions { - /** Override the compiler for tests. */ - compile?: (source: string) => Promise | string - /** Validation level forwarded to mjml. Default: "soft". */ - validationLevel?: "strict" | "soft" | "skip" -} - -export function mjmlRenderer(options: MjmlRenderOptions = {}): Renderer { - let cached: ((source: string) => Promise) | null = null - const resolveCompile = async () => { - if (cached) return cached - if (options.compile) { - const user = options.compile - cached = async (s) => user(s) - return cached - } - try { - const mod: unknown = await import("mjml" as string) - const fn = (typeof mod === "function" ? mod : (mod as { default?: unknown }).default) as - | ((src: string, opts?: unknown) => { html: string; errors?: unknown[] }) - | undefined - if (typeof fn !== "function") throw new Error("mjml is not a function") - cached = async (src) => { - const result = fn(src, { validationLevel: options.validationLevel ?? "soft" }) - return result.html - } - return cached - } catch (err) { - throw new Error( - "[unemail/render/mjml] requires `mjml` as a peer dependency. " + - `Install it or pass \`compile\` via options. Original error: ${(err as Error).message}`, - ) - } - } - - return { - name: "mjml", - match: (msg: EmailMessage) => typeof msg.mjml === "string" && msg.mjml.length > 0, - async render(msg) { - const compile = await resolveCompile() - return compile(msg.mjml as string) - }, - } -} - -export default mjmlRenderer diff --git a/src/render/pipeline.ts b/src/render/pipeline.ts deleted file mode 100644 index b66f319..0000000 --- a/src/render/pipeline.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Composable HTML transforms applied after rendering. Use via - * middleware so the pipeline runs just before `driver.send`: - * - * ```ts - * email.use(htmlPipeline(withPreheader(), inlineCss(), cidRewrite())) - * ``` - * - * Each transform is a pure function over the rendered HTML string. - * - * @module - */ - -import type { EmailMessage, Middleware } from "../types.ts" - -export type HtmlTransform = (html: string, msg: EmailMessage) => string | Promise - -/** Pipe `EmailMessage.html` through each transform in order. */ -export function htmlPipeline(...transforms: HtmlTransform[]): Middleware { - return { - name: "html-pipeline", - async beforeSend(msg) { - if (!msg.html) return - let html = msg.html - for (const t of transforms) html = await t(html, msg) - ;(msg as { html?: string }).html = html - }, - } -} - -/** Inject a hidden preheader snippet (Litmus-approved) before the - * visible content. Uses `msg.preheader` when present, or falls back - * to the supplied function. */ -export interface PreheaderOptions { - /** Explicit text to use. Overrides `msg.preheader`. */ - text?: string | ((msg: EmailMessage) => string | undefined) -} - -export function withPreheader(options: PreheaderOptions = {}): HtmlTransform { - return (html, msg) => { - const record = msg as { preheader?: string } - const resolved = - typeof options.text === "function" ? options.text(msg) : (options.text ?? record.preheader) - if (!resolved) return html - const hidden = - `
` + - escapeHtml(resolved) + - "\u00A0\u200C".repeat(60) + - `
` - // Insert immediately after the opening (or at top). - return /]*>/i.test(html) - ? html.replace(/]*)>/i, (match) => `${match}${hidden}`) - : `${hidden}${html}` - } -} - -/** Dark-mode CSS hook — injects Outlook.com / Apple Mail dark-mode - * meta tags + a `[data-ogsc]` scope. Consumers supply the rules. */ -export interface DarkModeOptions { - /** CSS block applied via `[data-ogsc]` (Outlook.com) and - * `@media (prefers-color-scheme: dark)`. */ - darkCss?: string -} - -export function darkModeHook(options: DarkModeOptions = {}): HtmlTransform { - const head = - `` + - `` + - (options.darkCss - ? `` - : "") - return (html) => { - if (/]*>/i.test(html)) return html.replace(/]*)>/i, (m) => `${m}${head}`) - return `${head}${html}` - } -} - -/** CID auto-rewrite — scan for `` tags whose src matches - * a CID on one of the message attachments and rewrite to `cid:`. */ -export function cidRewrite(): HtmlTransform { - return (html, msg) => { - if (!msg.attachments?.length) return html - const byUrl = new Map() - for (const a of msg.attachments) { - if (a.cid && a.filename) { - byUrl.set(a.filename, a.cid) - } - } - return html.replace(/]*?)src=(["'])([^"']+)\2/gi, (full, attrs, quote, src) => { - const basename = src.split("/").pop() ?? src - const cid = byUrl.get(basename) ?? byUrl.get(src) - if (!cid) return full - return ` { - const mod = (await dynamicImport("juice").catch(() => null)) as { - default?: (html: string) => string - } | null - if (!mod) return html - const fn = mod.default ?? (mod as unknown as (html: string) => string) - return (fn as (html: string) => string)(html) - } -} - -/** Hidden behind a Function constructor so the typechecker doesn't - * require the peer dep to be installed. */ -const dynamicImport: (specifier: string) => Promise = new Function( - "s", - "return import(s)", -) as (s: string) => Promise - -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) -} diff --git a/src/render/react.ts b/src/render/react.ts index 878dde3..e99bf04 100644 --- a/src/render/react.ts +++ b/src/render/react.ts @@ -1,67 +1,74 @@ -import type { EmailMessage } from "../types.ts" -import type { Renderer, WithRenderOptions } from "./_middleware.ts" -import { withRender } from "./_middleware.ts" +import type { MessageContent } from "../core/types.ts" +import type { Renderer } from "./index.ts" +import { createError } from "../core/error.ts" -/** React Email adapter. Accepts a `react:` prop on `email.send()`. - * - * Requires the optional peer `@react-email/render` (or the bundled - * renderer from `react-email`). We resolve it lazily so users who don't - * use React pay nothing — the module is Workers-parseable even without - * the peer installed. */ -export interface ReactRenderOptions { - /** Bring-your-own renderer — useful for testing or custom setups. */ - render?: (element: unknown) => Promise | string - /** Pretty-print the rendered HTML. Forwarded to `@react-email/render`. */ - pretty?: boolean +/** `content` shape this renderer claims. */ +export interface ReactContent extends MessageContent { + type: "react" + /** A React element — typically a `react-email` component. */ + element: unknown } -export function reactRenderer(options: ReactRenderOptions = {}): Renderer { - let cached: ((element: unknown) => Promise) | null = null - const resolveRender = async (): Promise<(element: unknown) => Promise> => { - if (cached) return cached - if (options.render) { - const userRender = options.render - cached = async (el) => userRender(el) - return cached - } - try { - const mod = await import("@react-email/render" as string) - const render = (mod.render ?? mod.default?.render) as - | undefined - | ((el: unknown, opts?: { pretty?: boolean }) => Promise | string) - if (!render) throw new Error("@react-email/render has no `render` export") - cached = async (el) => render(el, { pretty: options.pretty ?? false }) - return cached - } catch (err) { - throw new Error( - "[unemail/render/react] requires `@react-email/render` as a peer dependency. " + - `Install it or pass \`render\` via options. Original error: ${(err as Error).message}`, - ) - } - } - - return { - name: "react", - match: (msg: EmailMessage) => msg.react != null, - async render(msg) { - const r = await resolveRender() - return r(msg.react) - }, - } +export interface ReactRendererOptions { + /** Supply your own renderer instead of loading `@react-email/render`. + * Useful for a pinned version, or for `renderToStaticMarkup`. */ + render?: (element: unknown, options?: { plainText?: boolean }) => Promise | string + /** Also produce the text alternative with `react-email`'s own plain-text + * pass, which reads better than deriving it from the HTML. + * Default: true when `@react-email/render` supplies it. */ + plainText?: boolean } -/** Convenience factory identical in spirit to the other drivers: +/** + * Render `react-email` components. * - * ```ts - * import { withRender } from "unemail" - * import reactRender from "unemail/render/react" + * `@react-email/render` is an optional peer dependency — it is imported on + * first use, so nothing is pulled into a bundle that does not call this. * - * email.use(withRender(reactRender())) - * ``` + * ```ts + * import reactRenderer from "unemail/render/react" * - * Default export mirrors the driver convention for consistency. + * email.use(withRender(reactRenderer())) + * await email.send({ to, subject, content: { type: "react", element: } }) + * ``` */ -export default reactRenderer +export default function reactRenderer(options: ReactRendererOptions = {}): Renderer { + let load: Promise> | null = null -export type { Renderer, WithRenderOptions } -export { withRender } + function resolveRender() { + if (options.render) return Promise.resolve(options.render) + load ??= import("@react-email/render").then( + (mod) => mod.render, + (cause) => { + throw createError( + "render/react", + "INVALID_OPTIONS", + "`@react-email/render` is not installed — add it, or pass `render` explicitly", + { cause }, + ) + }, + ) + return load + } + + return { + name: "react", + type: "react", + async render(content) { + const element = (content as ReactContent).element + if (element == null) { + throw createError("render/react", "INVALID_OPTIONS", "`content.element` is required") + } + const render = await resolveRender() + const html = await render(element) + if (options.plainText === false) return { html } + try { + return { html, text: await render(element, { plainText: true }) } + } catch { + // Older `@react-email/render` has no plainText mode; the render + // middleware derives text from the HTML instead. + return { html } + } + }, + } +} diff --git a/src/result/index.ts b/src/result/index.ts deleted file mode 100644 index 8565acc..0000000 --- a/src/result/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Narrow, dependency-free helpers for the `Result` discriminated - * union. Import when you prefer fluent semantics over discriminating - * `{ data, error }` by hand. - * - * @module - */ - -import type { EmailError, Result } from "../types.ts" - -export function isOk(r: Result): r is { data: T; error: null } { - return r.error === null -} - -export function isErr(r: Result): r is { data: null; error: EmailError } { - return r.error !== null -} - -/** Return `data` or throw the `error`. Intentionally dramatic — only - * use when you're sure the caller wants a throw. */ -export function unwrap(r: Result): T { - if (r.error) throw r.error - return r.data -} - -/** Return `data` if Ok, `fallback` otherwise. */ -export function unwrapOr(r: Result, fallback: T): T { - return r.error ? fallback : r.data -} - -/** Apply `f` to `data` if Ok; pass through the Err unchanged. */ -export function mapOk(r: Result, f: (t: T) => U): Result { - if (r.error) return r as unknown as Result - return { data: f(r.data), error: null } -} - -/** Transform the `error` while preserving Ok. */ -export function mapErr(r: Result, f: (e: EmailError) => EmailError): Result { - if (!r.error) return r - return { data: null, error: f(r.error) } -} - -/** Run `f` and capture any thrown `EmailError` into a `Result`. Any - * non-EmailError exception re-throws. */ -export async function tryAsync( - f: () => Promise, - wrap: (err: unknown) => EmailError, -): Promise> { - try { - return { data: await f(), error: null } - } catch (err) { - return { data: null, error: wrap(err) } - } -} diff --git a/src/suppression/index.ts b/src/suppression/index.ts deleted file mode 100644 index df8689d..0000000 --- a/src/suppression/index.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Suppression store — persistent record of recipients who must not - * receive mail. Unifies bounces, complaints, and opt-outs across every - * driver we ship. - * - * @module - */ - -import type { MaybePromise } from "../types.ts" - -export type SuppressionReason = - | "bounce" - | "complaint" - | "unsubscribed" - | "manual" - | "invalid" - | string - -export interface SuppressionRecord { - recipient: string - reason: SuppressionReason - source?: string - at: Date -} - -export interface SuppressionStore { - has: (recipient: string) => MaybePromise - add: (recipient: string, reason: SuppressionReason, source?: string) => MaybePromise - remove: (recipient: string) => MaybePromise - list?: () => MaybePromise> -} - -/** Options for `memorySuppressionStore`. */ -export interface MemorySuppressionStoreOptions { - /** Injectable clock for deterministic tests. */ - now?: () => number -} - -/** In-memory store. Recipients are normalized to lowercase. */ -export function memorySuppressionStore(opts: MemorySuppressionStoreOptions = {}): SuppressionStore { - const now = opts.now ?? Date.now - const map = new Map() - const key = (r: string) => r.toLowerCase().trim() - return { - has(recipient) { - return map.get(key(recipient)) ?? null - }, - add(recipient, reason, source) { - map.set(key(recipient), { recipient, reason, source, at: new Date(now()) }) - }, - remove(recipient) { - map.delete(key(recipient)) - }, - list() { - return Array.from(map.values()) - }, - } -} - -/** Minimal unstorage-like contract (decoupled so we don't bind the - * dependency). Mirrors the shape already used by `src/queue`. */ -interface UnstorageLike { - getItem: (key: string) => MaybePromise - setItem: (key: string, value: unknown) => MaybePromise - removeItem: (key: string) => MaybePromise - getKeys?: (base?: string) => MaybePromise> -} - -/** Persist suppressions to any `unstorage` driver (KV, Redis, - * filesystem, …). Keys are prefixed with `suppression:`. */ -export function unstorageSuppressionStore(storage: UnstorageLike): SuppressionStore { - const prefix = "suppression:" - const key = (r: string) => prefix + r.toLowerCase().trim() - return { - async has(recipient) { - const value = await storage.getItem(key(recipient)) - if (!value) return null - return deserialize(value) - }, - async add(recipient, reason, source) { - const rec: SuppressionRecord = { recipient, reason, source, at: new Date() } - await storage.setItem(key(recipient), serialize(rec)) - }, - async remove(recipient) { - await storage.removeItem(key(recipient)) - }, - async list() { - if (!storage.getKeys) return [] - const keys = await storage.getKeys(prefix) - const out: SuppressionRecord[] = [] - for (const k of keys) { - const value = await storage.getItem(k) - if (value) out.push(deserialize(value)) - } - return out - }, - } -} - -function serialize(rec: SuppressionRecord): unknown { - return { ...rec, at: rec.at.toISOString() } -} - -function deserialize(value: unknown): SuppressionRecord { - const v = value as { recipient: string; reason: string; source?: string; at: string } - return { recipient: v.recipient, reason: v.reason, source: v.source, at: new Date(v.at) } -} diff --git a/src/test/inbox.ts b/src/test/inbox.ts deleted file mode 100644 index ad336ca..0000000 --- a/src/test/inbox.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Email } from "../email.ts" -import type { EmailMessage } from "../types.ts" -import { createEmail } from "../email.ts" -import mock from "../driver/mock.ts" - -/** An `Email` instance plus an `inbox` that records every message sent - * through it. Use in tests instead of stubbing providers by hand. */ -export interface TestEmail extends Email { - readonly inbox: readonly EmailMessage[] - /** The most recent message, or `undefined` if the inbox is empty. */ - readonly last: EmailMessage | undefined - /** Find the first message matching a predicate. */ - find: (predicate: (msg: EmailMessage) => boolean) => EmailMessage | undefined - /** All messages matching a predicate. */ - filter: (predicate: (msg: EmailMessage) => boolean) => EmailMessage[] - /** Wait up to `timeout` ms for a matching message to arrive. Resolves - * with the message; rejects on timeout. */ - waitFor: ( - predicate: (msg: EmailMessage) => boolean, - options?: { timeout?: number; interval?: number }, - ) => Promise - /** Empty the inbox without disposing the driver. */ - clear: () => void -} - -export interface CreateTestEmailOptions { - /** Pre-populate the inbox (useful for regression fixtures). */ - inbox?: EmailMessage[] - /** Pass through to the underlying mock driver. */ - fail?: boolean -} - -/** Shorthand for tests: `createTestEmail()` returns a working `Email` with - * an `inbox` you can assert against. Exposed under `unemail/test`. */ -export function createTestEmail(options: CreateTestEmailOptions = {}): TestEmail { - const inbox: EmailMessage[] = options.inbox ?? [] - const driver = mock({ inbox, fail: options.fail }) - const email = createEmail({ driver }) - - Object.defineProperties(email, { - inbox: { get: () => inbox, enumerable: true }, - last: { get: () => inbox[inbox.length - 1], enumerable: true }, - find: { - value: (predicate: (msg: EmailMessage) => boolean) => inbox.find(predicate), - }, - filter: { - value: (predicate: (msg: EmailMessage) => boolean) => inbox.filter(predicate), - }, - clear: { - value: () => { - inbox.length = 0 - }, - }, - waitFor: { - value: async ( - predicate: (msg: EmailMessage) => boolean, - waitOpts: { timeout?: number; interval?: number } = {}, - ): Promise => { - const timeout = waitOpts.timeout ?? 2000 - const interval = waitOpts.interval ?? 10 - const deadline = Date.now() + timeout - while (Date.now() <= deadline) { - const match = inbox.find(predicate) - if (match) return match - await new Promise((r) => setTimeout(r, interval)) - } - throw new Error(`[unemail/test] waitFor timed out after ${timeout}ms`) - }, - }, - }) - return email as TestEmail -} diff --git a/src/test/index.ts b/src/test/index.ts deleted file mode 100644 index 917059e..0000000 --- a/src/test/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createTestEmail, type CreateTestEmailOptions, type TestEmail } from "./inbox.ts" -export { emailMatchers, matchesEmail, toEmailSnapshot, type EmailMatch } from "./matchers.ts" diff --git a/src/test/matchers.ts b/src/test/matchers.ts deleted file mode 100644 index d37bed1..0000000 --- a/src/test/matchers.ts +++ /dev/null @@ -1,245 +0,0 @@ -import type { EmailMessage } from "../types.ts" -import { normalizeAddresses } from "../_normalize.ts" - -/** A partial message shape used in assertions. Each field is loose: - * strings and RegExps are both accepted for `subject` / `text` / `html`, - * and `to`/`from`/`cc`/`bcc` match any supplied address. */ -export interface EmailMatch { - from?: string | RegExp - to?: string | RegExp - cc?: string | RegExp - bcc?: string | RegExp - subject?: string | RegExp - text?: string | RegExp - html?: string | RegExp - stream?: string -} - -/** Check whether `actual` satisfies all fields declared in `expected`. */ -export function matchesEmail( - actual: EmailMessage, - expected: EmailMatch, -): { pass: boolean; diff: string | null } { - for (const [key, value] of Object.entries(expected)) { - if (value == null) continue - const got = pickField(actual, key as keyof EmailMatch) - if (!fieldMatches(got, value)) { - return { - pass: false, - diff: `expected ${key}=${formatExpected(value)} but got ${JSON.stringify(got)}`, - } - } - } - return { pass: true, diff: null } -} - -function pickField(msg: EmailMessage, key: keyof EmailMatch): string | string[] | undefined { - if (key === "from" || key === "to" || key === "cc" || key === "bcc") { - const value = msg[key] as EmailMessage["to"] | undefined - return normalizeAddresses(value).map((a) => a.email) - } - const value = (msg as unknown as Record)[key] - return typeof value === "string" ? value : undefined -} - -function fieldMatches(got: string | string[] | undefined, expected: string | RegExp): boolean { - if (got == null) return false - const values = Array.isArray(got) ? got : [got] - return values.some((v) => (expected instanceof RegExp ? expected.test(v) : v === expected)) -} - -function formatExpected(value: unknown): string { - if (value instanceof RegExp) return value.toString() - return JSON.stringify(value) -} - -function inboxOf(received: unknown): readonly EmailMessage[] | null { - const v = received as { inbox?: unknown } | null | undefined - if (v && Array.isArray(v.inbox)) return v.inbox as readonly EmailMessage[] - return null -} - -/** Vitest-compatible matchers: register from a test setup file. - * - * ```ts - * import { expect } from "vitest" - * import { emailMatchers } from "unemail/test" - * expect.extend(emailMatchers) - * ``` - * - * Adds: - * - `toHaveSent(match)` — any sent email matches the partial. - * - `toHaveSentTo(address)` — any sent email contains this recipient. - * - `toHaveSentWithSubject(pattern)` — subject matches exact or regex. - * - `toHaveSentWithAttachment(filename | predicate)`. - * - `toHaveSentMatching(predicate)` — fully custom. - */ -export type MatcherResult = { pass: boolean; message: () => string } -export type AttachmentPredicate = (a: NonNullable[number]) => boolean -type HasInbox = { inbox: readonly EmailMessage[] } - -export function toHaveSent(received: HasInbox, match: EmailMatch): MatcherResult { - const inbox = inboxOf(received) - if (!inbox) - return { - pass: false, - message: () => - `toHaveSent: received value does not expose an inbox; pass a TestEmail instance`, - } - const hits: string[] = [] - for (const msg of inbox) { - const { pass, diff } = matchesEmail(msg, match) - if (pass) - return { pass: true, message: () => `expected no email to match ${JSON.stringify(match)}` } - if (diff) hits.push(diff) - } - return { - pass: false, - message: () => - `expected an email to match ${JSON.stringify(match)}; checked ${inbox.length} message(s):\n - ${hits.join("\n - ")}`, - } -} - -export function toHaveSentTo(received: HasInbox, recipient: string): MatcherResult { - const inbox = inboxOf(received) - if (!inbox) - return { - pass: false, - message: () => - `toHaveSentTo: received value does not expose an inbox; pass a TestEmail instance`, - } - for (const msg of inbox) { - const emails = [ - ...normalizeAddresses(msg.to), - ...normalizeAddresses(msg.cc), - ...normalizeAddresses(msg.bcc), - ].map((a) => a.email.toLowerCase()) - if (emails.includes(recipient.toLowerCase())) - return { pass: true, message: () => `expected no email to be sent to ${recipient}` } - } - return { - pass: false, - message: () => - `expected an email to ${recipient}; ${inbox.length} message(s) checked but none matched`, - } -} - -export function toHaveSentWithSubject(received: HasInbox, pattern: string | RegExp): MatcherResult { - const inbox = inboxOf(received) - if (!inbox) - return { - pass: false, - message: () => - `toHaveSentWithSubject: received value does not expose an inbox; pass a TestEmail instance`, - } - for (const msg of inbox) { - const ok = pattern instanceof RegExp ? pattern.test(msg.subject) : msg.subject === pattern - if (ok) - return { - pass: true, - message: () => `expected no email with subject ${formatExpected(pattern)}`, - } - } - return { - pass: false, - message: () => - `expected an email with subject ${formatExpected(pattern)}; got ${inbox.map((m) => JSON.stringify(m.subject)).join(", ")}`, - } -} - -export function toHaveSentWithAttachment( - received: HasInbox, - match: string | AttachmentPredicate, -): MatcherResult { - const inbox = inboxOf(received) - if (!inbox) - return { - pass: false, - message: () => - `toHaveSentWithAttachment: received value does not expose an inbox; pass a TestEmail instance`, - } - const predicate = - typeof match === "string" - ? (a: NonNullable[number]) => a.filename === match - : match - for (const msg of inbox) { - if ((msg.attachments ?? []).some(predicate)) - return { pass: true, message: () => `expected no email with a matching attachment` } - } - return { - pass: false, - message: () => - `expected an email with an attachment matching ${typeof match === "string" ? match : ""}; checked ${inbox.length} message(s)`, - } -} - -export function toHaveSentMatching( - received: HasInbox, - predicate: (msg: EmailMessage) => boolean, -): MatcherResult { - const inbox = inboxOf(received) - if (!inbox) - return { - pass: false, - message: () => - `toHaveSentMatching: received value does not expose an inbox; pass a TestEmail instance`, - } - for (const msg of inbox) { - if (predicate(msg)) - return { pass: true, message: () => `expected no email to match the predicate` } - } - return { - pass: false, - message: () => `expected an email to match the predicate; ${inbox.length} checked`, - } -} - -/** Vitest-compatible matcher object. Has an explicit index signature - * so it type-checks against `expect.extend`'s `MatchersObject`. */ -export const emailMatchers: { - toHaveSent: typeof toHaveSent - toHaveSentTo: typeof toHaveSentTo - toHaveSentWithSubject: typeof toHaveSentWithSubject - toHaveSentWithAttachment: typeof toHaveSentWithAttachment - toHaveSentMatching: typeof toHaveSentMatching -} = { - toHaveSent, - toHaveSentTo, - toHaveSentWithSubject, - toHaveSentWithAttachment, - toHaveSentMatching, -} - -/** Snapshot helper — returns a stable, serializable view of an email. - * Volatile fields (Message-ID, Date, random boundaries) are normalized - * so snapshots survive reruns. */ -export function toEmailSnapshot(msg: EmailMessage): Record { - const snap: Record = { - from: normalizeAddresses(msg.from).map((a) => a.email), - to: normalizeAddresses(msg.to).map((a) => a.email), - subject: msg.subject, - } - if (msg.cc) snap.cc = normalizeAddresses(msg.cc).map((a) => a.email) - if (msg.bcc) snap.bcc = normalizeAddresses(msg.bcc).map((a) => a.email) - if (msg.text) snap.text = msg.text - if (msg.html) snap.html = msg.html - if (msg.headers) snap.headers = sanitizeHeaders(msg.headers) - if (msg.attachments) - snap.attachments = msg.attachments.map((a) => ({ - filename: a.filename, - contentType: a.contentType, - disposition: a.disposition, - size: typeof a.content === "string" ? a.content.length : a.content.byteLength, - })) - return snap -} - -function sanitizeHeaders(headers: Record): Record { - const out: Record = {} - for (const [key, value] of Object.entries(headers)) { - const lower = key.toLowerCase() - if (lower === "message-id" || lower === "date") continue - out[key] = value - } - return out -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 8a038bf..0000000 --- a/src/types.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Core types shared by every `unemail` driver, middleware, and adapter. - * - * The public surface is designed to stay runtime-agnostic (Node, Bun, Deno, - * Cloudflare Workers, browser), so all types here are plain structural - * shapes with no host dependencies. - * - * @module - */ - -/** A value that may be returned synchronously or as a promise. */ -export type MaybePromise = T | Promise - -/** Basic contact shape — an address plus optional display name. */ -export interface EmailAddress { - email: string - name?: string -} - -/** Accepts a single string (`"Ada "`), an `EmailAddress`, or a - * list of either. Drivers normalize to a flat array internally. */ -export type EmailAddressInput = string | EmailAddress | ReadonlyArray - -/** File-like payload — either encoded content or an inline `content-id` - * reference used by HTML `` blocks. */ -export interface Attachment { - filename: string - content: string | Uint8Array - contentType?: string - disposition?: "attachment" | "inline" - cid?: string -} - -/** Key-value tag (usually forwarded to provider analytics). */ -export interface EmailTag { - name: string - value: string -} - -/** User-supplied message before driver-specific normalization. */ -export interface EmailMessage { - /** Stream namespace — routed via `mount(stream, driver)`. Optional. */ - stream?: string - - from: EmailAddressInput - to: EmailAddressInput - cc?: EmailAddressInput - bcc?: EmailAddressInput - replyTo?: EmailAddressInput - - subject: string - /** Short preview text shown by most mail clients alongside the - * subject. Injected into the rendered HTML by the render - * pipeline's `withPreheader()` transform. */ - preheader?: string - text?: string - html?: string - - headers?: Record - attachments?: ReadonlyArray - tags?: ReadonlyArray - - /** Deduplication key — drivers pass through where supported, otherwise - * the core memoizes via the idempotency store. */ - idempotencyKey?: string - - /** Schedule future delivery. ISO string or `Date`. Drivers that do not - * support scheduling reject with `EmailErrorCode.UNSUPPORTED`. */ - scheduledAt?: string | Date - - /** Unsubscribe configuration — emits RFC 2369 `List-Unsubscribe` and, - * when `oneClick` is true, RFC 8058 `List-Unsubscribe-Post` headers. - * Required by Gmail + Yahoo bulk sender rules (Feb 2024). */ - unsubscribe?: UnsubscribeOptions - - /** Provider-side template. `id` is the provider's template id (or alias - * for Postmark). `variables` are passed to the template engine under - * provider-specific names (`dynamic_template_data`, `TemplateModel`, - * `params`, `dataVariables`, …). Drivers without templating raise - * `UNSUPPORTED`. */ - template?: TemplateOptions - - /** Per-message tracking overrides. Drivers that don't expose granular - * tracking fall back to their global setting. */ - tracking?: TrackingOptions - - /** Run this send in sandbox / test mode. Mapped per-driver: - * - Mailgun `o:testmode`, SendGrid `mail_settings.sandbox_mode`, - * SES configuration sets, Postmark test-stream. - * - Mailtrap: routes to Email Sandbox API (`sandbox.api.mailtrap.io/.../{inboxId}`) - * when true; Email API uses `send.api.mailtrap.io`. Requires driver `inboxId` for sandbox. - * Not the same as SendGrid/Mailgun test flags. - * Drivers without sandbox support raise `UNSUPPORTED`. */ - sandbox?: boolean - - /** Provider-agnostic metadata echoed back in webhook events. SendGrid - * maps to `custom_args`, Postmark to `Metadata`, Mailgun to - * `v:key=value`, Resend to `headers["X-Metadata-*"]`. */ - metadata?: Record - - /** SendGrid-style per-recipient personalizations. When set, drivers - * that support it (SendGrid, Mailgun recipient-variables) dispatch - * a single batched request; others loop. */ - personalizations?: ReadonlyArray - - /** AMP4Email alternative part. Providers without AMP support ignore. */ - amp?: string - - /** RFC 3461 Delivery Status Notification requests. SMTP-only. */ - dsn?: DsnOptions - - /** Pre-built raw RFC 5322 message body. Bypasses the MIME builder - * entirely — useful for replay, forwarding, or when you've composed - * the message yourself. SMTP-only. */ - raw?: string | Uint8Array - - /** Unrendered React element — resolved to `html` by the `withRender` - * middleware from `unemail/render/react`. Ignored by drivers. */ - react?: unknown - /** Unrendered jsx-email element — resolved to `html` by the - * `withRender` middleware from `unemail/render/jsx-email`. */ - jsx?: unknown - /** MJML source — compiled to `html` by the `withRender` middleware - * from `unemail/render/mjml`. */ - mjml?: string - /** Handlebars template source — rendered by - * `unemail/render/handlebars` using `handlebarsVars` as the - * context. */ - handlebars?: string - handlebarsVars?: Record - /** Liquid template source — rendered by `unemail/render/liquid` - * using `liquidVars` as the context. */ - liquid?: string - liquidVars?: Record - /** Locale hint consumed by `unemail/render/i18n` to dispatch to the - * right sub-renderer. */ - locale?: string -} - -/** Per-recipient personalization (SendGrid-native). Loops fall back - * to copies of the message for providers without native support. */ -export interface Personalization { - to: EmailAddressInput - cc?: EmailAddressInput - bcc?: EmailAddressInput - subject?: string - variables?: Record - sendAt?: Date | string - customArgs?: Record -} - -/** RFC 3461 DSN (Delivery Status Notification) request. SMTP only. */ -export interface DsnOptions { - notify?: ReadonlyArray<"SUCCESS" | "FAILURE" | "DELAY" | "NEVER"> - ret?: "FULL" | "HDRS" - envid?: string - orcpt?: string -} - -/** Provider-side template settings. `id` is required when the provider - * addresses templates by id; `alias` is used when they address by - * name (Postmark). `variables` is a plain object — drivers stringify - * or serialize as their API demands. */ -export interface TemplateOptions { - id?: string - alias?: string - variables?: Record - /** Override locale for multi-locale template systems. */ - locale?: string -} - -/** Per-message tracking overrides. Unset fields defer to driver defaults. */ -export interface TrackingOptions { - opens?: boolean - clicks?: boolean - unsubscribes?: boolean -} - -/** RFC 2369 + RFC 8058 unsubscribe configuration. At least one of - * `url` or `mailto` must be provided. When `oneClick` defaults to - * `true` (when `url` is set), the core also emits - * `List-Unsubscribe-Post: List-Unsubscribe=One-Click`. */ -export interface UnsubscribeOptions { - url?: string - mailto?: string - oneClick?: boolean -} - -/** Outcome of a successful send — at minimum the provider-assigned id. */ -export interface EmailResult { - id: string - driver: string - stream?: string - at: Date - provider?: Record -} - -/** Machine-readable error taxonomy. Stable across drivers. */ -export type EmailErrorCode = - | "INVALID_OPTIONS" - | "NETWORK" - | "AUTH" - | "RATE_LIMIT" - | "TIMEOUT" - | "PROVIDER" - | "UNSUPPORTED" - | "CANCELLED" - -/** Resend-style discriminated union — one of `data` or `error` is always - * non-null. Narrowing on `error` gives you typed success data. */ -export type Result = { data: T; error: null } | { data: null; error: EmailError } - -/** Feature matrix advertised by each driver. Callers can gate behavior - * (e.g. skip attachments for drivers that do not support them). */ -export interface DriverFlags { - attachments?: boolean - html?: boolean - text?: boolean - batch?: boolean - scheduling?: boolean - idempotency?: boolean - tracking?: boolean - templates?: boolean - tagging?: boolean - replyTo?: boolean - customHeaders?: boolean - inbound?: boolean - webhooks?: boolean - cancelable?: boolean - retrievable?: boolean - personalizations?: boolean -} - -/** Status returned by `driver.retrieve(id)`. Mirrors the provider state - * where possible; `unknown` covers providers that don't expose it. */ -export type SendStatusState = - | "scheduled" - | "queued" - | "sent" - | "delivered" - | "bounced" - | "complained" - | "opened" - | "clicked" - | "cancelled" - | "failed" - | "unknown" - -export interface SendStatus { - id: string - driver: string - state: SendStatusState - /** Last-observed event timestamp if provided. */ - at?: Date - /** Raw provider-specific payload. */ - provider?: Record -} - -/** Contract every driver implements. `send` is the only required method; - * everything else is optional and feature-gated via `flags`. */ -export interface EmailDriver { - readonly name: string - readonly flags?: DriverFlags - readonly options?: TOpts - getInstance?: () => TInstance - initialize?: () => MaybePromise - dispose?: () => MaybePromise - isAvailable?: () => MaybePromise - send: (msg: EmailMessage, ctx: SendContext) => MaybePromise> - sendBatch?: ( - msgs: ReadonlyArray, - ctx: SendContext, - ) => MaybePromise>> - /** Cancel a scheduled send. Optional — drivers without support are - * gated by `flags.cancelable`. */ - cancel?: (id: string) => MaybePromise> - /** Retrieve the current state of a previously-sent message. Optional - * — drivers without support are gated by `flags.retrievable`. */ - retrieve?: (id: string) => MaybePromise> -} - -/** Factory that produces a driver from user options. Always returned via - * `defineDriver()` for type inference. */ -export type DriverFactory = ( - options?: TOpts, -) => EmailDriver - -/** Per-send context available to drivers and middleware. Extend via - * middleware by mutating `meta`. */ -export interface SendContext { - driver: string - stream?: string - attempt: number - signal?: AbortSignal - meta: Record -} - -/** Hook-based middleware. `onError` may recover and return a `Result`; the - * rest are observational. */ -export interface Middleware { - name?: string - beforeSend?: (msg: EmailMessage, ctx: SendContext) => MaybePromise - afterSend?: ( - msg: EmailMessage, - ctx: SendContext, - result: Result, - ) => MaybePromise - onError?: ( - msg: EmailMessage, - ctx: SendContext, - error: EmailError, - ) => MaybePromise | void> -} - -/** Key-value store used for the idempotency cache. Intentionally minimal so - * an `unstorage` adapter or a custom KV implementation can plug in. */ -export interface IdempotencyStore { - get: (key: string) => MaybePromise - set: (key: string, value: EmailResult, ttlSeconds?: number) => MaybePromise -} - -/** Error raised by any part of the pipeline. Stable shape — drivers wrap - * unknown errors via `toEmailError()` in `./errors.ts`. */ -export class EmailError extends Error { - override readonly name: string = "EmailError" - readonly driver: string - readonly code: EmailErrorCode - readonly status?: number - readonly retryable: boolean - override readonly cause?: unknown - - constructor(init: { - driver: string - code: EmailErrorCode - message: string - status?: number - retryable?: boolean - cause?: unknown - }) { - super(init.message) - this.driver = init.driver - this.code = init.code - this.status = init.status - this.retryable = - init.retryable ?? - (init.code === "NETWORK" || init.code === "RATE_LIMIT" || init.code === "TIMEOUT") - this.cause = init.cause - } -} diff --git a/src/verify/arc.ts b/src/verify/arc.ts deleted file mode 100644 index 0000ef4..0000000 --- a/src/verify/arc.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Minimal ARC (RFC 8617) signer — ships just enough to produce a - * valid ARC-Set (ARC-Authentication-Results, ARC-Message-Signature, - * ARC-Seal) for an intermediary. Uses Web Crypto, no deps. - * - * Scope: sign-only. Verification happens at the final receiver; this - * module helps the middle hop preserve auth-chain provenance. - * - * @module - */ - -export type ArcAlgorithm = "rsa-sha256" | "ed25519-sha256" - -export interface ArcSignerOptions { - selector: string - domain: string - privateKey: string | CryptoKey - algorithm?: ArcAlgorithm - /** Current instance number (i=). Required — the intermediary - * increments it each hop, starting at 1. */ - instance: number - /** Authentication-Results payload as you observed it. e.g. - * `"dkim=pass header.d=acme.com; spf=pass"`. */ - authResults: string - /** Headers to include in the ARC-Message-Signature body. */ - signedHeaders?: ReadonlyArray -} - -export interface ArcHeaders { - "ARC-Authentication-Results": string - "ARC-Message-Signature": string - "ARC-Seal": string -} - -/** Produce the three ARC headers for one hop. Caller prepends them to - * the outgoing message headers. Returns strings without trailing - * CRLF. */ -export async function signArc(message: string, options: ArcSignerOptions): Promise { - const alg = options.algorithm ?? "rsa-sha256" - const { instance, selector, domain, authResults } = options - const sep = message.indexOf("\r\n\r\n") - if (sep < 0) throw new Error("[unemail/arc] message must contain CRLF CRLF separator") - const headersBlock = message.slice(0, sep) - const body = message.slice(sep + 4) - - const authLine = `ARC-Authentication-Results: i=${instance}; ${authResults}` - const bodyHash = await sha256Base64(canonBody(body)) - - const amsFields = { - i: String(instance), - a: alg, - c: "relaxed/relaxed", - d: domain, - s: selector, - t: Math.floor(Date.now() / 1000).toString(), - bh: bodyHash, - h: (options.signedHeaders ?? ["From", "To", "Subject", "Date"]).join(":"), - b: "", - } - const amsValue = serializeFields(amsFields) - const amsHeader = `ARC-Message-Signature: ${amsValue}` - const amsSig = await signHeader( - message, - amsHeader, - options.signedHeaders ?? ["From", "To", "Subject", "Date"], - options.privateKey, - alg, - headersBlock, - ) - const amsFinal = `ARC-Message-Signature: ${amsValue.replace(/b=$/, `b=${amsSig}`)}` - - const asFields = { - i: String(instance), - a: alg, - cv: instance === 1 ? "none" : "pass", - d: domain, - s: selector, - t: Math.floor(Date.now() / 1000).toString(), - b: "", - } - const asValue = serializeFields(asFields) - const asBase = `${authLine}\r\n${amsFinal}\r\nARC-Seal: ${asValue}` - const asSig = await signString(asBase.replace(/b=$/, "b="), options.privateKey, alg) - const asFinal = `ARC-Seal: ${asValue.replace(/b=$/, `b=${asSig}`)}` - - return { - "ARC-Authentication-Results": authLine.slice("ARC-Authentication-Results: ".length), - "ARC-Message-Signature": amsFinal.slice("ARC-Message-Signature: ".length), - "ARC-Seal": asFinal.slice("ARC-Seal: ".length), - } -} - -async function signHeader( - _message: string, - _amsHeader: string, - signed: ReadonlyArray, - key: string | CryptoKey, - alg: ArcAlgorithm, - headersBlock: string, -): Promise { - const parsed = parseHeaders(headersBlock) - const canon = signed - .map((n) => parsed.find((h) => h.name.toLowerCase() === n.toLowerCase())) - .filter((h): h is { name: string; value: string } => Boolean(h)) - .map( - (h) => - `${h.name.toLowerCase()}:${h.value - .replace(/\r\n/g, "") - .replace(/[ \t]+/g, " ") - .trim()}\r\n`, - ) - .join("") - return signString(canon, key, alg) -} - -async function signString( - value: string, - key: string | CryptoKey, - alg: ArcAlgorithm, -): Promise { - const imported = await importKey(key, alg) - const algo: AlgorithmIdentifier = - alg === "ed25519-sha256" - ? ({ name: "Ed25519" } as unknown as AlgorithmIdentifier) - : { name: "RSASSA-PKCS1-v1_5" } - const sig = await crypto.subtle.sign( - algo, - imported, - new TextEncoder().encode(value) as BufferSource, - ) - return bytesToBase64(new Uint8Array(sig)) -} - -async function importKey(key: string | CryptoKey, alg: ArcAlgorithm): Promise { - if (typeof key !== "string") return key - const b64 = key - .replace(/-----BEGIN [A-Z ]+-----/g, "") - .replace(/-----END [A-Z ]+-----/g, "") - .replace(/\s+/g, "") - const der = base64ToBytes(b64) - if (alg === "ed25519-sha256") { - return crypto.subtle.importKey( - "pkcs8", - der as BufferSource, - { name: "Ed25519" } as unknown as AlgorithmIdentifier, - false, - ["sign"], - ) - } - return crypto.subtle.importKey( - "pkcs8", - der as BufferSource, - { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, - false, - ["sign"], - ) -} - -function canonBody(body: string): string { - const lines = body.split(/\r\n/) - while (lines.length && lines[lines.length - 1] === "") lines.pop() - if (!lines.length) return "\r\n" - return lines.map((l) => l.replace(/[ \t]+/g, " ").replace(/[ \t]+$/g, "")).join("\r\n") + "\r\n" -} - -function parseHeaders(block: string): Array<{ name: string; value: string }> { - const out: Array<{ name: string; value: string }> = [] - const lines = block.split(/\r\n/) - let current: { name: string; value: string } | null = null - for (const line of lines) { - if (!line) continue - if (/^[ \t]/.test(line)) { - if (current) current.value += "\r\n" + line - continue - } - if (current) out.push(current) - const colon = line.indexOf(":") - if (colon < 0) continue - current = { name: line.slice(0, colon), value: line.slice(colon + 1) } - } - if (current) out.push(current) - return out -} - -function serializeFields(fields: Record): string { - const order = ["i", "a", "c", "cv", "d", "s", "t", "bh", "h", "b"] - const parts: string[] = [] - for (const k of order) if (fields[k] !== undefined) parts.push(`${k}=${fields[k]}`) - return parts.join("; ") -} - -async function sha256Base64(value: string): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(value) as BufferSource, - ) - return bytesToBase64(new Uint8Array(digest)) -} - -function bytesToBase64(bytes: Uint8Array): string { - let s = "" - for (const b of bytes) s += String.fromCharCode(b) - return btoa(s) -} - -function base64ToBytes(s: string): Uint8Array { - const bin = atob(s) - const buf = new ArrayBuffer(bin.length) - const out = new Uint8Array(buf) - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) - return out -} diff --git a/src/verify/index.ts b/src/verify/index.ts deleted file mode 100644 index 0b9a5ba..0000000 --- a/src/verify/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { ParsedEmail } from "../parse/index.ts" - -/** DKIM / SPF / DMARC verification helpers. - * - * Two modes: - * 1. **Trust the relay** (default): parse the \`Authentication-Results\` - * header supplied by the MTA that delivered the message. Cheap, - * works on Workers, relies on the upstream being honest (typically - * Gmail, Google Workspace, Exchange, SES — all trustworthy). - * 2. **Active verification**: plug in a callback to run DNS lookups + - * cryptographic verification. We don't bundle this because active - * DKIM needs DNS access (Workers require \`dns-over-https\`) and a - * real crypto validator — not worth reinventing. When the callback - * is present it overrides the parsed header result. */ - -export type AuthResult = - | "pass" - | "fail" - | "neutral" - | "softfail" - | "temperror" - | "permerror" - | "none" - -export interface AuthenticationResults { - dkim: AuthResult - spf: AuthResult - dmarc: AuthResult - authenticatedDomain?: string - raw?: string -} - -export interface VerifyOptions { - /** Optional callback for active verification. Receives the parsed - * email; should return fresh results (authoritative lookups, etc.). */ - verify?: (mail: ParsedEmail) => Promise | AuthenticationResults -} - -const UNKNOWN: AuthenticationResults = { dkim: "none", spf: "none", dmarc: "none" } - -/** Run all three checks. Returns the callback result if provided, - * otherwise the parsed \`Authentication-Results\` header. */ -export async function verifyAll( - mail: ParsedEmail, - options: VerifyOptions = {}, -): Promise { - if (options.verify) return options.verify(mail) - return parseAuthenticationResults(mail.headers["authentication-results"]) -} - -export function verifyDkim(mail: ParsedEmail): AuthResult { - return parseAuthenticationResults(mail.headers["authentication-results"]).dkim -} - -export function verifySpf(mail: ParsedEmail): AuthResult { - return parseAuthenticationResults(mail.headers["authentication-results"]).spf -} - -export function verifyDmarc(mail: ParsedEmail): AuthResult { - return parseAuthenticationResults(mail.headers["authentication-results"]).dmarc -} - -/** Parse one or more \`Authentication-Results\` headers per RFC 8601. */ -export function parseAuthenticationResults(header: string | undefined): AuthenticationResults { - if (!header) return UNKNOWN - const result: AuthenticationResults = { dkim: "none", spf: "none", dmarc: "none", raw: header } - // Header shape: `mta.example.com; dkim=pass header.d=example.com; spf=pass smtp.mailfrom=example.com; dmarc=pass` - for (const entry of header.split(";")) { - const trimmed = entry.trim() - const match = /^(dkim|spf|dmarc)=([a-z]+)/i.exec(trimmed) - if (!match) continue - const method = match[1]!.toLowerCase() as "dkim" | "spf" | "dmarc" - const outcome = match[2]!.toLowerCase() as AuthResult - result[method] = outcome - if (method === "dkim") { - const domain = /header\.d=([^\s;]+)/i.exec(trimmed) - if (domain) result.authenticatedDomain = domain[1] - } - } - return result -} diff --git a/src/webhook/_crypto.ts b/src/webhook/_crypto.ts deleted file mode 100644 index 4971694..0000000 --- a/src/webhook/_crypto.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** Shared Web-Crypto helpers for the webhook + inbound verifiers. Kept - * tiny and zero-dep so every entry that wants HMAC/HEX can reach for it - * without dragging Node's crypto module. */ - -const encoder = new TextEncoder() - -/** HMAC- with a secret + message → hex string. */ -export async function webCryptoHmacHex( - algorithm: "SHA-1" | "SHA-256" | "SHA-512", - secret: string, - message: string, -): Promise { - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret) as BufferSource, - { name: "HMAC", hash: algorithm }, - false, - ["sign"], - ) - const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(message) as BufferSource) - return bytesToHex(new Uint8Array(sig)) -} - -/** Constant-time string equality — used everywhere signatures are - * compared. */ -export function timingSafeEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false - let mismatch = 0 - for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i) - return mismatch === 0 -} - -export function bytesToHex(bytes: Uint8Array): string { - let out = "" - for (const byte of bytes) out += byte.toString(16).padStart(2, "0") - return out -} - -export function b64ToBytes(value: string): Uint8Array { - const g = globalThis as { Buffer?: { from: (v: string, enc: string) => Uint8Array } } - if (g.Buffer) return g.Buffer.from(value, "base64") - const binary = atob(value) - const out = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) - return out -} diff --git a/src/webhook/index.ts b/src/webhook/index.ts deleted file mode 100644 index 6eff478..0000000 --- a/src/webhook/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** Unified webhook event schema that every provider verifier normalizes - * into. Consumers get one shape regardless of which SDK the upstream - * vendor ships. */ - -export type WebhookEventType = - | "sent" - | "delivered" - | "bounced" - | "complained" - | "opened" - | "clicked" - | "unsubscribed" - | "rejected" - | "failed" - | "other" - -export interface WebhookEvent { - type: WebhookEventType - id: string - at: Date - recipient: string - provider: string - /** Provider-native payload, preserved for drivers that surface extra - * fields (bounce diagnostics, click URLs, etc.). */ - raw: unknown - /** For \`clicked\` events — the URL the recipient clicked. */ - url?: string - /** For \`bounced\` events — bounce classification (\`"hard"\` / \`"soft"\` - * / \`"unknown"\`). */ - bounce?: "hard" | "soft" | "unknown" -} - -/** A provider-specific verifier that can normalize one webhook request - * into one or more \`WebhookEvent\`s. */ -export interface WebhookProvider { - readonly name: string - verify: (request: Request) => Promise | WebhookEvent[] | null -} - -/** Handler returned by \`defineWebhookHandler\`. */ -export type WebhookHandler = (request: Request) => Promise - -export interface DefineWebhookHandlerOptions { - providers: ReadonlyArray - onEvent: ( - event: WebhookEvent, - context: { provider: string; request: Request }, - ) => void | Promise - onUnknown?: (request: Request) => Promise | Response - onVerificationFailure?: (request: Request, provider: string) => Promise | Response -} - -/** Build a fetch-compatible handler that accepts webhook payloads from - * any registered provider, verifies signatures, and yields unified - * \`WebhookEvent\`s via \`onEvent\`. */ -export function defineWebhookHandler(options: DefineWebhookHandlerOptions): WebhookHandler { - return async (request: Request) => { - for (const provider of options.providers) { - const events = await provider.verify(request.clone()) - if (events == null) continue - if (events.length === 0) { - return options.onVerificationFailure - ? options.onVerificationFailure(request, provider.name) - : new Response("invalid signature", { status: 401 }) - } - for (const event of events) { - await options.onEvent(event, { provider: provider.name, request }) - } - return new Response("ok", { status: 200 }) - } - return options.onUnknown - ? options.onUnknown(request) - : new Response("no matching webhook provider", { status: 404 }) - } -} diff --git a/src/webhook/mailgun.ts b/src/webhook/mailgun.ts deleted file mode 100644 index 5bf25e3..0000000 --- a/src/webhook/mailgun.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { WebhookEvent, WebhookProvider } from "./index.ts" -import { timingSafeEqual, webCryptoHmacHex } from "./_crypto.ts" - -/** Mailgun webhook verifier. The payload always contains - * \`signature: { timestamp, token, signature }\` - * plus an \`event-data\` block. HMAC-SHA256 of \`\${timestamp}\${token}\` - * keyed with the API signing key must equal \`signature\`. */ -export interface MailgunWebhookOptions { - signingKey: string - /** Window in seconds to accept messages from. Default: 300. */ - toleranceSeconds?: number - now?: () => number -} - -interface MailgunWebhookBody { - signature?: { - timestamp?: string - token?: string - signature?: string - } - "event-data"?: { - id?: string - timestamp?: number - event?: string - recipient?: string - url?: string - severity?: string - } -} - -export default function mailgunWebhook(options: MailgunWebhookOptions): WebhookProvider { - const tolerance = options.toleranceSeconds ?? 300 - const now = options.now ?? (() => Math.floor(Date.now() / 1000)) - return { - name: "mailgun", - async verify(request) { - if (request.method !== "POST") return null - const ct = request.headers.get("content-type") ?? "" - if (!ct.startsWith("application/json")) return null - const body = (await request.json()) as MailgunWebhookBody - const sig = body.signature - if (!sig?.timestamp || !sig?.token || !sig?.signature) return [] - const ts = Number(sig.timestamp) - if (!Number.isFinite(ts) || Math.abs(now() - ts) > tolerance) return [] - const expected = await webCryptoHmacHex( - "SHA-256", - options.signingKey, - `${sig.timestamp}${sig.token}`, - ) - if (!timingSafeEqual(expected, sig.signature)) return [] - return [normalize(body)] - }, - } -} - -function normalize(body: MailgunWebhookBody): WebhookEvent { - const data = body["event-data"] ?? {} - const type = mapType(data.event) - const event: WebhookEvent = { - type, - id: data.id ?? "", - at: data.timestamp ? new Date(data.timestamp * 1000) : new Date(), - recipient: data.recipient ?? "", - provider: "mailgun", - raw: body, - } - if (type === "clicked" && data.url) event.url = data.url - if (type === "bounced") event.bounce = data.severity === "permanent" ? "hard" : "soft" - return event -} - -function mapType(raw: string | undefined): WebhookEvent["type"] { - switch (raw) { - case "accepted": - return "sent" - case "delivered": - return "delivered" - case "failed": - case "temporary_fail": - case "permanent_fail": - return "bounced" - case "complained": - return "complained" - case "opened": - return "opened" - case "clicked": - return "clicked" - case "unsubscribed": - return "unsubscribed" - case "rejected": - return "rejected" - default: - return "other" - } -} diff --git a/src/webhook/postmark.ts b/src/webhook/postmark.ts deleted file mode 100644 index a2dd28a..0000000 --- a/src/webhook/postmark.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { WebhookEvent, WebhookProvider } from "./index.ts" -import { timingSafeEqual } from "./_crypto.ts" - -/** Postmark webhook verifier. Postmark doesn't expose an HMAC — the - * standard integration uses HTTP Basic auth on the webhook URL. Pass - * \`basicAuth\` to enable verification. */ -export interface PostmarkWebhookOptions { - basicAuth?: string -} - -interface PostmarkWebhookBody { - RecordType?: string - MessageID?: string - Recipient?: string - Email?: string - DeliveredAt?: string - ReceivedAt?: string - BouncedAt?: string - Type?: string - OriginalLink?: string -} - -export default function postmarkWebhook(options: PostmarkWebhookOptions = {}): WebhookProvider { - return { - name: "postmark", - async verify(request) { - if (request.method !== "POST") return null - if (!(request.headers.get("user-agent") ?? "").toLowerCase().includes("postmark")) return null - if (options.basicAuth) { - const auth = request.headers.get("authorization") ?? "" - if (!auth.startsWith("Basic ")) return [] - let decoded = "" - try { - decoded = atob(auth.slice(6)) - } catch { - return [] - } - if (!timingSafeEqual(decoded, options.basicAuth)) return [] - } - const body = (await request.json()) as PostmarkWebhookBody - return [normalize(body)] - }, - } -} - -function normalize(body: PostmarkWebhookBody): WebhookEvent { - const type = mapType(body.RecordType) - const at = body.DeliveredAt ?? body.BouncedAt ?? body.ReceivedAt - const event: WebhookEvent = { - type, - id: body.MessageID ?? "", - at: at ? new Date(at) : new Date(), - recipient: body.Recipient ?? body.Email ?? "", - provider: "postmark", - raw: body, - } - if (type === "clicked" && body.OriginalLink) event.url = body.OriginalLink - if (type === "bounced" && body.Type) - event.bounce = /HardBounce|BadEmailAddress|ManuallyDeactivated/i.test(body.Type) - ? "hard" - : "soft" - return event -} - -function mapType(raw: string | undefined): WebhookEvent["type"] { - switch (raw) { - case "Delivery": - return "delivered" - case "Bounce": - return "bounced" - case "SpamComplaint": - return "complained" - case "Open": - return "opened" - case "Click": - return "clicked" - case "SubscriptionChange": - return "unsubscribed" - default: - return "other" - } -} diff --git a/src/webhook/resend.ts b/src/webhook/resend.ts deleted file mode 100644 index 9843a65..0000000 --- a/src/webhook/resend.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { WebhookEvent, WebhookProvider } from "./index.ts" -import { b64ToBytes, timingSafeEqual, webCryptoHmacHex, bytesToHex } from "./_crypto.ts" - -/** Resend webhook verifier. Resend uses the Svix signature format: - * - \`svix-id\`: unique id - * - \`svix-timestamp\`: unix seconds - * - \`svix-signature\`: space-separated \`v1,\` tokens - * - * The message HMAC'd is \`\${svix-id}.\${svix-timestamp}.\${body}\`. - * - * The secret must be provided with or without Svix's \`whsec_\` prefix. */ -export interface ResendWebhookOptions { - secret: string - /** Window in seconds to accept messages from. Default: 300. */ - toleranceSeconds?: number - now?: () => number -} - -export default function resendWebhook(options: ResendWebhookOptions): WebhookProvider { - const secret = options.secret.replace(/^whsec_/, "") - const tolerance = options.toleranceSeconds ?? 300 - const now = options.now ?? (() => Math.floor(Date.now() / 1000)) - return { - name: "resend", - async verify(request) { - const id = request.headers.get("svix-id") - const timestamp = request.headers.get("svix-timestamp") - const signatureHeader = request.headers.get("svix-signature") - if (!id || !timestamp || !signatureHeader) return null - const ts = Number(timestamp) - if (!Number.isFinite(ts) || Math.abs(now() - ts) > tolerance) return [] - const body = await request.text() - const message = `${id}.${timestamp}.${body}` - const expected = await svixHmacBase64(secret, message) - const provided = signatureHeader.split(" ").flatMap((s) => { - const [version, value] = s.split(",") - return version === "v1" && value ? [value] : [] - }) - if (!provided.some((sig) => timingSafeEqual(sig, expected))) return [] - const parsed = JSON.parse(body) as ResendWebhookBody - return [normalize(parsed)] - }, - } -} - -async function svixHmacBase64(secret: string, message: string): Promise { - // Svix secrets are base64-encoded; we HMAC-SHA256 with the raw bytes. - const rawKey = b64ToBytes(secret) - const keyUint8 = rawKey.slice() as Uint8Array - const key = await crypto.subtle.importKey( - "raw", - keyUint8 as BufferSource, - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ) - const sig = await crypto.subtle.sign( - "HMAC", - key, - new TextEncoder().encode(message) as BufferSource, - ) - // Base64 encode the raw hash bytes. - return bytesToBase64(new Uint8Array(sig)) -} - -function bytesToBase64(bytes: Uint8Array): string { - const g = globalThis as { - Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } } - } - if (g.Buffer) return g.Buffer.from(bytes).toString("base64") - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary) -} - -interface ResendWebhookBody { - type?: string - created_at?: string - data?: { - email_id?: string - to?: string[] | string - from?: string - click?: { link?: string } - bounce?: { bounceType?: string } - } -} - -function normalize(body: ResendWebhookBody): WebhookEvent { - const type = mapType(body.type) - const data = body.data ?? {} - const recipient = Array.isArray(data.to) ? (data.to[0] ?? "") : (data.to ?? "") - const event: WebhookEvent = { - type, - id: data.email_id ?? "", - at: body.created_at ? new Date(body.created_at) : new Date(), - recipient, - provider: "resend", - raw: body, - } - if (type === "clicked" && data.click?.link) event.url = data.click.link - if (type === "bounced" && data.bounce?.bounceType) - event.bounce = data.bounce.bounceType === "Permanent" ? "hard" : "soft" - return event -} - -function mapType(raw: string | undefined): WebhookEvent["type"] { - if (!raw) return "other" - if (raw.endsWith(".sent")) return "sent" - if (raw.endsWith(".delivered")) return "delivered" - if (raw.endsWith(".bounced")) return "bounced" - if (raw.endsWith(".complained")) return "complained" - if (raw.endsWith(".opened")) return "opened" - if (raw.endsWith(".clicked")) return "clicked" - if (raw.endsWith(".failed") || raw.endsWith(".delivery_delayed")) return "failed" - return "other" -} - -// Re-export for test reuse. -export { webCryptoHmacHex, bytesToHex } diff --git a/src/webhook/sendgrid.ts b/src/webhook/sendgrid.ts deleted file mode 100644 index c2193ab..0000000 --- a/src/webhook/sendgrid.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { WebhookEvent, WebhookProvider } from "./index.ts" -import { b64ToBytes } from "./_crypto.ts" - -/** SendGrid Event Webhook verifier. SG signs each request with ECDSA - * (P-256 / SHA-256): - * - \`X-Twilio-Email-Event-Webhook-Timestamp\` - * - \`X-Twilio-Email-Event-Webhook-Signature\` - * - * The signature is over \`\${timestamp}\${body}\` using the account's - * public verification key (base64 DER). Verified via Web Crypto. */ -export interface SendGridWebhookOptions { - /** Base64-encoded SPKI public key (SendGrid's "Verification Key"). */ - publicKey: string - toleranceSeconds?: number - now?: () => number -} - -interface SendGridEventBody { - sg_event_id?: string - event?: string - email?: string - timestamp?: number - url?: string - type?: string -} - -export default function sendgridWebhook(options: SendGridWebhookOptions): WebhookProvider { - const tolerance = options.toleranceSeconds ?? 300 - const now = options.now ?? (() => Math.floor(Date.now() / 1000)) - return { - name: "sendgrid", - async verify(request) { - if (request.method !== "POST") return null - const timestamp = request.headers.get("x-twilio-email-event-webhook-timestamp") - const signature = request.headers.get("x-twilio-email-event-webhook-signature") - if (!timestamp || !signature) return null - const ts = Number(timestamp) - if (!Number.isFinite(ts) || Math.abs(now() - ts) > tolerance) return [] - const body = await request.text() - const ok = await verifyEcdsa(options.publicKey, signature, `${timestamp}${body}`) - if (!ok) return [] - const parsed = JSON.parse(body) as SendGridEventBody[] - return parsed.map(normalize) - }, - } -} - -async function verifyEcdsa( - publicKeyBase64: string, - signatureBase64: string, - message: string, -): Promise { - try { - const spki = b64ToBytes(publicKeyBase64) - const signature = derToRaw(b64ToBytes(signatureBase64)) - const key = await crypto.subtle.importKey( - "spki", - spki.slice() as BufferSource, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["verify"], - ) - return crypto.subtle.verify( - { name: "ECDSA", hash: "SHA-256" }, - key, - signature.slice() as BufferSource, - new TextEncoder().encode(message) as BufferSource, - ) - } catch { - return false - } -} - -/** Convert a DER-encoded ECDSA signature (SEQUENCE of two INTEGER r, s) - * into the raw 64-byte form Web Crypto's \`verify\` expects. */ -function derToRaw(der: Uint8Array): Uint8Array { - if (der[0] !== 0x30) throw new Error("invalid DER signature") - let offset = 2 - if (der[1]! & 0x80) offset = 2 + (der[1]! & 0x7f) - if (der[offset] !== 0x02) throw new Error("invalid DER signature") - const rLen = der[offset + 1]! - const rStart = offset + 2 - const r = stripLeadingZero(der.slice(rStart, rStart + rLen), 32) - offset = rStart + rLen - if (der[offset] !== 0x02) throw new Error("invalid DER signature") - const sLen = der[offset + 1]! - const sStart = offset + 2 - const s = stripLeadingZero(der.slice(sStart, sStart + sLen), 32) - const out = new Uint8Array(64) - out.set(r, 32 - r.length) - out.set(s, 64 - s.length) - return out -} - -function stripLeadingZero(bytes: Uint8Array, size: number): Uint8Array { - let start = 0 - while (start < bytes.length - 1 && bytes[start] === 0) start++ - const out = bytes.slice(start) - if (out.length > size) return out.slice(out.length - size) - return out -} - -function normalize(body: SendGridEventBody): WebhookEvent { - const type = mapType(body.event) - const event: WebhookEvent = { - type, - id: body.sg_event_id ?? "", - at: body.timestamp ? new Date(body.timestamp * 1000) : new Date(), - recipient: body.email ?? "", - provider: "sendgrid", - raw: body, - } - if (type === "clicked" && body.url) event.url = body.url - if (type === "bounced") - event.bounce = body.type === "blocked" ? "hard" : body.type === "bounce" ? "hard" : "soft" - return event -} - -function mapType(raw: string | undefined): WebhookEvent["type"] { - switch (raw) { - case "processed": - return "sent" - case "delivered": - return "delivered" - case "open": - return "opened" - case "click": - return "clicked" - case "unsubscribe": - return "unsubscribed" - case "spamreport": - return "complained" - case "bounce": - case "blocked": - return "bounced" - case "dropped": - return "rejected" - case "deferred": - return "failed" - default: - return "other" - } -} diff --git a/src/webhook/ses.ts b/src/webhook/ses.ts deleted file mode 100644 index 955173e..0000000 --- a/src/webhook/ses.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { WebhookEvent, WebhookProvider } from "./index.ts" - -/** AWS SES via SNS webhook verifier. - * - * Full SNS signature verification requires fetching the AWS public cert - * advertised by \`SigningCertURL\` (we don't fetch out-of-band from the - * webhook in this minimal implementation). Use the \`verifySignature\` - * callback option to plug in \`aws-sns-signature-verification\` or your - * own verifier — we default to accepting on topic-ARN allow-listing. */ -export interface SesWebhookOptions { - /** Restrict to these SNS TopicArns. Recommended. */ - topicArns?: readonly string[] - /** Optional async signature verifier (fetches \`SigningCertURL\`). */ - verifySignature?: (body: SnsEnvelope) => Promise | boolean -} - -export interface SnsEnvelope { - Type?: string - TopicArn?: string - Message?: string - Signature?: string - SigningCertURL?: string - SubscribeURL?: string - MessageId?: string - Timestamp?: string -} - -interface SesMessage { - eventType?: string - mail?: { - messageId?: string - timestamp?: string - destination?: string[] - } - bounce?: { - bounceType?: string - bouncedRecipients?: Array<{ emailAddress?: string }> - } - complaint?: { - complainedRecipients?: Array<{ emailAddress?: string }> - } - delivery?: { recipients?: string[] } - open?: { timestamp?: string } - click?: { link?: string } -} - -export default function sesWebhook(options: SesWebhookOptions = {}): WebhookProvider { - return { - name: "ses", - async verify(request) { - if (request.method !== "POST") return null - const messageType = request.headers.get("x-amz-sns-message-type") - if (!messageType) return null - const body = (await request.json()) as SnsEnvelope - if (options.topicArns && body.TopicArn && !options.topicArns.includes(body.TopicArn)) - return [] - if (options.verifySignature) { - const ok = await options.verifySignature(body) - if (!ok) return [] - } - if (messageType === "SubscriptionConfirmation" || messageType === "UnsubscribeConfirmation") { - // Signal success but emit no events — callers may auto-confirm via body.SubscribeURL. - return [] - } - if (!body.Message) return [] - const message = JSON.parse(body.Message) as SesMessage - return normalize(message, body) - }, - } -} - -function normalize(message: SesMessage, envelope: SnsEnvelope): WebhookEvent[] { - const type = mapType(message.eventType) - const base: Pick = { - type, - id: message.mail?.messageId ?? envelope.MessageId ?? "", - provider: "ses", - raw: message, - at: message.mail?.timestamp ? new Date(message.mail.timestamp) : new Date(), - } - const recipients = resolveRecipients(message) - if (recipients.length === 0) return [{ ...base, recipient: "" }] - return recipients.map((recipient) => { - const event: WebhookEvent = { ...base, recipient } - if (type === "bounced" && message.bounce?.bounceType) - event.bounce = message.bounce.bounceType === "Permanent" ? "hard" : "soft" - if (type === "clicked" && message.click?.link) event.url = message.click.link - return event - }) -} - -function resolveRecipients(message: SesMessage): string[] { - if (message.bounce?.bouncedRecipients) - return message.bounce.bouncedRecipients.map((r) => r.emailAddress ?? "").filter(Boolean) - if (message.complaint?.complainedRecipients) - return message.complaint.complainedRecipients.map((r) => r.emailAddress ?? "").filter(Boolean) - if (message.delivery?.recipients) return message.delivery.recipients - if (message.mail?.destination) return message.mail.destination - return [] -} - -function mapType(raw: string | undefined): WebhookEvent["type"] { - switch (raw) { - case "Send": - return "sent" - case "Delivery": - return "delivered" - case "Bounce": - return "bounced" - case "Complaint": - return "complained" - case "Open": - return "opened" - case "Click": - return "clicked" - case "Reject": - return "rejected" - case "DeliveryDelay": - case "RenderingFailure": - return "failed" - default: - return "other" - } -} diff --git a/src/webhook/standard.ts b/src/webhook/standard.ts deleted file mode 100644 index 6cd7b3c..0000000 --- a/src/webhook/standard.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Reference implementation of the Standard Webhooks protocol - * (https://standardwebhooks.com). Zero-dep, Web-Crypto only, <5 kB. - * - * Used by Resend (and growing in adoption). Drop-in replacement for - * the Svix client when you only need signature verification. - * - * @module - */ - -import { b64ToBytes, timingSafeEqual } from "./_crypto.ts" - -const encoder = /* @__PURE__ */ new TextEncoder() -const TOLERANCE_SECONDS = 5 * 60 - -export interface StandardWebhookOptions { - /** Webhook secret. `whsec_` prefix is accepted and stripped. */ - secret: string - /** Max age of the timestamp the verifier will accept. Default: - * 5 minutes (same as the Svix reference). */ - toleranceSeconds?: number - /** Clock source — injected for tests. Default: `Date.now`. */ - now?: () => number -} - -/** Verify a Standard Webhooks request. Pass the raw HTTP request (or - * an adapter with `headers.get()` + `text()`). Resolves the payload - * on success, rejects with an `Error` on signature failure / stale - * timestamp. */ -export async function verifyStandardWebhook( - request: Request, - options: StandardWebhookOptions, -): Promise { - const msgId = request.headers.get("webhook-id") - const timestamp = request.headers.get("webhook-timestamp") - const signatures = request.headers.get("webhook-signature") - if (!msgId || !timestamp || !signatures) - throw new Error("[unemail/webhook/standard] missing webhook-* headers") - - const now = options.now ?? Date.now - const tolerance = options.toleranceSeconds ?? TOLERANCE_SECONDS - const ts = Number(timestamp) - if (!Number.isFinite(ts) || Math.abs(now() / 1000 - ts) > tolerance) - throw new Error("[unemail/webhook/standard] timestamp outside tolerance window") - - const body = await request.text() - const expected = await computeSignature( - stripPrefix(options.secret), - `${msgId}.${timestamp}.${body}`, - ) - for (const part of signatures.split(/\s+/)) { - const [version, sig] = part.split(",", 2) - if (version !== "v1" || !sig) continue - if (timingSafeEqual(sig, expected)) return body - } - throw new Error("[unemail/webhook/standard] signature mismatch") -} - -/** Sign a payload for tests or self-sending. Returns the value you'd - * put in the `webhook-signature` header. */ -export async function signStandardWebhook( - secret: string, - msgId: string, - timestamp: number, - body: string, -): Promise { - const sig = await computeSignature(stripPrefix(secret), `${msgId}.${timestamp}.${body}`) - return `v1,${sig}` -} - -function stripPrefix(secret: string): string { - return secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret -} - -async function computeSignature(secretBase64: string, message: string): Promise { - const keyBytes = b64ToBytes(secretBase64) - const key = await crypto.subtle.importKey( - "raw", - keyBytes as BufferSource, - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ) - const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(message) as BufferSource) - return bytesToBase64(new Uint8Array(sig)) -} - -function bytesToBase64(bytes: Uint8Array): string { - let s = "" - for (const b of bytes) s += String.fromCharCode(b) - return btoa(s) -} diff --git a/test/address.test.ts b/test/address.test.ts deleted file mode 100644 index a2c7817..0000000 --- a/test/address.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from "vitest" -import { mustParseAddress, parseAddress, parseAddresses } from "../src/address.ts" - -describe("parseAddress", () => { - it("parses a bare address", () => { - const { data, error } = parseAddress("ada@acme.com") - expect(error).toBeNull() - expect(data?.email).toBe("ada@acme.com") - expect(data?.local).toBe("ada") - expect(data?.domain).toBe("acme.com") - }) - - it("parses display-name form", () => { - const { data } = parseAddress('"Ada, Jr." ') - expect(data?.name).toBe("Ada, Jr.") - expect(data?.email).toBe("ada@acme.com") - expect(data?.toString()).toBe('"Ada, Jr." ') - }) - - it("parses unquoted display-name form", () => { - const { data } = parseAddress("Ada ") - expect(data?.name).toBe("Ada") - expect(data?.email).toBe("ada@acme.com") - }) - - it("rejects missing @", () => { - expect(parseAddress("not-an-email").error?.code).toBe("INVALID_OPTIONS") - }) - - it("rejects consecutive dots in local-part", () => { - expect(parseAddress("a..b@example.com").error?.code).toBe("INVALID_OPTIONS") - }) - - it("rejects empty domain label", () => { - expect(parseAddress("a@.example.com").error?.code).toBe("INVALID_OPTIONS") - }) - - it("accepts SMTPUTF8 by default", () => { - const { data } = parseAddress("müşteri@örnek.com") - expect(data?.email).toBe("müşteri@örnek.com") - }) - - it("rejects non-ASCII when smtpUtf8:false", () => { - expect(parseAddress("müşteri@örnek.com", { smtpUtf8: false }).error).not.toBeNull() - }) -}) - -describe("parseAddresses", () => { - it("walks mixed arrays", () => { - const { data } = parseAddresses(["a@x.com", { email: "b@x.com", name: "Bob" }]) - expect(data).toHaveLength(2) - expect(data?.[1]?.name).toBe("Bob") - }) - - it("short-circuits on the first failure", () => { - const { error } = parseAddresses(["a@x.com", "nope"]) - expect(error).not.toBeNull() - }) -}) - -describe("mustParseAddress", () => { - it("throws on failure", () => { - expect(() => mustParseAddress("bad")).toThrow() - }) -}) diff --git a/test/compliance/unsubscribe.test.ts b/test/compliance/unsubscribe.test.ts deleted file mode 100644 index 789bb45..0000000 --- a/test/compliance/unsubscribe.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it } from "vitest" -import { createEmail } from "../../src/index.ts" -import { - defineUnsubscribeHandler, - signUnsubscribeToken, - verifyUnsubscribeToken, -} from "../../src/compliance/index.ts" -import { memorySuppressionStore } from "../../src/suppression/index.ts" -import type { EmailDriver, EmailMessage } from "../../src/types.ts" - -function capturing(): { driver: EmailDriver; last: () => EmailMessage | undefined } { - let last: EmailMessage | undefined - return { - driver: { - name: "capture", - send: (msg) => { - last = msg - return { data: { id: "1", driver: "capture", at: new Date() }, error: null } - }, - }, - last: () => last, - } -} - -describe("List-Unsubscribe auto-injection", () => { - it("injects both RFC 2369 and RFC 8058 headers when a URL is provided", async () => { - const cap = capturing() - const email = createEmail({ driver: cap.driver }) - await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - text: "x", - unsubscribe: { url: "https://example.com/u?t=abc" }, - }) - const headers = cap.last()!.headers! - expect(headers["List-Unsubscribe"]).toBe("") - expect(headers["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click") - }) - - it("supports mailto-only unsubscribe (RFC 2369 without one-click)", async () => { - const cap = capturing() - const email = createEmail({ driver: cap.driver }) - await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - text: "x", - unsubscribe: { mailto: "unsubscribe@example.com" }, - }) - const headers = cap.last()!.headers! - expect(headers["List-Unsubscribe"]).toBe("") - expect(headers["List-Unsubscribe-Post"]).toBeUndefined() - }) - - it("honours existing user-supplied headers without duplication", async () => { - const cap = capturing() - const email = createEmail({ driver: cap.driver }) - await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - text: "x", - headers: { "List-Unsubscribe": "" }, - unsubscribe: { url: "https://example.com/u" }, - }) - const headers = cap.last()!.headers! - expect(headers["List-Unsubscribe"]).toBe("") - }) - - it("emits both url + mailto when both are provided", async () => { - const cap = capturing() - const email = createEmail({ driver: cap.driver }) - await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - text: "x", - unsubscribe: { url: "https://example.com/u", mailto: "u@example.com" }, - }) - const headers = cap.last()!.headers! - expect(headers["List-Unsubscribe"]).toBe(", ") - }) -}) - -describe("unsubscribe token", () => { - it("signs and verifies round-trip", async () => { - const token = await signUnsubscribeToken( - { recipient: "ada@acme.com", campaign: "welcome" }, - "s3cret", - ) - const payload = await verifyUnsubscribeToken(token, "s3cret") - expect(payload).toEqual({ recipient: "ada@acme.com", campaign: "welcome" }) - }) - - it("rejects tampered tokens (body edited after signing)", async () => { - const token = await signUnsubscribeToken({ recipient: "ada@acme.com" }, "s3cret") - const [body, sig] = token.split(".") - const tamperedBody = body!.slice(0, -2) + "AA" - expect(await verifyUnsubscribeToken(`${tamperedBody}.${sig}`, "s3cret")).toBeNull() - }) - - it("rejects tokens signed with a different secret", async () => { - const token = await signUnsubscribeToken({ recipient: "ada@acme.com" }, "s3cret") - expect(await verifyUnsubscribeToken(token, "different")).toBeNull() - }) - - it("rejects expired tokens", async () => { - const token = await signUnsubscribeToken({ recipient: "ada@acme.com", exp: 1000 }, "s3cret") - expect(await verifyUnsubscribeToken(token, "s3cret", () => 2_000_000)).toBeNull() - }) -}) - -describe("defineUnsubscribeHandler", () => { - it("adds the recipient to the store on a valid POST", async () => { - const store = memorySuppressionStore() - const handler = defineUnsubscribeHandler({ secret: "sk", store }) - const token = await signUnsubscribeToken({ recipient: "ada@acme.com" }, "sk") - const res = await handler( - new Request(`https://app/u?t=${encodeURIComponent(token)}`, { method: "POST" }), - ) - expect(res.status).toBe(200) - const rec = await store.has("ada@acme.com") - expect(rec?.reason).toBe("unsubscribed") - }) - - it("rejects invalid tokens with 400", async () => { - const handler = defineUnsubscribeHandler({ secret: "sk" }) - const res = await handler(new Request("https://app/u?t=garbage")) - expect(res.status).toBe(400) - }) -}) diff --git a/test/core.test.ts b/test/core.test.ts deleted file mode 100644 index b4f8e50..0000000 --- a/test/core.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from "vitest" -import { createEmail, defineDriver } from "../src/index.ts" -import mock from "../src/driver/mock.ts" - -describe("createEmail", () => { - it("sends via the default driver and returns {data, error}", async () => { - const email = createEmail({ driver: mock() }) - const { data, error } = await email.send({ - from: "sender@example.com", - to: "recipient@example.com", - subject: "hi", - text: "hello", - }) - expect(error).toBeNull() - expect(data?.driver).toBe("mock") - expect(data?.id).toMatch(/^mock_/) - }) - - it("routes by mounted stream", async () => { - const transactional = mock() - const marketing = mock() - const email = createEmail({ driver: transactional }).mount("marketing", marketing) - - await email.send({ from: "a@b.com", to: "c@d.com", subject: "tx", text: "1" }) - await email.send({ - stream: "marketing", - from: "a@b.com", - to: "c@d.com", - subject: "mk", - text: "2", - }) - - expect(transactional.getInstance?.()).toHaveLength(1) - expect(marketing.getInstance?.()).toHaveLength(1) - }) - - it("memoizes results by idempotency key", async () => { - const driver = mock() - const email = createEmail({ driver, idempotency: true }) - - const a = await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "once", - text: "x", - idempotencyKey: "welcome/42", - }) - const b = await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "once", - text: "x", - idempotencyKey: "welcome/42", - }) - - expect(a.data?.id).toBe(b.data?.id) - expect(driver.getInstance?.()).toHaveLength(1) - }) - - it("runs middleware hooks in order", async () => { - const calls: string[] = [] - const email = createEmail({ driver: mock() }).use({ - beforeSend: () => { - calls.push("before") - }, - afterSend: () => { - calls.push("after") - }, - }) - - await email.send({ from: "a@b.com", to: "c@d.com", subject: "hi", text: "x" }) - expect(calls).toEqual(["before", "after"]) - }) - - it("sendBatchStream yields one Result per message without short-circuiting", async () => { - let call = 0 - const email = createEmail({ - driver: defineDriver(() => ({ - name: "alt", - send: () => { - call++ - if (call === 2) { - return { - data: null, - error: { - name: "EmailError", - message: "bad", - driver: "alt", - code: "PROVIDER", - retryable: false, - } as never, - } - } - return { - data: { id: `id_${call}`, driver: "alt", at: new Date() }, - error: null, - } - }, - }))(), - }) - - const messages = [1, 2, 3].map((n) => ({ - from: "a@b.com", - to: "c@d.com", - subject: `s${n}`, - text: "x", - })) - - const outcomes: Array<"ok" | "err"> = [] - for await (const r of email.sendBatchStream(messages)) { - outcomes.push(r.error ? "err" : "ok") - } - expect(outcomes).toEqual(["ok", "err", "ok"]) - }) - - it("dispose() cascades to mounted drivers", async () => { - let disposed = 0 - const driver = defineDriver(() => ({ - name: "probe", - send: () => ({ data: null, error: null as never }), - dispose: () => { - disposed++ - }, - })) - const email = createEmail({ driver: driver() }).mount("x", driver()) - await email.dispose() - expect(disposed).toBe(2) - }) -}) diff --git a/test/core/address.test.ts b/test/core/address.test.ts new file mode 100644 index 0000000..ce61aee --- /dev/null +++ b/test/core/address.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest" +import { + dedupeAddresses, + formatAddress, + formatAddressList, + isValidEmail, + parseAddress, + toAddressList, +} from "../../src/core/address.ts" + +describe("parseAddress", () => { + it.each([ + ["ada@acme.com", { email: "ada@acme.com" }], + [" ada@acme.com ", { email: "ada@acme.com" }], + ["Ada Lovelace ", { email: "ada@acme.com", name: "Ada Lovelace" }], + ['"Lovelace, Ada" ', { email: "ada@acme.com", name: "Lovelace, Ada" }], + ["", { email: "ada@acme.com" }], + ])("parses %s", (input, expected) => { + expect(parseAddress(input)).toEqual(expected) + }) +}) + +describe("formatAddress", () => { + it("returns a bare address when there is no name", () => { + expect(formatAddress({ email: "a@b.com" })).toBe("a@b.com") + }) + + it("quotes a name containing a delimiter", () => { + expect(formatAddress({ email: "a@b.com", name: "Lovelace, Ada" })).toBe( + '"Lovelace, Ada" ', + ) + }) + + it("escapes an embedded quote", () => { + expect(formatAddress({ email: "a@b.com", name: 'Ada "The First"' })).toBe( + '"Ada \\"The First\\"" ', + ) + }) + + it("round-trips through parseAddress", () => { + const address = { email: "a@b.com", name: "Ada Lovelace" } + expect(parseAddress(formatAddress(address))).toEqual(address) + }) + + it("joins a list", () => { + expect(formatAddressList([{ email: "a@b.com" }, { email: "c@d.com", name: "Cee" }])).toBe( + "a@b.com, Cee ", + ) + }) +}) + +describe("toAddressList", () => { + it("returns an empty list for nullish input", () => { + expect(toAddressList(undefined)).toEqual([]) + }) + + it("accepts a single string, an object, or a mixed array", () => { + expect(toAddressList("a@b.com")).toEqual([{ email: "a@b.com" }]) + expect(toAddressList({ email: "a@b.com", name: "A" })).toEqual([ + { email: "a@b.com", name: "A" }, + ]) + expect(toAddressList(["a@b.com", { email: "c@d.com" }])).toHaveLength(2) + }) + + it("skips empty strings rather than producing an empty address", () => { + expect(toAddressList(["a@b.com", "", " "])).toEqual([{ email: "a@b.com" }]) + }) +}) + +describe("dedupeAddresses", () => { + it("keeps the first occurrence's name", () => { + expect( + dedupeAddresses([ + { email: "A@b.com", name: "First" }, + { email: "a@B.com", name: "Second" }, + ]), + ).toEqual([{ email: "A@b.com", name: "First" }]) + }) +}) + +describe("isValidEmail", () => { + it.each(["a@b.com", "ada.lovelace+tag@sub.acme.co.uk"])("accepts %s", (value) => { + expect(isValidEmail(value)).toBe(true) + }) + + it.each(["", "a@b", "a b@c.com", "no-at-sign.com", "a@b.com,c@d.com", "a@b.com;c@d.com"])( + "rejects %s", + (value) => { + expect(isValidEmail(value)).toBe(false) + }, + ) +}) diff --git a/test/core/email.test.ts b/test/core/email.test.ts new file mode 100644 index 0000000..8f9625d --- /dev/null +++ b/test/core/email.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it, vi } from "vitest" +import type { EmailDriver, EmailResult, Result } from "../../src/core/types.ts" +import { createEmail } from "../../src/core/email.ts" +import { defineMiddleware } from "../../src/core/define.ts" +import { createError } from "../../src/core/error.ts" +import { err, ok } from "../../src/core/result.ts" +import mock from "../../src/drivers/mock.ts" + +const msg = { to: "ada@example.com", subject: "hi", text: "hello" } as const +const defaults = { from: "Acme " } + +describe("send", () => { + it("returns the provider id on success", async () => { + const email = createEmail({ driver: mock(), defaults }) + const { data, error } = await email.send(msg) + expect(error).toBeNull() + expect(data?.driver).toBe("mock") + expect(data?.id).toMatch(/^mock_/) + }) + + it("returns a normalization failure as a Result, never a throw", async () => { + const email = createEmail({ driver: mock() }) + const { data, error } = await email.send(msg) + expect(data).toBeNull() + expect(error?.code).toBe("INVALID_OPTIONS") + }) + + it("hands the driver a normalized message", async () => { + const driver = mock() + const email = createEmail({ driver, defaults }) + await email.send({ ...msg, to: "Ada " }) + expect(driver.getInstance().last()?.to).toEqual([{ email: "ada@example.com", name: "Ada" }]) + }) + + it("survives a driver that throws", async () => { + const driver: EmailDriver = { + name: "boom", + send() { + throw new Error("kaboom") + }, + } + const { error } = await createEmail({ driver, defaults }).send(msg) + expect(error?.code).toBe("PROVIDER") + expect(error?.message).toContain("kaboom") + }) +}) + +describe("sendBatch", () => { + it("keeps results positional and reports partial failure", async () => { + const driver = mock({ failWhen: (_m, index) => index === 1 }) + const email = createEmail({ driver, defaults }) + const batch = await email.sendBatch([ + { ...msg, subject: "a" }, + { ...msg, subject: "b" }, + { ...msg, subject: "c" }, + ]) + + expect(batch.ok).toBe(false) + expect(batch.results).toHaveLength(3) + expect(batch.sent).toHaveLength(2) + expect(batch.failed).toEqual([{ index: 1, error: expect.any(Error) }]) + expect(batch.results[0]!.data).not.toBeNull() + expect(batch.results[1]!.error).not.toBeNull() + expect(batch.results[2]!.data).not.toBeNull() + }) + + it("does not let one invalid message take down the batch", async () => { + const driver = mock() + const batch = await createEmail({ driver, defaults }).sendBatch([ + { ...msg, subject: "good" }, + { ...msg, to: "nonsense", subject: "bad" }, + { ...msg, subject: "also good" }, + ]) + expect(batch.sent).toHaveLength(2) + expect(batch.failed[0]?.index).toBe(1) + expect(batch.failed[0]?.error.code).toBe("INVALID_OPTIONS") + expect(driver.getInstance().messages.map((m) => m.subject)).toEqual(["good", "also good"]) + }) + + it("uses the driver's native batch when there is one", async () => { + const sendBatch = vi.fn(async (msgs: readonly unknown[]) => + msgs.map((_, index) => ok({ id: `id_${index}`, driver: "native", at: new Date() })), + ) + const driver = { name: "native", send: vi.fn(), sendBatch } as unknown as EmailDriver + const batch = await createEmail({ driver, defaults }).sendBatch([msg, msg]) + expect(sendBatch).toHaveBeenCalledOnce() + expect(batch.sent.map((r) => r.id)).toEqual(["id_0", "id_1"]) + }) + + it("fails loudly when a driver loses the 1:1 mapping", async () => { + const driver = { + name: "sloppy", + send: vi.fn(), + sendBatch: async () => [ok({ id: "only-one", driver: "sloppy", at: new Date() })], + } as unknown as EmailDriver + const batch = await createEmail({ driver, defaults }).sendBatch([msg, msg]) + expect(batch.ok).toBe(false) + expect(batch.failed).toHaveLength(2) + expect(batch.failed[0]?.error.message).toMatch(/1 results for 2 messages/) + }) + + it("returns an empty batch for an empty input", async () => { + const batch = await createEmail({ driver: mock(), defaults }).sendBatch([]) + expect(batch).toMatchObject({ ok: true, results: [], sent: [], failed: [] }) + }) +}) + +describe("sendStream", () => { + it("yields one result per message", async () => { + const email = createEmail({ driver: mock(), defaults }) + const seen: Result[] = [] + const input = Array.from({ length: 7 }, (_, i) => ({ ...msg, subject: `s${i}` })) + for await (const result of email.sendStream(input, { chunkSize: 3 })) seen.push(result) + expect(seen).toHaveLength(7) + expect(seen.every((r) => r.error === null)).toBe(true) + }) + + it("accepts an async iterable source", async () => { + async function* source() { + yield { ...msg, subject: "a" } + yield { ...msg, subject: "b" } + } + const email = createEmail({ driver: mock(), defaults }) + const seen = [] + for await (const result of email.sendStream(source())) seen.push(result) + expect(seen).toHaveLength(2) + }) +}) + +describe("mounts", () => { + it("routes by stream and falls back to the default driver", async () => { + const primary = mock() + const broadcast = mock() + const email = createEmail({ driver: primary, defaults }).mount("broadcast", broadcast) + + await email.send(msg) + await email.send({ ...msg, stream: "broadcast" }) + await email.send({ ...msg, stream: "unknown" }) + + expect(primary.getInstance().messages).toHaveLength(2) + expect(broadcast.getInstance().messages).toHaveLength(1) + }) + + it("splits one batch across the drivers its messages target", async () => { + const primary = mock() + const broadcast = mock() + const email = createEmail({ driver: primary, defaults }).mount("broadcast", broadcast) + const batch = await email.sendBatch([ + { ...msg, subject: "a" }, + { ...msg, subject: "b", stream: "broadcast" }, + { ...msg, subject: "c" }, + ]) + expect(batch.sent).toHaveLength(3) + expect(primary.getInstance().messages.map((m) => m.subject)).toEqual(["a", "c"]) + expect(broadcast.getInstance().messages.map((m) => m.subject)).toEqual(["b"]) + }) + + it("unmount disposes by default and stops routing", async () => { + const broadcast = mock() + const dispose = vi.fn() + const email = createEmail({ driver: mock(), defaults }) + email.mount("b", { ...broadcast, dispose }) + await email.unmount("b") + expect(dispose).toHaveBeenCalledOnce() + expect(email.getMounts()).toEqual([]) + }) +}) + +describe("initialize", () => { + it("runs once even when sends race", async () => { + const initialize = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + }) + const email = createEmail({ driver: { ...mock(), initialize }, defaults }) + await Promise.all([email.send(msg), email.send(msg), email.send(msg)]) + expect(initialize).toHaveBeenCalledOnce() + }) + + it("finishes before the driver is asked to send", async () => { + const order: string[] = [] + const driver: EmailDriver = { + name: "ordered", + async initialize() { + await new Promise((resolve) => setTimeout(resolve, 5)) + order.push("initialize") + }, + send() { + order.push("send") + return ok({ id: "1", driver: "ordered", at: new Date() }) + }, + } + await Promise.all([ + createEmail({ driver, defaults }).send(msg), + createEmail({ driver, defaults }).send(msg), + ]) + expect(order[0]).toBe("initialize") + }) + + it("initializes a driver mounted after the first send", async () => { + const initialize = vi.fn() + const email = createEmail({ driver: mock(), defaults }) + await email.send(msg) + + email.mount("late", { ...mock(), initialize }) + await email.send({ ...msg, stream: "late" }) + expect(initialize).toHaveBeenCalledOnce() + }) + + it("surfaces an initialize failure as a Result", async () => { + const driver = { + ...mock(), + initialize: () => { + throw createError("mock", "AUTH", "bad credentials") + }, + } + const { error } = await createEmail({ driver, defaults }).send(msg) + expect(error?.code).toBe("AUTH") + }) +}) + +describe("cancel and retrieve", () => { + it("reports UNSUPPORTED rather than throwing", async () => { + const email = createEmail({ driver: mock(), defaults }) + expect((await email.cancel("x")).error?.code).toBe("UNSUPPORTED") + expect((await email.retrieve("x")).error?.code).toBe("UNSUPPORTED") + }) + + it("delegates to a driver that supports them", async () => { + const driver: EmailDriver = { + ...mock(), + cancel: async () => ok(undefined), + retrieve: async (id) => ok({ id, driver: "mock", state: "delivered" as const }), + } + const email = createEmail({ driver, defaults }) + expect((await email.cancel("x")).error).toBeNull() + expect((await email.retrieve("x")).data?.state).toBe("delivered") + }) +}) + +describe("middleware", () => { + it("runs outermost-first in registration order", async () => { + const order: string[] = [] + const tag = (name: string) => + defineMiddleware(name, (next) => async (msgs, ctx) => { + order.push(`>${name}`) + const results = await next(msgs, ctx) + order.push(`<${name}`) + return results + }) + + const email = createEmail({ driver: mock(), defaults }).use(tag("a")).use(tag("b")) + await email.send(msg) + expect(order).toEqual([">a", ">b", " { + const driver = mock() + const email = createEmail({ driver, defaults }).use( + defineMiddleware( + "rewrite", + (next) => (msgs, ctx) => + next( + msgs.map((m) => ({ ...m, subject: `[tagged] ${m.subject}` })), + ctx, + ), + ), + ) + await email.send(msg) + expect(driver.getInstance().last()?.subject).toBe("[tagged] hi") + }) + + it("contains a middleware that throws", async () => { + const email = createEmail({ driver: mock(), defaults }).use( + defineMiddleware("explodes", () => () => { + throw new Error("middleware bug") + }), + ) + const { error } = await email.send(msg) + expect(error?.message).toContain("middleware bug") + }) + + it("rejects a middleware that returns the wrong number of results", async () => { + const email = createEmail({ driver: mock(), defaults }).use( + defineMiddleware("drops", () => async () => [ + ok({ id: "1", driver: "mock", at: new Date() }), + ]), + ) + const batch = await email.sendBatch([msg, msg]) + expect(batch.ok).toBe(false) + expect(batch.failed[0]?.error.message).toMatch(/returned 1 of 2 results/) + }) + + it("is applied to every mounted driver", async () => { + const seen: string[] = [] + const email = createEmail({ driver: mock(), defaults }) + .mount("b", mock()) + .use( + defineMiddleware("watch", (next) => (msgs, ctx) => { + seen.push(ctx.driver) + return next(msgs, ctx) + }), + ) + await email.send(msg) + await email.send({ ...msg, stream: "b" }) + expect(seen).toEqual(["mock", "mock"]) + }) +}) + +describe("isAvailable", () => { + it("defaults to true and never propagates a throw", async () => { + const bare = createEmail({ + driver: { name: "bare", send: () => ok({ id: "1", driver: "bare", at: new Date() }) }, + defaults, + }) + expect(await bare.isAvailable()).toBe(true) + + const angry = createEmail({ + driver: { + name: "angry", + isAvailable() { + throw new Error("no") + }, + send: () => err(createError("angry", "PROVIDER", "no")), + }, + defaults, + }) + expect(await angry.isAvailable()).toBe(false) + }) +}) + +describe("dispose", () => { + it("disposes the default driver and every mount, once", async () => { + const disposeA = vi.fn() + const disposeB = vi.fn() + const email = createEmail({ driver: { ...mock(), dispose: disposeA }, defaults }) + email.mount("b", { ...mock(), dispose: disposeB }) + await email.dispose() + expect(disposeA).toHaveBeenCalledOnce() + expect(disposeB).toHaveBeenCalledOnce() + }) +}) diff --git a/test/core/message.test.ts b/test/core/message.test.ts new file mode 100644 index 0000000..7271716 --- /dev/null +++ b/test/core/message.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest" +import { getHeader, hasHeader, normalizeMessage, patchMessage } from "../../src/core/message.ts" +import { EmailError } from "../../src/core/error.ts" + +const base = { to: "ada@example.com", subject: "hi", text: "hello" } as const + +describe("normalizeMessage", () => { + it("parses every address form into a flat list", () => { + const msg = normalizeMessage({ + ...base, + from: "Acme ", + to: ["a@x.com", { email: "b@x.com", name: "Bee" }, "Cee "], + }) + expect(msg.from).toEqual({ email: "hi@acme.com", name: "Acme" }) + expect(msg.to).toEqual([ + { email: "a@x.com" }, + { email: "b@x.com", name: "Bee" }, + { email: "c@x.com", name: "Cee" }, + ]) + }) + + it("always presents lists, so drivers never branch on nullish", () => { + const msg = normalizeMessage({ ...base, from: "a@b.com" }) + expect(msg.cc).toEqual([]) + expect(msg.bcc).toEqual([]) + expect(msg.replyTo).toEqual([]) + expect(msg.attachments).toEqual([]) + expect(msg.tags).toEqual([]) + expect(msg.metadata).toEqual({}) + }) + + it("deduplicates recipients case-insensitively", () => { + const msg = normalizeMessage({ ...base, from: "a@b.com", to: ["X@Y.com", "x@y.com"] }) + expect(msg.to).toHaveLength(1) + }) + + it("takes `from` from the defaults when the message omits it", () => { + const msg = normalizeMessage(base, { from: "Default " }) + expect(msg.from.email).toBe("d@x.com") + }) + + it("lets the message override a default", () => { + const msg = normalizeMessage({ ...base, from: "own@x.com" }, { from: "d@x.com" }) + expect(msg.from.email).toBe("own@x.com") + }) + + it("merges default headers, tags and metadata", () => { + const msg = normalizeMessage( + { ...base, from: "a@b.com", headers: { "X-B": "2" }, tags: [{ name: "t2", value: "b" }] }, + { headers: { "X-A": "1" }, tags: [{ name: "t1", value: "a" }], metadata: { env: "prod" } }, + ) + expect(msg.headers).toMatchObject({ "X-A": "1", "X-B": "2" }) + expect(msg.tags.map((t) => t.name)).toEqual(["t1", "t2"]) + expect(msg.metadata).toEqual({ env: "prod" }) + }) + + it("rejects a missing `from`", () => { + expect(() => normalizeMessage(base)).toThrow(/`from` is required/) + }) + + it("rejects an empty `to`", () => { + expect(() => normalizeMessage({ ...base, from: "a@b.com", to: [] })).toThrow(/at least one/) + }) + + it("rejects a malformed address and names the field", () => { + expect(() => normalizeMessage({ ...base, from: "a@b.com", cc: "not-an-email" })).toThrow( + /`cc` contains an invalid address/, + ) + }) + + it("rejects a message with no body at all", () => { + expect(() => normalizeMessage({ from: "a@b.com", to: "c@d.com", subject: "x" })).toThrow( + /no body/, + ) + }) + + it("accepts a body supplied only as content", () => { + const msg = normalizeMessage({ + from: "a@b.com", + to: "c@d.com", + subject: "x", + content: { type: "react", element: null }, + }) + expect(msg.content?.type).toBe("react") + }) + + it("refuses a header value containing a line break", () => { + expect(() => + normalizeMessage({ ...base, from: "a@b.com", headers: { "X-Evil": "a\r\nBcc: v@x.com" } }), + ).toThrow(/line break/) + }) + + it("refuses a header name containing a line break", () => { + expect(() => + normalizeMessage({ ...base, from: "a@b.com", headers: { "X\nBcc": "v@x.com" } }), + ).toThrow(/line break/) + }) + + it("throws an EmailError with the INVALID_OPTIONS code", () => { + try { + normalizeMessage(base) + expect.unreachable() + } catch (error) { + expect(error).toBeInstanceOf(EmailError) + expect((error as EmailError).code).toBe("INVALID_OPTIONS") + } + }) + + it("parses scheduledAt and rejects nonsense", () => { + const msg = normalizeMessage({ ...base, from: "a@b.com", scheduledAt: "2030-01-01T00:00:00Z" }) + expect(msg.scheduledAt?.toISOString()).toBe("2030-01-01T00:00:00.000Z") + expect(() => normalizeMessage({ ...base, from: "a@b.com", scheduledAt: "later" })).toThrow( + /not a valid date/, + ) + }) + + it("never mutates the caller's object", () => { + const input = { ...base, from: "a@b.com", html: "

x

", preheader: "peek" } + const snapshot = structuredClone(input) + normalizeMessage(input) + expect(input).toEqual(snapshot) + }) + + it("freezes the result so a driver cannot alter a shared message", () => { + const msg = normalizeMessage({ ...base, from: "a@b.com" }) + expect(Object.isFrozen(msg)).toBe(true) + }) + + describe("unsubscribe headers", () => { + it("derives List-Unsubscribe and the one-click post", () => { + const msg = normalizeMessage({ + ...base, + from: "a@b.com", + unsubscribe: { url: "https://acme.com/u/1", mailto: "unsub@acme.com" }, + }) + expect(msg.headers["List-Unsubscribe"]).toBe( + ", ", + ) + expect(msg.headers["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click") + }) + + it("omits the one-click post for a mailto-only unsubscribe", () => { + const msg = normalizeMessage({ + ...base, + from: "a@b.com", + unsubscribe: { mailto: "unsub@acme.com" }, + }) + expect(msg.headers["List-Unsubscribe"]).toBe("") + expect(msg.headers["List-Unsubscribe-Post"]).toBeUndefined() + }) + + it("does not overwrite a header the caller set explicitly", () => { + const msg = normalizeMessage({ + ...base, + from: "a@b.com", + headers: { "list-unsubscribe": "" }, + unsubscribe: { url: "https://derived" }, + }) + expect(msg.headers["list-unsubscribe"]).toBe("") + expect(msg.headers["List-Unsubscribe"]).toBeUndefined() + }) + }) + + describe("preheader", () => { + it("injects a hidden block just inside ", () => { + const msg = normalizeMessage({ + ...base, + from: "a@b.com", + html: "

Hi

", + preheader: "Your code is inside", + }) + expect(msg.html).toMatch(/
Your code is inside/) + }) + + it("prepends when there is no body tag", () => { + const msg = normalizeMessage({ ...base, from: "a@b.com", html: "

Hi

", preheader: "x" }) + expect(msg.html?.startsWith("
{ + const msg = normalizeMessage({ + ...base, + from: "a@b.com", + html: "

Hi

", + preheader: '', + }) + expect(msg.html).not.toContain("") - expect(out).toBe("hi") - }) - - it("breaks block tags with newlines", () => { - expect(htmlToText("

a

b

")).toBe("a\n\nb") - }) - - it("keeps
as a single newline", () => { - expect(htmlToText("a
b
c")).toBe("a\nb\nc") - }) - - it("renders with href fallback when text differs", () => { - expect(htmlToText(`

Click here please

`)).toBe( - "Click here (https://x.co/y) please", - ) - }) - - it("collapses entities", () => { - expect(htmlToText("

1 & 2 < 3

")).toBe("1 & 2 < 3") - }) -}) diff --git a/test/render/middleware.test.ts b/test/render/middleware.test.ts deleted file mode 100644 index 4886e6d..0000000 --- a/test/render/middleware.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vitest" -import { createEmail } from "../../src/index.ts" -import { withRender } from "../../src/render/index.ts" -import reactRenderer from "../../src/render/react.ts" -import mjmlRenderer from "../../src/render/mjml.ts" -import mock from "../../src/driver/mock.ts" - -describe("withRender middleware", () => { - it("turns `react:` into `html` + derives text", async () => { - const driver = mock() - const email = createEmail({ driver }) - email.use( - withRender( - reactRenderer({ - render: async (el) => `

Hello ${(el as { name: string }).name}

`, - }), - ), - ) - - const { error } = await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - react: { name: "Ada" }, - }) - expect(error).toBeNull() - const sent = driver.getInstance?.()?.[0] as { html?: string; text?: string } - expect(sent?.html).toBe("

Hello Ada

") - expect(sent?.text).toBe("Hello Ada") - }) - - it("does not derive text when user supplied it", async () => { - const driver = mock() - const email = createEmail({ driver }) - email.use(withRender(reactRenderer({ render: async () => "

HTML only

" }))) - await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - react: {}, - text: "custom fallback", - }) - const sent = driver.getInstance?.()?.[0] - expect(sent?.text).toBe("custom fallback") - }) - - it("picks the first matching renderer when multiple are registered", async () => { - const driver = mock() - const email = createEmail({ driver }) - email.use( - withRender( - reactRenderer({ render: async () => "

from-react

" }), - mjmlRenderer({ compile: () => "

from-mjml

" }), - ), - ) - // Only mjml is set — first renderer declines, second handles it. - await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - mjml: "", - }) - const sent = driver.getInstance?.()?.[0] - expect(sent?.html).toBe("

from-mjml

") - }) - - it("reactRenderer throws a helpful error when the peer isn't installed", async () => { - const r = reactRenderer() // no `render` override → tries to import @react-email/render - await expect( - r.render({ react: {}, from: "a@b.com", to: "c@d.com", subject: "x" }), - ).rejects.toThrow(/@react-email\/render/) - }) -}) diff --git a/test/render/pipeline.test.ts b/test/render/pipeline.test.ts deleted file mode 100644 index 3ffa094..0000000 --- a/test/render/pipeline.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest" -import { createEmail } from "../../src/index.ts" -import { cidRewrite, darkModeHook, htmlPipeline, withPreheader } from "../../src/render/pipeline.ts" -import mock from "../../src/driver/mock.ts" - -describe("html pipeline", () => { - it("withPreheader injects a hidden preview snippet", async () => { - const driver = mock() - const email = createEmail({ driver }) - email.use(htmlPipeline(withPreheader())) - await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - preheader: "Your OTP is ready", - html: "

Hello

", - }) - const sent = driver.getInstance?.()?.[0]?.html ?? "" - expect(sent).toMatch(/display:none/) - expect(sent).toContain("Your OTP is ready") - }) - - it("darkModeHook injects supported-color-schemes meta", async () => { - const driver = mock() - const email = createEmail({ driver }) - email.use(htmlPipeline(darkModeHook({ darkCss: "body{background:#000}" }))) - await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - html: "x", - }) - const sent = driver.getInstance?.()?.[0]?.html ?? "" - expect(sent).toContain('name="color-scheme"') - expect(sent).toContain("prefers-color-scheme: dark") - }) - - it("cidRewrite converts matching to cid: refs", async () => { - const driver = mock() - const email = createEmail({ driver }) - email.use(htmlPipeline(cidRewrite())) - await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - html: '', - attachments: [ - { filename: "logo.png", cid: "logo", content: "data", contentType: "image/png" }, - ], - }) - const sent = driver.getInstance?.()?.[0]?.html ?? "" - expect(sent).toContain('src="cid:logo"') - expect(sent).toContain("unmatched.png") - }) -}) diff --git a/test/render/render.test.ts b/test/render/render.test.ts new file mode 100644 index 0000000..e07c9f3 --- /dev/null +++ b/test/render/render.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest" +import type { Renderer } from "../../src/render/index.ts" +import { createEmail } from "../../src/core/email.ts" +import mock from "../../src/drivers/mock.ts" +import { defineTemplate, htmlToText, withRender } from "../../src/render/index.ts" +import reactRenderer from "../../src/render/react.ts" + +const defaults = { from: "hi@acme.com" } + +const upper: Renderer = { + name: "upper", + type: "upper", + render: (content) => ({ html: `

${String(content.source).toUpperCase()}

` }), +} + +describe("withRender", () => { + it("resolves content into html before the driver sees it", async () => { + const driver = mock() + const email = createEmail({ driver, defaults, use: [withRender(upper)] }) + await email.send({ + to: "ada@example.com", + subject: "hi", + content: { type: "upper", source: "hello" }, + }) + + const sent = driver.getInstance().last()! + expect(sent.html).toBe("

HELLO

") + expect(sent.content).toBeUndefined() + }) + + it("derives a text alternative from the html", async () => { + const driver = mock() + await createEmail({ driver, defaults, use: [withRender(upper)] }).send({ + to: "ada@example.com", + subject: "hi", + content: { type: "upper", source: "hello" }, + }) + expect(driver.getInstance().last()?.text).toBe("HELLO") + }) + + it("leaves an explicit text alone", async () => { + const driver = mock() + await createEmail({ driver, defaults, use: [withRender(upper)] }).send({ + to: "ada@example.com", + subject: "hi", + text: "written by hand", + content: { type: "upper", source: "hello" }, + }) + expect(driver.getInstance().last()?.text).toBe("written by hand") + }) + + it("skips the derivation when autoText is off", async () => { + const driver = mock() + await createEmail({ driver, defaults, use: [withRender(upper, { autoText: false })] }).send({ + to: "ada@example.com", + subject: "hi", + content: { type: "upper", source: "hello" }, + }) + expect(driver.getInstance().last()?.text).toBeUndefined() + }) + + it("prefers a text the renderer produced itself", async () => { + const driver = mock() + const withOwnText: Renderer = { + name: "own", + type: "own", + render: () => ({ html: "

rich

", text: "renderer's own text" }), + } + await createEmail({ driver, defaults, use: [withRender(withOwnText)] }).send({ + to: "ada@example.com", + subject: "hi", + content: { type: "own" }, + }) + expect(driver.getInstance().last()?.text).toBe("renderer's own text") + }) + + it("leaves a message without content untouched", async () => { + const driver = mock() + await createEmail({ driver, defaults, use: [withRender(upper)] }).send({ + to: "ada@example.com", + subject: "hi", + html: "

already rendered

", + }) + expect(driver.getInstance().last()?.html).toBe("

already rendered

") + }) + + it("fails the send when no renderer claims the content type", async () => { + const { error } = await createEmail({ + driver: mock(), + defaults, + use: [withRender(upper)], + }).send({ to: "ada@example.com", subject: "hi", content: { type: "mjml", source: "x" } }) + expect(error?.code).toBe("INVALID_OPTIONS") + expect(error?.message).toMatch(/no renderer registered/) + }) + + it("never writes back into the caller's message object", async () => { + const template = { + to: "ada@example.com", + subject: "hi", + content: { type: "upper", source: "hello" }, + } as const + const snapshot = structuredClone(template) + const email = createEmail({ driver: mock(), defaults, use: [withRender(upper)] }) + await email.send(template) + await email.send(template) + expect(template).toEqual(snapshot) + }) + + it("renders every message in a batch", async () => { + const driver = mock() + const email = createEmail({ driver, defaults, use: [withRender(upper)] }) + await email.sendBatch([ + { to: "a@x.com", subject: "1", content: { type: "upper", source: "one" } }, + { to: "b@x.com", subject: "2", content: { type: "upper", source: "two" } }, + ]) + expect(driver.getInstance().messages.map((m) => m.html)).toEqual(["

ONE

", "

TWO

"]) + }) +}) + +describe("defineTemplate", () => { + it("produces a partial message from typed variables", async () => { + const welcome = defineTemplate<{ name: string }>(({ name }) => ({ + subject: `Welcome, ${name}`, + content: { type: "upper", source: `hello ${name}` }, + })) + + const driver = mock() + await createEmail({ driver, defaults, use: [withRender(upper)] }).send({ + to: "ada@example.com", + ...welcome({ name: "Ada" }), + subject: welcome({ name: "Ada" }).subject!, + }) + + const sent = driver.getInstance().last()! + expect(sent.subject).toBe("Welcome, Ada") + expect(sent.html).toBe("

HELLO ADA

") + }) +}) + +describe("reactRenderer", () => { + it("uses an injected render function", async () => { + const driver = mock() + const renderer = reactRenderer({ + render: (element, options) => + options?.plainText ? `text:${String(element)}` : `

${String(element)}

`, + }) + await createEmail({ driver, defaults, use: [withRender(renderer)] }).send({ + to: "ada@example.com", + subject: "hi", + content: { type: "react", element: "Welcome" }, + }) + + const sent = driver.getInstance().last()! + expect(sent.html).toBe("

Welcome

") + expect(sent.text).toBe("text:Welcome") + }) + + it("requires an element", async () => { + const { error } = await createEmail({ + driver: mock(), + defaults, + use: [withRender(reactRenderer({ render: () => "

" }))], + }).send({ to: "ada@example.com", subject: "hi", content: { type: "react", element: null } }) + expect(error?.message).toMatch(/`content.element` is required/) + }) + + it("explains itself when the optional peer is missing", async () => { + const { error } = await createEmail({ + driver: mock(), + defaults, + use: [withRender(reactRenderer())], + }).send({ to: "ada@example.com", subject: "hi", content: { type: "react", element: "x" } }) + expect(error?.message).toMatch(/@react-email\/render` is not installed/) + }) +}) + +describe("htmlToText", () => { + it("turns block tags into line breaks", () => { + expect(htmlToText("

one

two

")).toBe("one\n\ntwo") + }) + + it("turns
into a single newline", () => { + expect(htmlToText("a
b")).toBe("a\nb") + }) + + it("keeps a link's destination", () => { + expect(htmlToText('Acme')).toBe("Acme (https://acme.com)") + }) + + it("does not repeat a link whose text is its own href", () => { + expect(htmlToText('https://acme.com')).toBe("https://acme.com") + }) + + it("drops scripts and styles entirely", () => { + expect(htmlToText("

safe

")).toBe("safe") + }) + + it("decodes entities without double-decoding", () => { + expect(htmlToText("

a & b

")).toBe("a & b") + expect(htmlToText("

&lt;

")).toBe("<") + expect(htmlToText("

€ €

")).toBe("€ €") + }) + + it("collapses runs of blank lines", () => { + expect(htmlToText("
x
")).toBe("x") + }) +}) diff --git a/test/result/result.test.ts b/test/result/result.test.ts deleted file mode 100644 index 4c9f547..0000000 --- a/test/result/result.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest" -import { isErr, isOk, mapErr, mapOk, tryAsync, unwrap, unwrapOr } from "../../src/result/index.ts" -import { createError } from "../../src/errors.ts" -import type { Result } from "../../src/types.ts" - -function ok(data: T): Result { - return { data, error: null } -} - -function err(message = "boom"): Result { - return { data: null, error: createError("x", "PROVIDER", message) } -} - -describe("Result helpers", () => { - it("isOk / isErr narrow correctly", () => { - const r: Result = ok(42) - expect(isOk(r)).toBe(true) - expect(isErr(r)).toBe(false) - }) - - it("unwrap throws on Err", () => { - expect(() => unwrap(err())).toThrow(/boom/) - }) - - it("unwrapOr returns fallback on Err", () => { - expect(unwrapOr(err(), 7)).toBe(7) - expect(unwrapOr(ok(3), 7)).toBe(3) - }) - - it("mapOk transforms data", () => { - const r = mapOk(ok(4), (n) => n * 2) - expect(r.data).toBe(8) - }) - - it("mapOk passes Err through", () => { - const r = mapOk(err(), (n) => n * 2) - expect(r.error?.message).toContain("boom") - }) - - it("mapErr transforms the error", () => { - const original = err("original") - const mapped = mapErr(original, (e) => - createError(e.driver, "TIMEOUT", `wrapped: ${e.message}`), - ) - expect(mapped.error?.code).toBe("TIMEOUT") - expect(mapped.error?.message).toContain("wrapped") - }) - - it("tryAsync captures thrown errors via the wrapper", async () => { - const r = await tryAsync( - async () => { - throw new Error("oops") - }, - (e) => createError("x", "NETWORK", (e as Error).message), - ) - expect(r.error?.code).toBe("NETWORK") - expect(r.error?.message).toContain("oops") - }) - - it("tryAsync returns data on resolution", async () => { - const r = await tryAsync( - async () => 99, - () => createError("x", "PROVIDER", "unused"), - ) - expect(r.data).toBe(99) - }) -}) diff --git a/test/suppression/store.test.ts b/test/suppression/store.test.ts deleted file mode 100644 index dfe9c4d..0000000 --- a/test/suppression/store.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from "vitest" -import { memorySuppressionStore, unstorageSuppressionStore } from "../../src/suppression/index.ts" - -describe("memorySuppressionStore", () => { - it("stores and retrieves suppression records", async () => { - const store = memorySuppressionStore() - await store.add("ada@acme.com", "bounce", "ses-webhook") - const rec = await store.has("ada@acme.com") - expect(rec?.reason).toBe("bounce") - expect(rec?.source).toBe("ses-webhook") - }) - - it("is case-insensitive", async () => { - const store = memorySuppressionStore() - await store.add("Ada@ACME.com", "bounce") - expect(await store.has("ada@acme.com")).not.toBeNull() - }) - - it("remove() deletes the record", async () => { - const store = memorySuppressionStore() - await store.add("a@b.com", "bounce") - await store.remove("a@b.com") - expect(await store.has("a@b.com")).toBeNull() - }) - - it("list() enumerates all records", async () => { - const store = memorySuppressionStore() - await store.add("a@b.com", "bounce") - await store.add("c@d.com", "complaint") - const all = await store.list!() - expect(all).toHaveLength(2) - }) -}) - -describe("unstorageSuppressionStore", () => { - function fakeStorage() { - const map = new Map() - return { - getItem: (k: string) => map.get(k) ?? null, - setItem: (k: string, v: unknown) => { - map.set(k, v) - }, - removeItem: (k: string) => { - map.delete(k) - }, - getKeys: (prefix?: string) => - Array.from(map.keys()).filter((k) => !prefix || k.startsWith(prefix)), - } - } - - it("round-trips records through an unstorage-like backend", async () => { - const store = unstorageSuppressionStore(fakeStorage()) - await store.add("a@b.com", "bounce", "ses") - const rec = await store.has("a@b.com") - expect(rec).not.toBeNull() - expect(rec!.reason).toBe("bounce") - expect(rec!.at).toBeInstanceOf(Date) - }) -}) diff --git a/test/test/inbox.test.ts b/test/test/inbox.test.ts deleted file mode 100644 index 5fd6a99..0000000 --- a/test/test/inbox.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest" -import { createTestEmail } from "../../src/test/index.ts" - -describe("createTestEmail", () => { - it("records sends in the inbox", async () => { - const email = createTestEmail() - await email.send({ from: "a@b.com", to: "c@d.com", subject: "hi", text: "x" }) - expect(email.inbox).toHaveLength(1) - expect(email.last?.subject).toBe("hi") - }) - - it("supports find / filter", async () => { - const email = createTestEmail() - await email.send({ from: "a@b.com", to: "one@x.com", subject: "welcome", text: "" }) - await email.send({ from: "a@b.com", to: "two@x.com", subject: "reminder", text: "" }) - await email.send({ from: "a@b.com", to: "three@x.com", subject: "welcome", text: "" }) - expect(email.filter((m) => m.subject === "welcome")).toHaveLength(2) - expect(email.find((m) => m.subject === "reminder")?.to).toBe("two@x.com") - }) - - it("clears inbox without disposing", async () => { - const email = createTestEmail() - await email.send({ from: "a@b.com", to: "c@d.com", subject: "hi", text: "x" }) - email.clear() - expect(email.inbox).toHaveLength(0) - await email.send({ from: "a@b.com", to: "c@d.com", subject: "hi2", text: "x" }) - expect(email.inbox).toHaveLength(1) - }) - - it("waitFor resolves when a matching message arrives", async () => { - const email = createTestEmail() - const pending = email.waitFor((m) => m.subject === "target", { timeout: 500, interval: 5 }) - setTimeout(() => { - email.send({ from: "a@b.com", to: "c@d.com", subject: "noise", text: "" }).catch(() => {}) - email.send({ from: "a@b.com", to: "c@d.com", subject: "target", text: "" }).catch(() => {}) - }, 20) - const msg = await pending - expect(msg.subject).toBe("target") - }) - - it("waitFor rejects on timeout", async () => { - const email = createTestEmail() - await expect( - email.waitFor((m) => m.subject === "never", { timeout: 50, interval: 5 }), - ).rejects.toThrow(/waitFor timed out/) - }) -}) diff --git a/test/test/matchers.test.ts b/test/test/matchers.test.ts deleted file mode 100644 index 22fb16a..0000000 --- a/test/test/matchers.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from "vitest" -import { - createTestEmail, - emailMatchers, - matchesEmail, - toEmailSnapshot, -} from "../../src/test/index.ts" - -expect.extend(emailMatchers) - -describe("emailMatchers", () => { - it("matches by subject regex", async () => { - const email = createTestEmail() - await email.send({ from: "a@b.com", to: "c@d.com", subject: "Welcome Ada", text: "" }) - expect(emailMatchers.toHaveSent(email, { subject: /welcome/i }).pass).toBe(true) - }) - - it("matches by recipient email", async () => { - const email = createTestEmail() - await email.send({ from: "a@b.com", to: "Ada ", subject: "hi", text: "" }) - expect(emailMatchers.toHaveSent(email, { to: "ada@acme.com" }).pass).toBe(true) - }) - - it("fails cleanly when no message matches", async () => { - const email = createTestEmail() - await email.send({ from: "a@b.com", to: "c@d.com", subject: "hi", text: "" }) - const result = emailMatchers.toHaveSent(email, { subject: "missing" }) - expect(result.pass).toBe(false) - expect(result.message()).toMatch(/expected an email to match/) - }) -}) - -describe("emailMatchers — extended", () => { - it("toHaveSentTo matches a recipient", async () => { - const email = createTestEmail() - await email.send({ - from: "a@b.com", - to: ["Ada ", "bob@acme.com"], - cc: "c@d.com", - subject: "hi", - text: "", - }) - expect(emailMatchers.toHaveSentTo(email, "bob@acme.com").pass).toBe(true) - expect(emailMatchers.toHaveSentTo(email, "c@d.com").pass).toBe(true) - expect(emailMatchers.toHaveSentTo(email, "nobody@x.com").pass).toBe(false) - }) - - it("toHaveSentWithSubject supports strings and regex", async () => { - const email = createTestEmail() - await email.send({ from: "a@b.com", to: "c@d.com", subject: "Welcome", text: "" }) - expect(emailMatchers.toHaveSentWithSubject(email, "Welcome").pass).toBe(true) - expect(emailMatchers.toHaveSentWithSubject(email, /wel/i).pass).toBe(true) - expect(emailMatchers.toHaveSentWithSubject(email, "other").pass).toBe(false) - }) - - it("toHaveSentWithAttachment matches by filename and predicate", async () => { - const email = createTestEmail() - await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - text: "", - attachments: [ - { filename: "invite.ics", content: "BEGIN:VCALENDAR", contentType: "text/calendar" }, - ], - }) - expect(emailMatchers.toHaveSentWithAttachment(email, "invite.ics").pass).toBe(true) - expect( - emailMatchers.toHaveSentWithAttachment(email, (a) => a.contentType === "text/calendar").pass, - ).toBe(true) - expect(emailMatchers.toHaveSentWithAttachment(email, "absent.pdf").pass).toBe(false) - }) - - it("toHaveSentMatching runs a custom predicate", async () => { - const email = createTestEmail() - await email.send({ - from: "a@b.com", - to: "c@d.com", - subject: "hi", - text: "", - tags: [{ name: "campaign", value: "welcome-v2" }], - }) - expect( - emailMatchers.toHaveSentMatching(email, (m) => - (m.tags ?? []).some((t) => t.name === "campaign" && t.value === "welcome-v2"), - ).pass, - ).toBe(true) - }) -}) - -describe("toEmailSnapshot", () => { - it("returns a stable shape and drops Message-ID / Date headers", () => { - const snap = toEmailSnapshot({ - from: "Ada ", - to: "bob@x.com", - subject: "hi", - text: "body", - headers: { - "Message-ID": "", - Date: "Wed, 01 Jan 2020 00:00:00 GMT", - "X-App": "unemail", - }, - }) - expect(snap).toMatchObject({ - from: ["ada@acme.com"], - to: ["bob@x.com"], - subject: "hi", - text: "body", - headers: { "X-App": "unemail" }, - }) - expect((snap.headers as Record)["Message-ID"]).toBeUndefined() - }) -}) - -describe("matchesEmail", () => { - it("matches string fields", () => { - const match = matchesEmail({ from: "a@b.com", to: "c@d.com", subject: "hi" }, { subject: "hi" }) - expect(match.pass).toBe(true) - }) - it("rejects mismatches with a diff", () => { - const match = matchesEmail( - { from: "a@b.com", to: "c@d.com", subject: "hi" }, - { subject: "not-hi" }, - ) - expect(match.pass).toBe(false) - expect(match.diff).toMatch(/expected subject/) - }) -}) diff --git a/test/verify/verify.test.ts b/test/verify/verify.test.ts deleted file mode 100644 index 6f245f0..0000000 --- a/test/verify/verify.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vitest" -import { - parseAuthenticationResults, - verifyAll, - verifyDkim, - verifyDmarc, - verifySpf, -} from "../../src/verify/index.ts" -import type { ParsedEmail } from "../../src/parse/index.ts" - -function mailWith(authHeader?: string): ParsedEmail { - return { - to: [], - cc: [], - bcc: [], - references: [], - attachments: [], - headers: authHeader ? { "authentication-results": authHeader } : {}, - } -} - -describe("parseAuthenticationResults", () => { - it("extracts dkim/spf/dmarc outcomes and the authenticating domain", () => { - const out = parseAuthenticationResults( - "mx.google.com; dkim=pass header.d=example.com; spf=pass smtp.mailfrom=example.com; dmarc=pass", - ) - expect(out.dkim).toBe("pass") - expect(out.spf).toBe("pass") - expect(out.dmarc).toBe("pass") - expect(out.authenticatedDomain).toBe("example.com") - }) - - it("returns `none` when the header is missing", () => { - const out = parseAuthenticationResults(undefined) - expect(out).toMatchObject({ dkim: "none", spf: "none", dmarc: "none" }) - }) - - it("captures fail outcomes", () => { - const out = parseAuthenticationResults( - "mx.google.com; dkim=fail; spf=softfail; dmarc=temperror", - ) - expect(out.dkim).toBe("fail") - expect(out.spf).toBe("softfail") - expect(out.dmarc).toBe("temperror") - }) -}) - -describe("verify*", () => { - const happy = mailWith("mx; dkim=pass header.d=example.com; spf=pass; dmarc=pass") - - it("each helper returns the right slice", () => { - expect(verifyDkim(happy)).toBe("pass") - expect(verifySpf(happy)).toBe("pass") - expect(verifyDmarc(happy)).toBe("pass") - }) - - it("verifyAll prefers the async callback over header parsing", async () => { - const header = mailWith("mx; dkim=fail; spf=fail; dmarc=fail") - const out = await verifyAll(header, { - verify: () => ({ - dkim: "pass", - spf: "pass", - dmarc: "pass", - authenticatedDomain: "override.com", - }), - }) - expect(out).toEqual({ - dkim: "pass", - spf: "pass", - dmarc: "pass", - authenticatedDomain: "override.com", - }) - }) -}) diff --git a/test/webhook/standard.test.ts b/test/webhook/standard.test.ts deleted file mode 100644 index 3558ba6..0000000 --- a/test/webhook/standard.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest" -import { signStandardWebhook, verifyStandardWebhook } from "../../src/webhook/standard.ts" - -// Test fixture. Not a real secret; pattern avoids the `whsec_` prefix -// that GitHub's secret scanner flags as a Stripe webhook signing key. -const SECRET = "dGVzdC1maXh0dXJlLW9ubHktbm90LWEtcmVhbC1zZWNyZXQ=" - -function request(body: string, headers: Record, method = "POST"): Request { - const init: RequestInit = { method, headers } - if (method !== "GET" && method !== "HEAD") init.body = body - return new Request("https://app/webhook", init) -} - -describe("Standard Webhooks", () => { - it("round-trips sign → verify", async () => { - const ts = Math.floor(Date.now() / 1000) - const body = JSON.stringify({ event: "test" }) - const sig = await signStandardWebhook(SECRET, "msg_1", ts, body) - const payload = await verifyStandardWebhook( - request(body, { - "webhook-id": "msg_1", - "webhook-timestamp": String(ts), - "webhook-signature": sig, - }), - { secret: SECRET }, - ) - expect(JSON.parse(payload)).toEqual({ event: "test" }) - }) - - it("rejects mismatched signatures", async () => { - const ts = Math.floor(Date.now() / 1000) - await expect( - verifyStandardWebhook( - request("{}", { - "webhook-id": "msg_2", - "webhook-timestamp": String(ts), - "webhook-signature": "v1,not-a-real-sig", - }), - { secret: SECRET }, - ), - ).rejects.toThrow(/signature/) - }) - - it("rejects stale timestamps", async () => { - const ts = Math.floor(Date.now() / 1000) - 60 * 60 // 1 hour ago - const body = "{}" - const sig = await signStandardWebhook(SECRET, "msg_3", ts, body) - await expect( - verifyStandardWebhook( - request(body, { - "webhook-id": "msg_3", - "webhook-timestamp": String(ts), - "webhook-signature": sig, - }), - { secret: SECRET }, - ), - ).rejects.toThrow(/tolerance/) - }) - - it("accepts multiple space-separated signatures (rotation)", async () => { - const ts = Math.floor(Date.now() / 1000) - const body = "{}" - const good = await signStandardWebhook(SECRET, "msg_4", ts, body) - const combined = `v1,old-garbage ${good}` - const out = await verifyStandardWebhook( - request(body, { - "webhook-id": "msg_4", - "webhook-timestamp": String(ts), - "webhook-signature": combined, - }), - { secret: SECRET }, - ) - expect(out).toBe(body) - }) - - it("accepts a secret with the whsec_ prefix and strips it", async () => { - const ts = Math.floor(Date.now() / 1000) - const body = "{}" - // Rebuild the prefixed form at runtime so static scanners don't - // flag this as a leaked webhook signing key. - const prefixed = ["whsec", SECRET].join("_") - const sig = await signStandardWebhook(prefixed, "msg_5", ts, body) - const out = await verifyStandardWebhook( - request(body, { - "webhook-id": "msg_5", - "webhook-timestamp": String(ts), - "webhook-signature": sig, - }), - { secret: prefixed }, - ) - expect(out).toBe(body) - }) -}) diff --git a/test/webhook/webhooks.test.ts b/test/webhook/webhooks.test.ts deleted file mode 100644 index 9a625b1..0000000 --- a/test/webhook/webhooks.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, expect, it } from "vitest" -import { defineWebhookHandler } from "../../src/webhook/index.ts" -import mailgunWebhook from "../../src/webhook/mailgun.ts" -import postmarkWebhook from "../../src/webhook/postmark.ts" -import sesWebhook from "../../src/webhook/ses.ts" -import { webCryptoHmacHex } from "../../src/webhook/_crypto.ts" - -function jsonRequest(body: unknown, headers: Record = {}): Request { - return new Request("https://example.com/webhook", { - method: "POST", - headers: { "content-type": "application/json", ...headers }, - body: JSON.stringify(body), - }) -} - -describe("mailgun webhook", () => { - const signingKey = "mg-signing-key" - const ts = "1000000000" - const token = "tok-abc" - - it("accepts a correctly signed payload", async () => { - const signature = await webCryptoHmacHex("SHA-256", signingKey, `${ts}${token}`) - const handler = defineWebhookHandler({ - providers: [ - mailgunWebhook({ signingKey, toleranceSeconds: 10 ** 10, now: () => Number(ts) }), - ], - onEvent: (event) => { - events.push(event.type) - }, - }) - const events: string[] = [] - const res = await handler( - jsonRequest({ - signature: { timestamp: ts, token, signature }, - "event-data": { - id: "evt_1", - event: "delivered", - recipient: "a@b.com", - timestamp: Number(ts), - }, - }), - ) - expect(res.status).toBe(200) - expect(events).toEqual(["delivered"]) - }) - - it("rejects a mismatched signature", async () => { - const handler = defineWebhookHandler({ - providers: [ - mailgunWebhook({ signingKey, toleranceSeconds: 10 ** 10, now: () => Number(ts) }), - ], - onEvent: () => {}, - }) - const res = await handler( - jsonRequest({ - signature: { timestamp: ts, token, signature: "deadbeef" }, - "event-data": { - id: "evt_1", - event: "delivered", - recipient: "a@b.com", - timestamp: Number(ts), - }, - }), - ) - expect(res.status).toBe(401) - }) - - it("rejects a stale timestamp", async () => { - const signature = await webCryptoHmacHex("SHA-256", signingKey, `${ts}${token}`) - const handler = defineWebhookHandler({ - providers: [ - mailgunWebhook({ signingKey, toleranceSeconds: 60, now: () => Number(ts) + 1_000_000 }), - ], - onEvent: () => {}, - }) - const res = await handler( - jsonRequest({ - signature: { timestamp: ts, token, signature }, - "event-data": { id: "evt_1", event: "delivered", recipient: "a@b.com" }, - }), - ) - expect(res.status).toBe(401) - }) -}) - -describe("postmark webhook", () => { - it("normalizes delivery + bounce + click events", async () => { - const events: Array<{ type: string; bounce?: string; url?: string }> = [] - const handler = defineWebhookHandler({ - providers: [postmarkWebhook()], - onEvent: (e) => { - events.push({ type: e.type, bounce: e.bounce, url: e.url }) - }, - }) - await handler( - jsonRequest( - { - RecordType: "Bounce", - MessageID: "pm_1", - Recipient: "a@b.com", - Type: "HardBounce", - BouncedAt: "2026-04-17T12:00:00Z", - }, - { "user-agent": "Postmark/webhook" }, - ), - ) - await handler( - jsonRequest( - { - RecordType: "Click", - MessageID: "pm_2", - Recipient: "a@b.com", - OriginalLink: "https://x.co/y", - ReceivedAt: "2026-04-17T12:00:00Z", - }, - { "user-agent": "Postmark/webhook" }, - ), - ) - expect(events).toEqual([ - { type: "bounced", bounce: "hard", url: undefined }, - { type: "clicked", bounce: undefined, url: "https://x.co/y" }, - ]) - }) -}) - -describe("ses webhook", () => { - it("normalizes a Bounce message nested in an SNS envelope", async () => { - const events: Array<{ type: string; recipient: string; bounce?: string }> = [] - const handler = defineWebhookHandler({ - providers: [sesWebhook()], - onEvent: (e) => { - events.push({ type: e.type, recipient: e.recipient, bounce: e.bounce }) - }, - }) - const message = { - eventType: "Bounce", - mail: { messageId: "ses_1", timestamp: "2026-04-17T12:00:00Z", destination: ["a@b.com"] }, - bounce: { bounceType: "Permanent", bouncedRecipients: [{ emailAddress: "a@b.com" }] }, - } - const res = await handler( - jsonRequest( - { Type: "Notification", Message: JSON.stringify(message), MessageId: "sns_1" }, - { "x-amz-sns-message-type": "Notification" }, - ), - ) - expect(res.status).toBe(200) - expect(events).toEqual([{ type: "bounced", recipient: "a@b.com", bounce: "hard" }]) - }) - - it("respects topicArns allow-list", async () => { - const events: unknown[] = [] - const handler = defineWebhookHandler({ - providers: [sesWebhook({ topicArns: ["arn:aws:sns:us-east-1:111111:allowed"] })], - onEvent: (e) => { - events.push(e) - }, - }) - const res = await handler( - jsonRequest( - { - Type: "Notification", - TopicArn: "arn:aws:sns:us-east-1:111111:other", - Message: JSON.stringify({}), - }, - { "x-amz-sns-message-type": "Notification" }, - ), - ) - expect(res.status).toBe(401) - expect(events).toEqual([]) - }) -}) diff --git a/tsconfig.json b/tsconfig.json index 8ba0ea5..7e40490 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,9 @@ "forceConsistentCasingInFileNames": true, "noImplicitOverride": true, "noEmit": true, - "types": ["node"] + "types": ["node"], + "isolatedDeclarations": true, + "declaration": true }, - "include": ["src", "test"] + "include": ["src", "test", "types"] } diff --git a/types/react-email.d.ts b/types/react-email.d.ts new file mode 100644 index 0000000..4e79af3 --- /dev/null +++ b/types/react-email.d.ts @@ -0,0 +1,9 @@ +// Ambient shape for the optional peer `@react-email/render`. Declared here +// rather than installed so the package stays out of this repo's dependency +// graph — `unemail/render/react` imports it only when a caller uses it. +declare module "@react-email/render" { + export function render( + element: unknown, + options?: { plainText?: boolean }, + ): Promise | string +}