Skip to content

feat!: v1 — batch-native pipeline, one composition model, bun toolchain - #103

Merged
productdevbook merged 3 commits into
mainfrom
v1-rewrite
Sep 1, 2026
Merged

productdevbook merged 3 commits into
mainfrom
v1-rewrite

Conversation

@productdevbook

Copy link
Copy Markdown
Owner

Rewrite of the core around three pieces: normalize once at the edge, one middleware shape, drivers as pure transports.

send(msg) ─┐
           ├─▶ normalizeMessage ─▶ compose(middleware) ─▶ driverHandler ─▶ provider
sendBatch ─┘      (once)              (a list)            (a transport)

The one decision that matters

The pipeline's unit of work is a list, and send() is the one-element case.

That buys a property the old design could not express: retry re-sends 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. fallback works the same way — if 3 of 500 fail at the primary, only those 3 reach the secondary, so nobody receives the same mail twice.

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
})

The cost: a middleware that does not care about the batch still maps over it. perMessage() lifts a per-message function for that case.

Defects fixed

Found while reading 0.x, each reproduced by a test in this PR:

Defect Fix
1 sendBatch returned on the first failure, losing the results of messages the provider had already accepted — and forced the type with res as Result<ReadonlyArray<…>> Positional BatchResult; results[i] ↔ messages[i], never short-circuits
2 initialize() set its done flag before awaiting, so concurrent sends raced past a half-initialized driver; a driver mounted after the first send was never initialized at all Per driver, promise stored before it is awaited
3 withRender mutated the caller's message object — applyUnsubscribeHeaders returned the same object when there was no unsubscribe, so a reused template accumulated html/text between sends Frozen NormalizedMessage; middleware derives new ones via patchMessage
4 A driver without personalizations support had all but the first silently dropped Field removed; sendBatch is the answer
5 A header value containing CR or LF was written into the message unchecked — a caller could append arbitrary headers and a body Rejected in normalizeMessage (RFC 5322 §2.2)
6 Four drivers (resend, postmark, ses, mailgun) had copied the HTTP layer instead of using _http.ts One drivers/_fetch.ts
7 version said 1.0.0-alpha.0 while package.json said 0.5.0 scripts/check-version.mjs, run in CI
8 The mock driver returned new (class extends Error {})(…) as never — not an EmailError, so error.code was undefined Real createError, with a failWhen predicate for partial-batch tests

Architecture changes

Two composition models collapse into one. 0.x had hook-based Middleware (beforeSend/afterSend/onError) and driver decorators (withRetry(driver)). The second existed because retry cannot be written with the first. Now there is one shape, applied at two sites: email.use(mw) for an instance, wrap(driver, ...mw) for a single driver — which is what keeps retry composing with failover.

The god-object message is gone. react, jsx, mjml, handlebars + handlebarsVars, liquid + liquidVars left the core type. One opaque content: { type, ... } block replaces them, and a renderer claims it by type — so a new template language is a package, not a core change.

Drivers receive normalized data. No driver in this repo parses an address or guards a header any more:

-const from = normalizeAddresses(msg.from)[0]
-if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required")
+const from = formatAddress(msg.from)   // guaranteed present

Required options are required. DriverFactory took options?: TOpts, so resend() typechecked and threw at runtime. It is a compile error now. A driver that names an instance type is also obliged to expose getInstance, so mock().getInstance() needs no optional-call guard.

Also: driver.flags → driver.features; SendStatusState → SendState; unemail/driver/* → unemail/drivers/* (plural, matching unstorage); instance-level defaults for from/replyTo/headers/tags/metadata; preheader injection; sendBatchStream → sendStream, accepting a sync or async iterable with a chunkSize.

Scope

1.0.0 ships the core, five drivers (resend, postmark, ses, smtp, mock), the two composite drivers (fallback, round-robin) and the render layer.

Not in 1.0.0, being reintroduced against the new core — MIGRATION.md lists them and pins unemail@^0.5.0 for anyone who needs them today:

inbound/* · webhook/* · queue/* · verify/* · parse/* · dmarc · mta-sts · ics · suppression · compliance · preferences · events · test, and the SendGrid, Mailgun, Brevo, MailerSend, Loops, Mailtrap, Zeptomail, MailChannels, Mailcrab, Cloudflare (×2) and Tee drivers.

The remaining drivers are mechanical work — they get normalized messages now, so most of them shrink. webhook/ and parse/ port almost unchanged.

Invariants, enforced rather than remembered

  • isolatedDeclarations — unemail/middleware was shipping without a .d.mts; obuild only warned. It is a typecheck failure now.
  • Bundle budgets per entry — largest driver 5.1 KB / 12 KB, core/email.mjs 4.9 KB / 8 KB.
  • Version agreement across package.json, jsr.json and src/index.ts.
  • ATTW against an ESM-only profile, as before.

Tooling: pnpm out, bun in

pnpm-lock.yaml, pnpm-workspace.yaml, the packageManager field and every pnpm reference in CI are gone. bun.lock is committed; CI uses oven-sh/setup-bun (release keeps a Node step purely for npm publish --provenance). This affects contributors, not consumers.

Verification

  • 190 tests, 12 files — including the init race, mount-after-first-send, partial batch failure, header injection, caller-object immutability, retry's partial re-send, fallback's per-message failover, bcc staying off the document, and SES/Postmark error classification.
  • lint, format, typecheck all clean.
  • Build: 141 KB across 74 files; every .mjs has its .d.mts.
  • Smoke test against dist/ through the real exports map.
  • Zero runtime dependencies. ESM only.

Reviewing this

docs/architecture.md explains the pipeline and why the list is the primitive. MIGRATION.md is the diff-by-diff upgrade path. The core is ~1,100 lines across seven files in src/core/.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb
@socket-security

socket-security Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

productdevbook and others added 2 commits September 2, 2026 01:32
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb
@productdevbook
productdevbook merged commit 179c934 into main Sep 1, 2026
3 checks passed
@productdevbook
productdevbook deleted the v1-rewrite branch September 1, 2026 23:36
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.

1 participant