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)
type SendHandler = (
msgs: readonly NormalizedMessage[],
ctx: SendContext,
) => Promise<readonly Result<EmailResult>[]>
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.
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.
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:
return next(
msgs.map((m) => patchMessage(m, { html })),
ctx,
)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.
use() registers a middleware once, and it wraps every mounted driver. So
any state it keeps has to be partitioned by destination — ctx.driver plus
ctx.stream — or one provider's outage becomes every provider's, which is
the opposite of what mounting a second provider is for.
src/middleware/_scope.ts is the shared keying helper. The circuit breaker
and the rate limiter both use it; anything stateful you write should too.
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.
fallback([wrap(resend(...), withRetry()), ses(...)]) // retry inside each leg
createEmail({ driver: fallback([...]), use: [withRetry()] }) // retry around the whole thingPer 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.
| 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.
- 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.jsonand theversionconstant must agree. They drifted in 0.x. isolatedDeclarations— a file that cannot emit its own.d.mtsfails typecheck rather than shipping a package with missing types.- ATTW — the published
exportsmap is checked against an ESM-only profile on every pull request. - JSR dry run — a slow type fails the pull request that introduced it, rather than halfway through a release with npm already published.
- Coverage floors — set at what the suite measured the day they were added, so they can only be raised.