From 7595ef2806a0980fc5541589c968d60a030b5f8e Mon Sep 17 00:00:00 2001 From: productdevbook Date: Wed, 2 Sep 2026 01:20:19 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat!:=20v1=20=E2=80=94=20batch-native=20pi?= =?UTF-8?q?peline,=20one=20composition=20model,=20bun=20toolchain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite of the core around three pieces: normalize once at the edge, one middleware shape, drivers as pure transports. The pipeline's unit of work is a list, and `send()` is the one-element case. That 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 now costs one small retry rather than a full re-send with duplicate deliveries. Failover works the same way, per message. Defects the old design carried, fixed here: - `sendBatch` returned on the first failure, losing the results of messages the provider had already accepted. It is positional now and never short-circuits. - `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. Initialization is per driver, and the promise is stored before it is awaited. - `withRender` mutated the caller's message, so a reused template accumulated `html` and `text` between sends. Messages are normalized into a frozen object and middleware derives new ones. - A driver without `personalizations` support had all but the first silently dropped. The field is gone; `sendBatch` is the answer. - A header value containing CR or LF was written into the message unchecked. Normalization rejects it. - Four drivers had copied the HTTP layer instead of using the shared one. - `version` said 1.0.0-alpha.0 while package.json said 0.5.0. Also: the two composition models (hook middleware and driver decorators) collapse into one; renderer-specific fields leave the core message type in favour of an opaque `content` block a renderer claims by type; `defineDriver` makes required options required, so `resend()` with no key is a compile error rather than a runtime throw. Scope for 1.0.0 is the core, five drivers (resend, postmark, ses, smtp, mock), the two composite drivers and the render layer. inbound, webhook, queue, verify, parse and the other fifteen drivers are being reintroduced against the new core; MIGRATION.md says so and pins 0.5.x for anyone who needs them today. Tooling moves to bun: pnpm-lock.yaml, pnpm-workspace.yaml, the packageManager field and every pnpm reference in CI are gone. Three invariants are now enforced rather than remembered β€” bundle budgets per entry, version agreement across package.json/jsr.json/src, and isolatedDeclarations, which caught a missing .d.mts for unemail/middleware. 190 tests, zero runtime dependencies, ESM only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb --- .github/workflows/ci.yml | 33 +- .github/workflows/release.yml | 35 +- .gitignore | 5 +- MIGRATION.md | 308 +-- README.md | 627 ++--- bun.lock | 411 ++++ docs/README.md | 14 - docs/architecture.md | 123 + docs/drivers.md | 258 +-- docs/inbound.md | 61 - docs/observability.md | 62 - docs/queue.md | 63 - docs/recipes/typed-get-instance.md | 54 - docs/rendering.md | 90 - docs/rfcs/001-packaging.md | 83 - docs/testing.md | 77 - docs/webhooks.md | 77 - jsr.json | 76 +- package.json | 312 +-- pnpm-lock.yaml | 2016 ----------------- pnpm-workspace.yaml | 10 - scripts/bundle-budget.mjs | 7 +- scripts/check-version.mjs | 34 + scripts/setup-mailcrab.mjs | 360 --- src/_define.ts | 17 - src/_idempotency.ts | 25 - src/_normalize.ts | 49 - src/address.ts | 131 -- src/compliance/index.ts | 130 -- src/core/address.ts | 62 + src/core/define.ts | 170 ++ src/core/email.ts | 292 +++ src/core/error.ts | 80 + src/core/message.ts | 182 ++ src/core/result.ts | 36 + src/core/types.ts | 305 +++ src/dmarc/index.ts | 124 - src/driver/_http.ts | 98 - src/driver/_smtp/mime.ts | 320 --- src/driver/brevo.ts | 123 - src/driver/cloudflare-email-service.ts | 185 -- src/driver/cloudflare-email.ts | 95 - src/driver/fallback.ts | 61 - src/driver/http.ts | 146 -- src/driver/loops.ts | 93 - src/driver/mailchannels.ts | 139 -- src/driver/mailcrab.ts | 56 - src/driver/mailersend.ts | 159 -- src/driver/mailgun.ts | 179 -- src/driver/mailtrap.ts | 391 ---- src/driver/mock.ts | 69 - src/driver/postmark.ts | 244 -- src/driver/resend.ts | 287 --- src/driver/round-robin.ts | 47 - src/driver/sendgrid.ts | 182 -- src/driver/ses.ts | 243 -- src/driver/smtp.ts | 174 -- src/driver/tee.ts | 67 - src/driver/zeptomail.ts | 134 -- src/drivers/_base64.ts | 46 + src/drivers/_fetch.ts | 149 ++ src/drivers/_mime.ts | 279 +++ src/{driver => drivers}/_ses/sigv4.ts | 0 src/{driver => drivers}/_smtp/auth.ts | 0 src/{driver => drivers}/_smtp/connection.ts | 2 +- src/{driver => drivers}/_smtp/dkim.ts | 0 src/{driver => drivers}/_smtp/errors.ts | 4 +- src/{driver => drivers}/_smtp/pool.ts | 0 src/{driver => drivers}/_smtp/reply.ts | 0 src/drivers/fallback.ts | 119 + src/drivers/mock.ts | 126 ++ src/drivers/postmark.ts | 200 ++ src/drivers/resend.ts | 186 ++ src/drivers/round-robin.ts | 115 + src/drivers/ses.ts | 165 ++ src/drivers/smtp.ts | 181 ++ src/email.ts | 301 --- src/errors.ts | 37 - src/events/index.ts | 137 -- src/ics/index.ts | 159 -- src/inbound/cloudflare.ts | 33 - src/inbound/index.ts | 61 - src/inbound/mailgun.ts | 53 - src/inbound/postmark.ts | 125 - src/inbound/reply.ts | 74 - src/inbound/sendgrid.ts | 31 - src/inbound/ses.ts | 70 - src/inbound/thread.ts | 46 - src/index.ts | 121 +- src/middleware/circuit-breaker.ts | 115 +- src/middleware/dedupe.ts | 71 - src/middleware/idempotency.ts | 106 + src/middleware/index.ts | 54 +- src/middleware/logger.ts | 186 +- src/middleware/metrics.ts | 104 - src/middleware/oauth2.ts | 118 - src/middleware/pii.ts | 102 - src/middleware/preferences.ts | 71 - src/middleware/rate-limit.ts | 171 +- src/middleware/retry.ts | 190 +- src/middleware/suppression.ts | 69 - src/middleware/telemetry.ts | 106 - src/mta-sts/index.ts | 86 - src/parse/arf.ts | 68 - src/parse/index.ts | 169 -- src/preferences/index.ts | 54 - src/queue/bullmq.ts | 70 - src/queue/index.ts | 49 - src/queue/memory.ts | 63 - src/queue/pg-boss.ts | 78 - src/queue/sqs.ts | 90 - src/queue/unstorage.ts | 73 - src/queue/worker.ts | 94 - src/render/_middleware.ts | 51 - src/render/define-template.ts | 26 - src/render/handlebars.ts | 71 - src/render/html.ts | 78 +- src/render/i18n.ts | 62 - src/render/index.ts | 113 +- src/render/jsx-email.ts | 48 - src/render/liquid.ts | 55 - src/render/mjml.ts | 53 - src/render/pipeline.ts | 124 - src/render/react.ts | 121 +- src/result/index.ts | 54 - src/suppression/index.ts | 107 - src/test/inbox.ts | 72 - src/test/index.ts | 2 - src/test/matchers.ts | 245 -- src/types.ts | 349 --- src/verify/arc.ts | 212 -- src/verify/index.ts | 81 - src/webhook/_crypto.ts | 46 - src/webhook/index.ts | 75 - src/webhook/mailgun.ts | 95 - src/webhook/postmark.ts | 82 - src/webhook/resend.ts | 119 - src/webhook/sendgrid.ts | 143 -- src/webhook/ses.ts | 124 - src/webhook/standard.ts | 92 - test/address.test.ts | 65 - test/compliance/unsubscribe.test.ts | 132 -- test/core.test.ts | 129 -- test/core/address.test.ts | 92 + test/core/email.test.ts | 341 +++ test/core/message.test.ts | 215 ++ test/dmarc/dmarc.test.ts | 66 - test/driver/_smtp/mime.test.ts | 86 - test/driver/cloudflare-email-service.test.ts | 177 -- test/driver/cloudflare-email.test.ts | 65 - test/driver/http-providers.test.ts | 219 -- test/driver/http.test.ts | 71 - test/driver/mailcrab.test.ts | 60 - test/driver/mailtrap.test.ts | 369 --- test/driver/meta.test.ts | 94 - test/driver/personalizations.test.ts | 49 - test/driver/postmark.test.ts | 132 -- test/driver/resend.test.ts | 154 -- test/driver/ses.test.ts | 120 - test/driver/smtp-dkim.test.ts | 156 -- test/driver/smtp.test.ts | 139 -- test/driver/tee.test.ts | 72 - test/driver/templates.test.ts | 133 -- test/driver/tracking-sandbox.test.ts | 115 - test/driver/zeptomail.test.ts | 81 - test/{driver => drivers}/_smtp/fake-server.ts | 0 test/drivers/composite.test.ts | 204 ++ test/drivers/http-drivers.test.ts | 289 +++ test/drivers/mime.test.ts | 135 ++ test/{driver/_ses => drivers}/sigv4.test.ts | 2 +- .../smtp-reply.test.ts} | 2 +- test/drivers/smtp.test.ts | Bin 0 -> 5872 bytes test/events/events.test.ts | 28 - test/ics/ics.test.ts | 79 - test/inbound/inbound.test.ts | 112 - test/inbound/reply.test.ts | 95 - test/inbound/ses.test.ts | 36 - test/middleware/circuit-breaker.test.ts | 72 - test/middleware/dedupe.test.ts | 58 - test/middleware/logger.test.ts | 43 - test/middleware/metrics.test.ts | 27 - test/middleware/middleware.test.ts | 285 +++ test/middleware/oauth2.test.ts | 80 - test/middleware/pii.test.ts | 32 - test/middleware/preferences.test.ts | 63 - test/middleware/rate-limit.test.ts | 50 - test/middleware/retry.test.ts | 312 +-- test/middleware/suppression.test.ts | 117 - test/middleware/telemetry.test.ts | 91 - test/mta-sts/mta-sts.test.ts | 49 - test/normalize.test.ts | 62 - test/parse/arf.test.ts | 44 - test/parse/parse.test.ts | 51 - test/queue/memory.test.ts | 143 -- test/render/define-template.test.ts | 43 - test/render/html.test.ts | 27 - test/render/middleware.test.ts | 73 - test/render/pipeline.test.ts | 55 - test/render/render.test.ts | 208 ++ test/result/result.test.ts | 67 - test/suppression/store.test.ts | 59 - test/test/inbox.test.ts | 47 - test/test/matchers.test.ts | 128 -- test/verify/verify.test.ts | 74 - test/webhook/standard.test.ts | 93 - test/webhook/webhooks.test.ts | 171 -- tsconfig.json | 6 +- types/react-email.d.ts | 9 + 208 files changed, 6559 insertions(+), 18562 deletions(-) create mode 100644 bun.lock delete mode 100644 docs/README.md create mode 100644 docs/architecture.md delete mode 100644 docs/inbound.md delete mode 100644 docs/observability.md delete mode 100644 docs/queue.md delete mode 100644 docs/recipes/typed-get-instance.md delete mode 100644 docs/rendering.md delete mode 100644 docs/rfcs/001-packaging.md delete mode 100644 docs/testing.md delete mode 100644 docs/webhooks.md delete mode 100644 pnpm-lock.yaml delete mode 100644 pnpm-workspace.yaml create mode 100644 scripts/check-version.mjs delete mode 100755 scripts/setup-mailcrab.mjs delete mode 100644 src/_define.ts delete mode 100644 src/_idempotency.ts delete mode 100644 src/_normalize.ts delete mode 100644 src/address.ts delete mode 100644 src/compliance/index.ts create mode 100644 src/core/address.ts create mode 100644 src/core/define.ts create mode 100644 src/core/email.ts create mode 100644 src/core/error.ts create mode 100644 src/core/message.ts create mode 100644 src/core/result.ts create mode 100644 src/core/types.ts delete mode 100644 src/dmarc/index.ts delete mode 100644 src/driver/_http.ts delete mode 100644 src/driver/_smtp/mime.ts delete mode 100644 src/driver/brevo.ts delete mode 100644 src/driver/cloudflare-email-service.ts delete mode 100644 src/driver/cloudflare-email.ts delete mode 100644 src/driver/fallback.ts delete mode 100644 src/driver/http.ts delete mode 100644 src/driver/loops.ts delete mode 100644 src/driver/mailchannels.ts delete mode 100644 src/driver/mailcrab.ts delete mode 100644 src/driver/mailersend.ts delete mode 100644 src/driver/mailgun.ts delete mode 100644 src/driver/mailtrap.ts delete mode 100644 src/driver/mock.ts delete mode 100644 src/driver/postmark.ts delete mode 100644 src/driver/resend.ts delete mode 100644 src/driver/round-robin.ts delete mode 100644 src/driver/sendgrid.ts delete mode 100644 src/driver/ses.ts delete mode 100644 src/driver/smtp.ts delete mode 100644 src/driver/tee.ts delete mode 100644 src/driver/zeptomail.ts create mode 100644 src/drivers/_base64.ts create mode 100644 src/drivers/_fetch.ts create mode 100644 src/drivers/_mime.ts rename src/{driver => drivers}/_ses/sigv4.ts (100%) rename src/{driver => drivers}/_smtp/auth.ts (100%) rename src/{driver => drivers}/_smtp/connection.ts (99%) rename src/{driver => drivers}/_smtp/dkim.ts (100%) rename src/{driver => drivers}/_smtp/errors.ts (94%) rename src/{driver => drivers}/_smtp/pool.ts (100%) rename src/{driver => drivers}/_smtp/reply.ts (100%) create mode 100644 src/drivers/fallback.ts create mode 100644 src/drivers/mock.ts create mode 100644 src/drivers/postmark.ts create mode 100644 src/drivers/resend.ts create mode 100644 src/drivers/round-robin.ts create mode 100644 src/drivers/ses.ts create mode 100644 src/drivers/smtp.ts delete mode 100644 src/email.ts delete mode 100644 src/errors.ts delete mode 100644 src/events/index.ts delete mode 100644 src/ics/index.ts delete mode 100644 src/inbound/cloudflare.ts delete mode 100644 src/inbound/index.ts delete mode 100644 src/inbound/mailgun.ts delete mode 100644 src/inbound/postmark.ts delete mode 100644 src/inbound/reply.ts delete mode 100644 src/inbound/sendgrid.ts delete mode 100644 src/inbound/ses.ts delete mode 100644 src/inbound/thread.ts delete mode 100644 src/middleware/dedupe.ts create mode 100644 src/middleware/idempotency.ts delete mode 100644 src/middleware/metrics.ts delete mode 100644 src/middleware/oauth2.ts delete mode 100644 src/middleware/pii.ts delete mode 100644 src/middleware/preferences.ts delete mode 100644 src/middleware/suppression.ts delete mode 100644 src/middleware/telemetry.ts delete mode 100644 src/mta-sts/index.ts delete mode 100644 src/parse/arf.ts delete mode 100644 src/parse/index.ts delete mode 100644 src/preferences/index.ts delete mode 100644 src/queue/bullmq.ts delete mode 100644 src/queue/index.ts delete mode 100644 src/queue/memory.ts delete mode 100644 src/queue/pg-boss.ts delete mode 100644 src/queue/sqs.ts delete mode 100644 src/queue/unstorage.ts delete mode 100644 src/queue/worker.ts delete mode 100644 src/render/_middleware.ts delete mode 100644 src/render/define-template.ts delete mode 100644 src/render/handlebars.ts delete mode 100644 src/render/i18n.ts delete mode 100644 src/render/jsx-email.ts delete mode 100644 src/render/liquid.ts delete mode 100644 src/render/mjml.ts delete mode 100644 src/render/pipeline.ts delete mode 100644 src/result/index.ts delete mode 100644 src/suppression/index.ts delete mode 100644 src/test/inbox.ts delete mode 100644 src/test/index.ts delete mode 100644 src/test/matchers.ts delete mode 100644 src/types.ts delete mode 100644 src/verify/arc.ts delete mode 100644 src/verify/index.ts delete mode 100644 src/webhook/_crypto.ts delete mode 100644 src/webhook/index.ts delete mode 100644 src/webhook/mailgun.ts delete mode 100644 src/webhook/postmark.ts delete mode 100644 src/webhook/resend.ts delete mode 100644 src/webhook/sendgrid.ts delete mode 100644 src/webhook/ses.ts delete mode 100644 src/webhook/standard.ts delete mode 100644 test/address.test.ts delete mode 100644 test/compliance/unsubscribe.test.ts delete mode 100644 test/core.test.ts create mode 100644 test/core/address.test.ts create mode 100644 test/core/email.test.ts create mode 100644 test/core/message.test.ts delete mode 100644 test/dmarc/dmarc.test.ts delete mode 100644 test/driver/_smtp/mime.test.ts delete mode 100644 test/driver/cloudflare-email-service.test.ts delete mode 100644 test/driver/cloudflare-email.test.ts delete mode 100644 test/driver/http-providers.test.ts delete mode 100644 test/driver/http.test.ts delete mode 100644 test/driver/mailcrab.test.ts delete mode 100644 test/driver/mailtrap.test.ts delete mode 100644 test/driver/meta.test.ts delete mode 100644 test/driver/personalizations.test.ts delete mode 100644 test/driver/postmark.test.ts delete mode 100644 test/driver/resend.test.ts delete mode 100644 test/driver/ses.test.ts delete mode 100644 test/driver/smtp-dkim.test.ts delete mode 100644 test/driver/smtp.test.ts delete mode 100644 test/driver/tee.test.ts delete mode 100644 test/driver/templates.test.ts delete mode 100644 test/driver/tracking-sandbox.test.ts delete mode 100644 test/driver/zeptomail.test.ts rename test/{driver => drivers}/_smtp/fake-server.ts (100%) create mode 100644 test/drivers/composite.test.ts create mode 100644 test/drivers/http-drivers.test.ts create mode 100644 test/drivers/mime.test.ts rename test/{driver/_ses => drivers}/sigv4.test.ts (97%) rename test/{driver/_smtp/reply.test.ts => drivers/smtp-reply.test.ts} (94%) create mode 100644 test/drivers/smtp.test.ts delete mode 100644 test/events/events.test.ts delete mode 100644 test/ics/ics.test.ts delete mode 100644 test/inbound/inbound.test.ts delete mode 100644 test/inbound/reply.test.ts delete mode 100644 test/inbound/ses.test.ts delete mode 100644 test/middleware/circuit-breaker.test.ts delete mode 100644 test/middleware/dedupe.test.ts delete mode 100644 test/middleware/logger.test.ts delete mode 100644 test/middleware/metrics.test.ts create mode 100644 test/middleware/middleware.test.ts delete mode 100644 test/middleware/oauth2.test.ts delete mode 100644 test/middleware/pii.test.ts delete mode 100644 test/middleware/preferences.test.ts delete mode 100644 test/middleware/rate-limit.test.ts delete mode 100644 test/middleware/suppression.test.ts delete mode 100644 test/middleware/telemetry.test.ts delete mode 100644 test/mta-sts/mta-sts.test.ts delete mode 100644 test/normalize.test.ts delete mode 100644 test/parse/arf.test.ts delete mode 100644 test/parse/parse.test.ts delete mode 100644 test/queue/memory.test.ts delete mode 100644 test/render/define-template.test.ts delete mode 100644 test/render/html.test.ts delete mode 100644 test/render/middleware.test.ts delete mode 100644 test/render/pipeline.test.ts create mode 100644 test/render/render.test.ts delete mode 100644 test/result/result.test.ts delete mode 100644 test/suppression/store.test.ts delete mode 100644 test/test/inbox.test.ts delete mode 100644 test/test/matchers.test.ts delete mode 100644 test/verify/verify.test.ts delete mode 100644 test/webhook/standard.test.ts delete mode 100644 test/webhook/webhooks.test.ts create mode 100644 types/react-email.d.ts 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/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..155f4a9 100644 --- a/README.md +++ b/README.md @@ -1,502 +1,377 @@ +

-
- 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" - -const metrics = createMetricsRegistry() +import { wrap } from "unemail" +import { fallback } from "unemail/drivers/fallback" -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 +## Upgrading from 0.x -- [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 +v1 is a rewrite. See [MIGRATION.md](./MIGRATION.md). -## License +## 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..8372ba6 --- /dev/null +++ b/bun.lock @@ -0,0 +1,411 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "unemail", + "devDependencies": { + "@types/node": "^26.0.0", + "@typescript/native-preview": "7.0.0-dev.20260619.1", + "@vitest/coverage-v8": "^4.1.9", + "bumpp": "^11.1.0", + "obuild": "^0.4.36", + "oxfmt": "^0.55.0", + "oxlint": "^1.70.0", + "typescript": "^6.0.3", + "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.55.0", "", { "os": "android", "cpu": "arm" }, "sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.55.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.55.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.55.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.55.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.55.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.55.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.55.0", "", { "os": "none", "cpu": "arm64" }, "sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.55.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.55.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.55.0", "", { "os": "win32", "cpu": "x64" }, "sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg=="], + + "@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.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" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-YDmSiD6l+nikPoqrwDKn9wkCFUDFJCfkKcOKrYVycX5plwu6H03JWKyW1h1HjEfrMVw4Mq/Qk12VTsXJWP4TDQ=="], + + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260619.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i07qdTFQEBYotjg/Iy0GAfARSFyKAjXmP5BNPo6+QXGOp5hJNnmXSxGyhIlooqbDXluTQRhESEmzVAuvYryKCA=="], + + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260619.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-WVetqm9ypzLv+b/8+SRYuO6bJDC4AaQmT/1EcYh8iP6y5vZl3h13iscJ+45eHc4Y3yMyoDHOivfOTyTIMH2Vrw=="], + + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260619.1", "", { "os": "linux", "cpu": "arm" }, "sha512-mqUzZ9htNWH0su9+A5QqnHJ25hQ+E8Cq5qOOmfdQjiCQnPT69HQZ9MiUozQc1qk0i9/UaXouYDK48Pe0oUvUOw=="], + + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260619.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-peEnXecjZsnsW24GutRasmZlIgRLbyXPoR0inMkzXT1h7/+Ns5tM8RPnuxelXoC10dg4Ajo8I9BlUAgX45LwEQ=="], + + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260619.1", "", { "os": "linux", "cpu": "x64" }, "sha512-vZhIHvtpQE4ZICMIzIrOVOl/YYOLJjA91CsJMyetNNzvuQCCtll7CWUsS6Sp1IdRqJdwM6seJrdDEAtWkEMFkA=="], + + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260619.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-pRdZDV9Q3IzBKOi5WrIaEnlkVLB0w3zVj2/BliPV/oQ8r8N+4LmSCuyy8PbL5bMw8SznTlmiIfv5gvoEx2SpEw=="], + + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260619.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sOvPE+lA/+SU/cnva+MXA1oq70HO0lLajsj9ZA10T1CN1PUGgaC1Zw7V0objTJsZ9ri6I3Ldp+Fh4Onz3mLDXw=="], + + "@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@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.7.4", "tinyexec": "^1.1.2", "tinyglobby": "^0.2.16", "unconfig": "^7.5.0", "yaml": "^2.8.4" }, "bin": { "bumpp": "bin/bumpp.mjs" } }, "sha512-jdwOGMyX8JIqpQ0N2RMRR87DHZaoJnUtui5lU9LqFfFK5JC0H8qY9uWqXoa+dEWt/K7rOmmsoyiZB8RBM7RPBQ=="], + + "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.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" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A=="], + + "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@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "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=="], + + "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..1064d56 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,14 +91,15 @@ "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", @@ -349,8 +112,15 @@ "typescript": "^6.0.3", "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 +} From 5f2488591f4071dbb2baa7f8289acc535335cf73 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Wed, 2 Sep 2026 01:32:57 +0200 Subject: [PATCH 2/3] chore: bring the repo's own artifacts up to v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cover art is the first thing anyone sees on the README and on npm, and it still advertised 0.x: it imported from `unemail/driver/ses` (singular), passed the removed `react:` field, listed eight providers this release does not ship, and claimed "15+ providers". The snippet is now the v1 API and would actually run β€” including `defaults: { from }`, which v1 requires somewhere. The tiles are the twelve things you can import today, and the tagline says what the release is rather than how many providers it counts. cover.png is re-rendered from the SVG rather than left behind at 0.x. `playground/` went with the 0.x tree, so the oxlint and oxfmt ignore entries pointed at nothing. README had no route to docs/architecture.md or docs/drivers.md. Dev dependencies: typescript 6.0.3 β†’ 7.0.2, which also settles a real mismatch β€” the repo declared TS 6 while typechecking with tsgo 7. Also bumpp 11 β†’ 12, oxfmt 0.55 β†’ 0.66 (reformats nothing), and a newer native-preview build, re-pinned exactly: a caret range on a prerelease resolves to a different nightly on a fresh install and the typechecker stops being reproducible. Verified after the bumps: lint, format, typecheck, 190 tests, build with every .d.mts present, bundle budgets, version consistency. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb --- .github/assets/cover.png | Bin 159897 -> 281504 bytes .github/assets/cover.svg | 34 ++++++------ .oxfmtrc.json | 2 +- .oxlintrc.json | 1 - README.md | 8 +++ bun.lock | 110 +++++++++++++++++++++++++++------------ package.json | 10 ++-- 7 files changed, 108 insertions(+), 57 deletions(-) diff --git a/.github/assets/cover.png b/.github/assets/cover.png index c8521055bc135ad7f50b9a5107dcd631f98490a9..54fe4db2a736a46dfcd4ea3c23893cce3dec8767 100644 GIT binary patch literal 281504 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 literal 159897 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

qsH^$BQrqIRZ-VKGByF>>u=PhmXh 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 - + @@ -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/.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/README.md b/README.md index 155f4a9..df24001 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,14 @@ list of any of those. SMTP needs `node:net` and `node:tls`. Everything else is `fetch` and Web Crypto. +## Docs + +- [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 + ## Upgrading from 0.x v1 is a rewrite. See [MIGRATION.md](./MIGRATION.md). diff --git a/bun.lock b/bun.lock index 8372ba6..75e9870 100644 --- a/bun.lock +++ b/bun.lock @@ -6,13 +6,13 @@ "name": "unemail", "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": { @@ -42,43 +42,43 @@ "@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.55.0", "", { "os": "android", "cpu": "arm" }, "sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g=="], + "@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.55.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw=="], + "@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.55.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA=="], + "@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.55.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg=="], + "@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.55.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA=="], + "@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.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw=="], + "@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.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg=="], + "@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.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA=="], + "@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.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA=="], + "@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.55.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ=="], + "@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.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A=="], + "@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.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA=="], + "@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.55.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ=="], + "@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.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA=="], + "@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.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g=="], + "@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.55.0", "", { "os": "none", "cpu": "arm64" }, "sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg=="], + "@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.55.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ=="], + "@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.55.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA=="], + "@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.55.0", "", { "os": "win32", "cpu": "x64" }, "sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg=="], + "@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=="], @@ -162,21 +162,61 @@ "@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.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" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-YDmSiD6l+nikPoqrwDKn9wkCFUDFJCfkKcOKrYVycX5plwu6H03JWKyW1h1HjEfrMVw4Mq/Qk12VTsXJWP4TDQ=="], + "@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.20260619.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i07qdTFQEBYotjg/Iy0GAfARSFyKAjXmP5BNPo6+QXGOp5hJNnmXSxGyhIlooqbDXluTQRhESEmzVAuvYryKCA=="], + "@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.20260619.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-WVetqm9ypzLv+b/8+SRYuO6bJDC4AaQmT/1EcYh8iP6y5vZl3h13iscJ+45eHc4Y3yMyoDHOivfOTyTIMH2Vrw=="], + "@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.20260619.1", "", { "os": "linux", "cpu": "arm" }, "sha512-mqUzZ9htNWH0su9+A5QqnHJ25hQ+E8Cq5qOOmfdQjiCQnPT69HQZ9MiUozQc1qk0i9/UaXouYDK48Pe0oUvUOw=="], + "@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.20260619.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-peEnXecjZsnsW24GutRasmZlIgRLbyXPoR0inMkzXT1h7/+Ns5tM8RPnuxelXoC10dg4Ajo8I9BlUAgX45LwEQ=="], + "@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.20260619.1", "", { "os": "linux", "cpu": "x64" }, "sha512-vZhIHvtpQE4ZICMIzIrOVOl/YYOLJjA91CsJMyetNNzvuQCCtll7CWUsS6Sp1IdRqJdwM6seJrdDEAtWkEMFkA=="], + "@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.20260619.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-pRdZDV9Q3IzBKOi5WrIaEnlkVLB0w3zVj2/BliPV/oQ8r8N+4LmSCuyy8PbL5bMw8SznTlmiIfv5gvoEx2SpEw=="], + "@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.20260619.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sOvPE+lA/+SU/cnva+MXA1oq70HO0lLajsj9ZA10T1CN1PUGgaC1Zw7V0objTJsZ9ri6I3Ldp+Fh4Onz3mLDXw=="], + "@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=="], @@ -250,7 +290,7 @@ "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@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.7.4", "tinyexec": "^1.1.2", "tinyglobby": "^0.2.16", "unconfig": "^7.5.0", "yaml": "^2.8.4" }, "bin": { "bumpp": "bin/bumpp.mjs" } }, "sha512-jdwOGMyX8JIqpQ0N2RMRR87DHZaoJnUtui5lU9LqFfFK5JC0H8qY9uWqXoa+dEWt/K7rOmmsoyiZB8RBM7RPBQ=="], + "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=="], @@ -338,7 +378,7 @@ "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.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" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A=="], + "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=="], @@ -386,7 +426,7 @@ "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "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=="], @@ -394,6 +434,8 @@ "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=="], diff --git a/package.json b/package.json index 1064d56..13abe55 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "unemail", "version": "1.0.0", "private": false, - "description": "Driver-based TypeScript email library. Batch-native middleware pipeline, zero dependencies, ESM-only β€” runs on Node, Bun, Deno, Cloudflare Workers and the browser.", + "description": "Driver-based TypeScript email library. Batch-native middleware pipeline, zero dependencies, ESM-only \u2014 runs on Node, Bun, Deno, Cloudflare Workers and the browser.", "keywords": [ "aws-ses", "batch-email", @@ -103,13 +103,13 @@ }, "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": { From d29cc51e5347e10da7751927da970572401d98ee Mon Sep 17 00:00:00 2001 From: productdevbook Date: Wed, 2 Sep 2026 01:34:13 +0200 Subject: [PATCH 3/3] fix(ci): restore the em-dash oxfmt expects in package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning the typechecker rewrote package.json through python's json.dump, which escapes non-ASCII by default β€” the description's em-dash came back as \u2014 and oxfmt --check rejected it. The format check ran before that edit, not after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 13abe55..09de4c2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "unemail", "version": "1.0.0", "private": false, - "description": "Driver-based TypeScript email library. Batch-native middleware pipeline, zero dependencies, ESM-only \u2014 runs 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",