feat!: v1 — batch-native pipeline, one composition model, bun toolchain - #103
Merged
Merged
Conversation
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
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
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
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.
Rewrite of the core around three pieces: normalize once at the edge, one middleware shape, drivers as pure transports.
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.
fallbackworks the same way — if 3 of 500 fail at the primary, only those 3 reach the secondary, so nobody receives the same mail twice.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:
sendBatchreturned on the first failure, losing the results of messages the provider had already accepted — and forced the type withres as Result<ReadonlyArray<…>>BatchResult;results[i]↔messages[i], never short-circuitsinitialize()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 allwithRendermutated the caller's message object —applyUnsubscribeHeadersreturned the same object when there was no unsubscribe, so a reused template accumulatedhtml/textbetween sendsNormalizedMessage; middleware derives new ones viapatchMessagepersonalizationssupport had all but the first silently droppedsendBatchis the answernormalizeMessage(RFC 5322 §2.2)_http.tsdrivers/_fetch.tsversionsaid1.0.0-alpha.0whilepackage.jsonsaid0.5.0scripts/check-version.mjs, run in CImockdriver returnednew (class extends Error {})(…) as never— not anEmailError, soerror.codewasundefinedcreateError, with afailWhenpredicate for partial-batch testsArchitecture 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+liquidVarsleft the core type. One opaquecontent: { type, ... }block replaces them, and a renderer claims it bytype— 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:
Required options are required.
DriverFactorytookoptions?: TOpts, soresend()typechecked and threw at runtime. It is a compile error now. A driver that names an instance type is also obliged to exposegetInstance, somock().getInstance()needs no optional-call guard.Also:
driver.flags→driver.features;SendStatusState→SendState;unemail/driver/*→unemail/drivers/*(plural, matchingunstorage); instance-leveldefaultsforfrom/replyTo/headers/tags/metadata;preheaderinjection;sendBatchStream→sendStream, accepting a sync or async iterable with achunkSize.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.mdlists them and pinsunemail@^0.5.0for 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/andparse/port almost unchanged.Invariants, enforced rather than remembered
isolatedDeclarations—unemail/middlewarewas shipping without a.d.mts; obuild only warned. It is a typecheck failure now.core/email.mjs4.9 KB / 8 KB.package.json,jsr.jsonandsrc/index.ts.Tooling: pnpm out, bun in
pnpm-lock.yaml,pnpm-workspace.yaml, thepackageManagerfield and everypnpmreference in CI are gone.bun.lockis committed; CI usesoven-sh/setup-bun(release keeps a Node step purely fornpm publish --provenance). This affects contributors, not consumers.Verification
.mjshas its.d.mts.dist/through the realexportsmap.Reviewing this
docs/architecture.mdexplains the pipeline and why the list is the primitive.MIGRATION.mdis the diff-by-diff upgrade path. The core is ~1,100 lines across seven files insrc/core/.🤖 Generated with Claude Code
https://claude.ai/code/session_01GQAPDWLnDrdYHKhou3Ntvb