driver-based rewrite (breaking)#56
Merged
Merged
Conversation
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.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
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).
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).
This was referenced Apr 17, 2026
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).
This was referenced Apr 17, 2026
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.
This was referenced Apr 17, 2026
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).
This was referenced Apr 17, 2026
This was referenced Apr 17, 2026
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).
This was referenced Apr 17, 2026
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).
This was referenced Apr 17, 2026
\`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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Full rewrite of
unemailinto a driver-based email library inspired byunjs/unstorage, plus a tooling alignment with our siblingproductdevbook/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 replacescreateEmailService+ provider patterndefineDriver<TOpts>()helper with full TypeScript inference{ data, error }Result<T>type +EmailErrortaxonomy (INVALID_OPTIONS,NETWORK,AUTH,RATE_LIMIT,TIMEOUT,PROVIDER,UNSUPPORTED,CANCELLED)mount(stream, driver)/unmount(stream)for Postmark-style message streamsbeforeSend,afterSend,onError(recovery path)sendBatch()with automatic fallback to sequential sendsTooling (aligned with
ahize)eslint+@antfu/eslint-configoxlint+oxfmttsdownobuildtsc@typescript/native-preview(tsgo --noEmit)tsconfig.jsonwith path aliasesverbatimModuleSyntax)jsr.jsonfor dual npm + JSR publishingahize(lint → typecheck → test → build → budget → attw)scripts/bundle-budget.mjsenforces per-module size ceilingsDeleted:
@antfu/eslint-config,tsdown,ts-node,dotenv,nodemailer,resend,@aws-sdk/client-ses,ofetch,mlly,scule,vite-tsconfig-pathsplus the oldplayground/examples and provider source files.Pipeline status
pnpm lint— 0 warnings, 0 errorspnpm typecheck— clean (tsgo --noEmit)pnpm vitest run— 15/15 tests passing (core + normalization)pnpm build— 19.6 kB across 17.mjsfilespnpm bundle-budget—index.mjs381B /drivers/mock.mjs1078B (both well under budget)Follow-up work (sub-issues under #24)
react:on send() #42 (React Email), [render] jsx-email adapter + MJML adapter + plain HTML helpers #43 (jsx-email/MJML), [render] Type-safe templates: defineTemplate<T>() #44 (type-safe templates)Test plan
pnpm installfrom scratch produces a clean lockfile (no legacy deps)pnpm testpasses locally (lint + typecheck + vitest)pnpm buildemits.mjs+.d.mtsfor every source file (obuild isolated-declarations)pnpm bundle-budgetstays under ceilingsdrivers/mockimport resolves via the./drivers/mockexport both in Node (.mjs) and under JSR (./src/drivers/mock.ts)🤖 Generated with Claude Code