Skip to content

driver-based rewrite (breaking)#56

Merged
productdevbook merged 11 commits into
mainfrom
v1/refactor
Apr 17, 2026
Merged

driver-based rewrite (breaking)#56
productdevbook merged 11 commits into
mainfrom
v1/refactor

Conversation

@productdevbook
Copy link
Copy Markdown
Owner

Summary

Full rewrite of unemail into a driver-based email library inspired by unjs/unstorage, plus a tooling alignment with our sibling productdevbook/ahize.

This is a breaking change. The v0.x createEmailService / provider API is replaced end-to-end. Track the full roadmap in #24.

Architecture

  • createEmail({ driver }) factory replaces createEmailService + provider pattern
  • defineDriver<TOpts>() helper with full TypeScript inference
  • Discriminated { data, error } Result<T> type + EmailError taxonomy (INVALID_OPTIONS, NETWORK, AUTH, RATE_LIMIT, TIMEOUT, PROVIDER, UNSUPPORTED, CANCELLED)
  • mount(stream, driver) / unmount(stream) for Postmark-style message streams
  • Composable middleware — beforeSend, afterSend, onError (recovery path)
  • Pluggable idempotency store with in-memory default (TTL-bound)
  • sendBatch() with automatic fallback to sequential sends
  • Runtime-neutral core — Node, Bun, Deno, Cloudflare Workers, browser — zero deps

Tooling (aligned with ahize)

Before After
eslint + @antfu/eslint-config oxlint + oxfmt
tsdown obuild
tsc @typescript/native-preview (tsgo --noEmit)
Verbose tsconfig.json with path aliases Minimal (bundler resolution, verbatimModuleSyntax)
npm-only jsr.json for dual npm + JSR publishing
Custom CI CI workflows mirror ahize (lint → typecheck → test → build → budget → attw)
scripts/bundle-budget.mjs enforces per-module size ceilings

Deleted: @antfu/eslint-config, tsdown, ts-node, dotenv, nodemailer, resend, @aws-sdk/client-ses, ofetch, mlly, scule, vite-tsconfig-paths plus the old playground/ examples and provider source files.

Pipeline status

  • pnpm lint — 0 warnings, 0 errors
  • pnpm typecheck — clean (tsgo --noEmit)
  • pnpm vitest run — 15/15 tests passing (core + normalization)
  • pnpm build — 19.6 kB across 17 .mjs files
  • pnpm bundle-budgetindex.mjs 381B / drivers/mock.mjs 1078B (both well under budget)

Follow-up work (sub-issues under #24)

Test plan

  • pnpm install from scratch produces a clean lockfile (no legacy deps)
  • pnpm test passes locally (lint + typecheck + vitest)
  • pnpm build emits .mjs + .d.mts for every source file (obuild isolated-declarations)
  • pnpm bundle-budget stays under ceilings
  • CI workflow turns green on this PR (first run on the new ahize-shaped config)
  • Verify drivers/mock import resolves via the ./drivers/mock export both in Node (.mjs) and under JSR (./src/drivers/mock.ts)

🤖 Generated with Claude Code

Full rewrite of unemail into a driver-based email library inspired by
unjs/unstorage. This is a breaking change — the v0.x provider API is
replaced end-to-end.

Highlights:
- createEmail({ driver }) factory replaces createEmailService/provider pattern
- defineDriver<TOpts>() helper with full TypeScript inference
- Discriminated { data, error } Result type + EmailError taxonomy
- mount/unmount namespacing for Postmark-style message streams
- Composable middleware (beforeSend / afterSend / onError)
- Pluggable idempotency store with in-memory default
- Batch sends with automatic fallback to sequential send
- Runtime-neutral core (Node, Bun, Deno, Workers, browser) — zero deps

Tooling aligned with sibling productdevbook/ahize:
- oxlint + oxfmt (replaces eslint + @antfu/eslint-config)
- obuild (replaces tsdown)
- @typescript/native-preview / tsgo --noEmit
- Minimal tsconfig (bundler resolution, verbatimModuleSyntax)
- jsr.json for dual npm + JSR publishing
- CI workflows mirror ahize (lint → typecheck → test → build → budget → attw)
- scripts/bundle-budget.mjs enforces per-module size ceilings

Refs #24 (v1.0 tracking). Closes paths for #25, #27, #29 (architecture core).
Auto-generation is no longer needed — driver exports are declared
directly in package.json and jsr.json.
@socket-security
Copy link
Copy Markdown

socket-security Bot commented Apr 17, 2026

Middleware (composable driver wrappers):
- withRetry — honors error.retryable and Retry-After on 429, exponential or
  constant backoff, cancelable via AbortSignal
- withRateLimit — sliding-window limiter with queue cap
- withCircuitBreaker — closed → open → half-open lifecycle with onStateChange
  telemetry hook

Drivers:
- drivers/resend — zero-dep fetch-based Resend client (send, sendBatch,
  Idempotency-Key header, scheduled_at, tags, attachments, error taxonomy).
  Works on Workers/Deno (no node:* imports).
- drivers/fallback — try a list of drivers in order; advance only on
  retryable failures, short-circuit on AUTH/INVALID_OPTIONS
- drivers/round-robin — cycle sends across drivers with optional weights

Exports wired into package.json + jsr.json (./middleware,
./drivers/resend, ./drivers/fallback, ./drivers/round-robin).

Tests: 36/36 passing (core + normalize + 3 middleware suites + resend +
fallback/round-robin). Bundle budget unchanged; resend 4.5KB, meta drivers
~1KB each.

Refs #32, #34, #40 (part of #24).
@productdevbook productdevbook changed the title v1.0: driver-based rewrite (breaking) driver-based rewrite (breaking) Apr 17, 2026
New driver in src/drivers/smtp.ts with protocol helpers split into
src/drivers/_smtp/ for readability and testability:

- reply.ts     — incremental multi-line SMTP reply parser
- errors.ts    — reply-code → EmailErrorCode taxonomy
- mime.ts      — MIME builder (single, multipart/alternative, multipart/mixed,
                 quoted-printable text, base64 attachments, dot-stuffing)
- auth.ts      — PLAIN / LOGIN / CRAM-MD5 / XOAUTH2 with pure send/recv ctx
- connection.ts— connect → EHLO → STARTTLS → AUTH state machine with a single
                 persistent data reader (no per-command listener juggling)
- pool.ts      — FIFO pool tracking idle + in-flight; graceful dispose that
                 hard-destroys failed sockets and QUITs clean idle ones

Bug fixes vs. the v0.x provider:
- #21 timeout < 5000ms: split into connectionTimeoutMs / commandTimeoutMs; no
  more socket.setTimeout() cascading destroys during TLS handshake
- #8  Brevo "501 Invalid domain name": EHLO argument now uses os.hostname()
  via the new `localName` option, not the server's host
- Pool dispose leak: inFlight sockets are tracked and destroyed on dispose;
  failed sends destroy hard rather than block on a reply during QUIT
- Dropped double EHLO pre-STARTTLS

Driver is Workers-parseable: node:net / node:tls / node:crypto are loaded
via dynamic import() inside createConnection(), not at module top-level.

Tooling: tsconfig "types": ["node"] so tsgo picks up @types/node.

Tests: 50/50 passing. smtp.test.ts covers happy path, 535→AUTH mapping,
short commandTimeoutMs respected, localName in EHLO, 550 on RCPT →
PROVIDER. _smtp/ subtests cover reply parsing and the MIME builder.

Refs #33, closes-path #21 and #8 (full closure with the end-to-end real-
provider smoke tests that live in playground/ when that lands).
Two new transactional providers behind the standard EmailDriver interface.
Both are zero-dep HTTP drivers that use the global fetch, so they run
unchanged on Node, Bun, Deno, Cloudflare Workers, and the browser.

Postmark (src/drivers/postmark.ts):
- /email and /email/batch with Postmark's PascalCase shape
- Native message-stream routing via msg.stream (Postmark's killer feature),
  plus driver-level messageStream default
- Error-taxonomy mapping: 401/403 + ErrorCode 10 → AUTH, 429 → RATE_LIMIT,
  5xx → NETWORK, otherwise PROVIDER
- sendBatch surfaces partial failures as the whole batch failing — matches
  the overall driver contract (either all results or an error)

AWS SES v2 (src/drivers/ses.ts):
- Raw-MIME send via POST /v2/email/outbound-emails (Content.Raw.Data)
- Reuses the _smtp/mime builder → full attachment support out of the box
- Configuration sets, FromArn, EmailTags, ReplyTo passthroughs
- Credentials from explicit options OR AWS_ACCESS_KEY_ID/SECRET env vars
- Error mapping for InvalidClientTokenId / ThrottlingException / etc.
- sendBatch: SES v2 raw-MIME has no bulk endpoint (SendBulkEmail requires
  templates), so we fall through to sequential sends explicitly

AWS SigV4 (src/drivers/_ses/sigv4.ts):
- Pure Web-Crypto implementation (crypto.subtle.digest/importKey/sign)
- No @aws-sdk/* and no node:crypto dependency — Workers-compatible
- Exported as a reusable module; next AWS-service driver plugs into it
- Signature stability tests + session-token path + body-change assertion

Tests: 66/66 passing (+10 new). Bundle: SES 5.5KB, Postmark 5.1KB,
SigV4 3.3KB — all well under budget.

Refs #35, #37 (part of #24).
Covers the remaining Tier-1 providers plus the Cloudflare runtimes called
out in #16 / #23. Every driver uses the shared _http.ts helper (JSON
fetch + error taxonomy), so new providers cost ~100-150 LOC each.

Drivers:
- http              — generic JSON endpoint with a pluggable `transform`
                      (replaces the v0 provider; fixes the flattening bug)
- zeptomail         — Zoho-enczapikey token, trackClicks/trackOpens
- sendgrid          — /v3/mail/send with personalizations + Bearer auth
- mailgun           — /v3/{domain}/messages, Basic-auth form-data,
                      regional endpoint support
- brevo             — /v3/smtp/email with api-key header
- mailersend        — /v1/email + /v1/bulk-email (batch), send_at scheduling
- loops             — /api/v1/transactional with dataVariables sourced
                      from msg.tags; transactionalId via driver default or
                      `headers["x-loops-transactional-id"]`
- mailchannels      — /tx/v1/send, no-auth from Cloudflare Workers; DKIM
                      signing passthrough for non-Worker use
- cloudflare-email  — Email Workers outbound binding (takes env.SEND_EMAIL);
                      builds raw MIME via the shared _smtp/mime builder
- mailcrab          — thin wrapper over the SMTP driver defaulting to
                      localhost:1025 with a one-line http://…:1080 pointer
                      on first send (suppress with `quiet: true`)

Shared helper: src/drivers/_http.ts — JSON fetch wrapper with
HTTP-status → EmailErrorCode taxonomy + optional custom classifyError hook
for provider-specific error codes.

Exports added to package.json + jsr.json for all ten new sub-paths.

Tests: 88/88 passing. test/drivers/http-providers.test.ts covers
SendGrid/Mailgun/Brevo/MailerSend/Loops/MailChannels with endpoint + auth
+ payload-shape assertions. test/drivers/mailcrab.test.ts exercises the
SMTP delegation + `quiet` flag via the fake-server harness.

Bundle: every new driver <5KB; total dist 124KB across 76 files.

Refs #36, #38, #39, #41 (part of #24). Full Workers coverage for #16/#23.
Rendering (unemail/render/*):
- `withRender(...renderers)` middleware resolves `msg.react`, `msg.jsx`,
  or `msg.mjml` into `msg.html` before the driver sees the message,
  auto-deriving `msg.text` via the zero-dep `htmlToText` helper
- `unemail/render/react`      — adapter around @react-email/render
  (optional peer; lazy dynamic import keeps the entry Workers-parseable)
- `unemail/render/jsx-email`  — adapter around `jsx-email`
- `unemail/render/mjml`       — adapter around `mjml`
- `defineTemplate<Vars>()`    — typed template factory producing a
  `Partial<EmailMessage>` for splat-into-send ergonomics
- Each renderer accepts a `render` / `compile` override for tests and
  self-hosted setups — no live peer install needed to unit test

Test utilities (unemail/test):
- `createTestEmail()`         — an `Email` with `.inbox`, `.last`,
  `.find`, `.filter`, `.waitFor`, `.clear` built on the mock driver
- `emailMatchers.toHaveSent`  — Vitest-compatible assertion; also
  usable as a plain function via `matchesEmail()`
- `EmailMessage` type gains opt-in `react` / `jsx` / `mjml` fields
  (all optional, ignored when no renderer is registered)

Tests: 108/108 passing. New suites under test/render/ and test/test/
cover htmlToText, the render middleware (including multi-renderer
fall-through), defineTemplate, inbox API (including waitFor timeout),
and matcher happy/fail paths.

Exports added to package.json + jsr.json: ./render, ./render/react,
./render/jsx-email, ./render/mjml, ./test.

Refs #42, #43, #44, #51 (part of #24).
Four new subsystems, all zero-dep and Workers-parseable:

unemail/parse:
- parseEmail(raw) wraps postal-mime (optional peer; dynamic import)
- Returns a strict ParsedEmail (typed EmailAddress[], ParsedAttachment
  with Uint8Array content, lowercase header map)
- normalizeParsed() exposed for the inbound adapters that bypass
  postal-mime (Postmark delivers JSON, not raw MIME)

unemail/inbound:
- defineInboundHandler({ providers, onEmail }) — single fetch handler
  that dispatches to the first matching adapter, verifies signatures,
  and yields a unified ParsedEmail
- Five adapters: cloudflare, postmark, sendgrid, mailgun (+ raw-MIME
  helper); SES inbound lands with the webhook SNS path

unemail/webhooks:
- Unified WebhookEvent schema (sent/delivered/bounced/complained/
  opened/clicked/unsubscribed/rejected/failed/other) with bounce
  classification and click URL
- defineWebhookHandler({ providers, onEvent }) mirrors the inbound
  handler shape
- Five provider verifiers, all Web-Crypto only:
  - resend   — Svix HMAC-SHA256 with base64-encoded secret
  - postmark — HTTP Basic auth gate (Postmark's standard integration)
  - mailgun  — HMAC-SHA256 over timestamp+token
  - sendgrid — ECDSA (P-256 / SHA-256) with DER → raw signature
  - ses      — SNS envelope parsing + TopicArn allow-list + pluggable
               cert-fetch signature verifier

unemail/verify:
- parseAuthenticationResults() parses the RFC 8601 header MTAs add
  after running DKIM/SPF/DMARC — cheap, works on Workers
- verifyDkim/Spf/Dmarc + verifyAll helpers; verifyAll accepts an
  async `verify` callback for users with DNS access that want
  authoritative lookups

Tests: 126/126 passing (+18 new across parse, inbound, webhooks with
real HMAC round-trips, verify). Exports wired into package.json +
jsr.json for every sub-path.

Bundle: 176 kB across 130 files; every entry still under budget.

Refs #45, #46, #47, #48, #49 (part of #24).
Observability (unemail/middleware/logger, telemetry):
- withLogger({ sink, redactLocalPart, includeSubject, includeRecipient })
  emits structured JSON entries (send.start / send.success / send.error)
  with driver, stream, attempt, messageId, recipient, subject, durationMs,
  and a serialized error {code, message, status, retryable}. User-supplied
  ctx.meta fields are forwarded under `meta` (internal `__logger*` keys
  stripped).
- withTelemetry({ tracer, sample }) opens one OTel span per send with
  attributes email.driver / email.stream / email.attempt / email.to /
  email.subject.length / email.message_id / email.error.code. No-op when
  no tracer is passed — safe to leave in place.

Queue (unemail/queue):
- EmailQueue contract (enqueue / pull / ack / fail / size) so backends
  plug in without changing producers or the worker.
- memoryQueue() — single-process, injectable clock for tests.
- unstorageQueue({ storage }) — durable across restarts on any unstorage
  driver (Redis, KV, FS, Mongo, Upstash…).
- startWorker(email, queue) — concurrency, maxAttempts, exponential
  backoff, graceful stop, waitForIdle, manual tick() for tests.

Docs:
- MIGRATION.md — step-by-step v0.x → v1 migration (createEmailService
  → createEmail, defineProvider → defineDriver, Result shape, provider
  path renames, retry/timeout → middleware, new capabilities).
- docs/drivers.md — built-in driver matrix + authoring guide + error
  taxonomy table.
- docs/rendering.md, inbound.md, webhooks.md, testing.md, observability.md,
  queue.md — subsystem-by-subsystem reference with runnable snippets.
- README rewrite — current feature list, peer-deps note, driver matrix
  link, docs index.

Exports: ./queue, ./queue/memory, ./queue/unstorage, ./queue/worker
added to package.json + jsr.json. withLogger / withTelemetry and their
types re-exported from the root barrel.

Tests: 138/138 passing (+12 across logger, telemetry, queue/memory,
queue/unstorage). Bundle: 192 kB / 143 files; every entry under budget.

Refs #50, #53, #54 (part of #24).
\`unemail/drivers/tee\` forwards every send to all listed drivers. The
first is authoritative — its Result is returned to the caller and any
failure propagates. The rest are mirrors for auditing/archival; their
errors are surfaced via an \`onMirrorError\` callback but never cause
the user-facing send to fail.

Options:
- \`drivers\`          — \`[primary, ...mirrors]\`
- \`onMirrorError\`    — logging hook (Sentry, OTel, etc.)
- \`awaitMirrors\`     — default false, mirrors run fire-and-forget; set
                         true when tests or archival workflows need to
                         observe them synchronously

Tests: 3 new cases — primary success forwards to mirror, primary error
surfaces but mirrors still run, mirror error reported without breaking
primary send. Exports wired in both package.json and jsr.json.

Closes the remainder of #40 (mock + fallback + round-robin + weighted
already shipped; tee was the last one).
@productdevbook productdevbook merged commit 9a1ae5e into main Apr 17, 2026
3 checks passed
@productdevbook productdevbook deleted the v1/refactor branch April 17, 2026 05:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cloudflare Workers support SMTP with brevo not working

1 participant