Email SDK v1: A Safer TypeScript API for Transactional Email
Switching transactional email providers looks like an import change. Preserving behavior across failures and uncertain delivery is the difficult part.
Switching transactional email providers looks like an import change. The difficult part is preserving behavior when provider capabilities, failures, and delivery certainty do not match.
Email SDK v1 is now available as @opencoredev/email-sdk. It provides one server-side TypeScript API across 23 adapters, but the adapter count is only the visible part of the release. The deeper work in v1 is making transactional email behavior explicit: which route will send, which fields it supports, when a failure is safe to retry, and when a fallback could create a duplicate.

That matters because an email pipeline is a distributed system. Your application, the network, an adapter, and a provider can disagree about what happened. A good abstraction cannot erase that uncertainty. It has to expose it without making every application rebuild the same routing machinery.
The provider API is not the whole boundary
Most provider integrations begin with a tiny wrapper:
await provider.send({
from,
to,
subject,
html,
});
That works until the application needs attachments, CC and BCC, custom headers, tags, metadata, scheduling, personalization, retries, fallback, idempotency, testing, or observability. Each provider names and supports those concepts differently. A generic wrapper can make the method names uniform while still losing behavior at the edge.
Email SDK v1 treats the boundary as three separate problems:
- Normalize the message. The application uses one
EmailMessageshape. - Validate the selected route. The SDK checks common rules and the adapter's declared capabilities before dispatch.
- Preserve delivery semantics. Errors carry enough information for retry and fallback policy to distinguish a proven non-send from an uncertain outcome.
The result is still a small quickstart:
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
});
const result = await email.send({
from: "Acme <hello@acme.com>",
to: "ada@example.com",
subject: "Welcome",
text: "Your account is ready.",
});
console.log(result.adapter, result.id);
The difference appears when the pipeline becomes real.
One message shape, without pretending every provider is identical
A normalized API is useful only if it does not silently discard data. In v1, adapters declare support for fields such as attachments, headers, tags, metadata, scheduling, and personalization. Validation runs before the adapter sends.
If an SMTP route receives provider-specific tags or metadata, for example, it rejects the message instead of quietly dropping those fields. If an adapter cannot schedule natively, it rejects sendAt instead of pretending the SDK queued the message. The field-support matrix documents the differences across adapters.
await email.send(
{
from: "Acme <hello@acme.com>",
to: [{ email: "ada@example.com", name: "Ada" }],
replyTo: "support@acme.com",
subject: "Receipt",
html: "<p>Thanks for your order.</p>",
text: "Thanks for your order.",
headers: [{ name: "X-App", value: "acme" }],
tags: [{ name: "kind", value: "receipt" }],
metadata: { userId: "user_123" },
attachments: [
{
filename: "receipt.txt",
content: "Order #123",
contentType: "text/plain",
},
],
},
{
idempotencyKey: "receipt:user_123:order_123",
metadata: { job: "order-receipt" },
},
);
The 23 adapter entry points are separate package exports, so an application imports only the integrations it uses. Routes keep literal names through send, validate, adapter, and withAdapter, which means TypeScript can reject a misspelled route before production.
Those routes cover provider APIs such as Resend, Postmark, SendGrid, AWS SES, Mailgun, Brevo, Mailchimp Transactional, Loops, and newer independent providers, plus a built-in SMTP transport. SMTP does not pull Nodemailer into the dependency graph. It uses the same message validation and routing contract as the API-backed adapters, while clearly rejecting fields that SMTP cannot represent. The adapter boundary also stays public, so a team can implement a private transport or publish a community adapter without forking the client.
The point is not to make providers interchangeable in every situation. They are not. It is to centralize the differences, validate them before dispatch, and let the application choose routes with accurate information.
The dangerous fallback case is a successful request with a missing response
Retries are usually described as a response to failure. Email delivery has a more difficult state: the request may have succeeded even though the caller observed a timeout.
Consider this sequence:
- Your application dispatches a request to the primary provider.
- The provider accepts the email.
- The response is lost or the connection times out.
- Your code sees an error and tries the backup provider.
- The recipient receives the same transactional email twice.
For a password reset or sign-in code, the duplicate is confusing. For a receipt, invoice, or account alert, it can look like the underlying action happened twice.
Email SDK v1 classifies adapter failures with a delivery value:
not_sentmeans the adapter can prove the provider did not accept the message.unknownmeans dispatch may have started, so delivery cannot be ruled out.
Fallback continues for a proven not_sent failure. It stops for unknown by default:
const email = createEmailClient({
adapters: [primary, backup],
retry: { maxAttempts: 3 },
fallback: {
adapters: ["backup"],
onUnknownDelivery: "stop",
},
});
An application can explicitly set onUnknownDelivery: "continue" when duplicates are acceptable or a provider offers stronger idempotency guarantees. The important part is that the risky behavior is a named policy rather than an accidental consequence of a catch block.
The SDK does not claim exactly-once delivery across providers. No client library can prove that without cooperation from the remote systems. V1 instead makes uncertainty visible, supports idempotency keys where adapters can use them, and leaves durable workflow state with the application.
Retries stay inside one adapter before fallback changes routes
V1 separates two decisions that are often collapsed:
- Retry: try the same adapter again according to
maxAttempts, delay, andshouldRetry. - Fallback: move to another adapter only after the current route reaches a terminal failure and policy allows the transition.
await email.send(message, {
adapter: "primary",
retry: { maxAttempts: 1 },
fallback: { adapters: ["backup"] },
idempotencyKey: "receipt:order_123",
});
Per-send retry and fallback objects replace the client defaults. An AbortSignal stops active work, backoff, and every later route. If all routes fail, EmailRouteError.failures preserves the attempted adapter order and typed adapter errors, so logs and user-facing decisions do not have to parse provider strings.
Three sending methods replace one overloaded batch API
V1 gives each execution model a separate method:
send()sends one message and returns one result.sendMany()runs independent sends sequentially, preserves input order, and returns one settled result per item.sendPersonalized()renders recipient variables against one shared message template.
await email.sendMany([
{
message: welcomeMessage,
options: { idempotencyKey: "welcome:ada" },
},
{
message: receiptMessage,
options: { idempotencyKey: "receipt:linus:123" },
},
]);
await email.sendPersonalized(
{
message: {
from: "Acme <hello@acme.com>",
subject: "Hi %recipient.name%",
text: "Welcome, %recipient.name%.",
},
recipients: [
{ to: "ada@example.com", variables: { name: "Ada" } },
{ to: "linus@example.com", variables: { name: "Linus" } },
],
},
{ idempotencyKey: "onboarding:2026-07" },
);
sendMany() is intentionally sequential in v1. That makes ordering and settled results predictable instead of hiding concurrency and rate-limit behavior inside the client. Applications that need a durable, concurrent queue should keep that orchestration in their job system or use the Convex component.
Plugins extend the pipeline without changing the core message API
The v1 plugin surface can register adapters, add middleware, compose lifecycle hooks, and extend the returned client. Built-in plugins cover common cross-cutting concerns:
- defaults for headers, tags, metadata, reply-to, and idempotency prefixes
- dynamic adapter routing
- whole-send timeouts
- redacted observability events
- in-memory capture for tests
import { defaultsPlugin } from "@opencoredev/email-sdk/plugins/defaults";
import { observabilityPlugin } from "@opencoredev/email-sdk/plugins/observability";
import { timeoutPlugin } from "@opencoredev/email-sdk/plugins/timeout";
const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
plugins: [
defaultsPlugin({
headers: [{ name: "X-App", value: "acme" }],
idempotencyKeyPrefix: "acme:",
}),
timeoutPlugin({ timeoutMs: 10_000 }),
observabilityPlugin({
log(event) {
console.log(event.type, event.adapter);
},
}),
],
});
Hooks and observability are designed around lifecycle metadata, not message content or secrets. Email SDK's anonymous telemetry follows the same boundary: it records operational shape such as adapter names, commands, timing, and error codes, while excluding subjects, addresses, bodies, headers, attachments, keys, and raw provider responses. It can be disabled with EMAIL_SDK_TELEMETRY=0, DO_NOT_TRACK=1, or telemetry: false.
Testing and the CLI use the same validation path
A production email pipeline should be testable without a provider account. The /testing entry point includes memory and failing adapters, plus capture utilities for asserting route attempts, retries, and lifecycle events.
The package also installs an email-sdk CLI:
email-sdk adapters
RESEND_API_KEY=re_xxx email-sdk doctor --adapter resend
email-sdk send \
--adapter resend \
--from "Acme <hello@acme.com>" \
--to ada@example.com \
--subject "Welcome" \
--text "Your account is ready." \
--dry-run
send --dry-run constructs the adapter, validates the normalized message, and prints the plan without calling the adapter's send method. This makes local setup checks use the same contract as application sends.
The same client now connects to templates, AI tools, and durable Convex workflows
V1 includes optional integrations around the core client:
@opencoredev/email-sdk/reactrenders React email templates and includes email-safe, shadcn-themed building blocks that use inline styles.@opencoredev/email-sdk/aicreates a narrow, approval-gated Vercel AI SDK tool. The application binds the sender, and the model can request only recipients, subject, text, and HTML.@opencoredev/convex-emailadds durable queueing, retries, fallback routes, reactive status, provider webhooks, recovery, and a safe test mode for Convex applications.
These integrations do not expand the model-visible or browser-visible secret boundary. Provider credentials remain in the server environment, and sending policy remains application-owned.
Migrating from v0
V1 uses adapter terminology consistently across the root API. The largest migration changes are mechanical:
providersbecomesadaptersdefaultProviderbecomesdefaultAdapterprovidersend options becomeadaptersendBatch()becomessendMany()recipientVariablesmoves tosendPersonalized()retry.retriesbecomesretry.maxAttempts- fallback becomes an explicit object with
adaptersandonUnknownDelivery idempotencyKeymoves into send options
The v0 to v1 migration guide includes before-and-after examples for the full breaking-change surface. The compatibility entry point can help stage a migration, but new code should target the adapter-first v1 API directly.
Install v1
bun add @opencoredev/email-sdk
Start with the quickstart, choose an adapter from the adapter directory, and read the production send pipeline guide before enabling retries or fallback for critical messages.
Email SDK is open source under AGPL-3.0. The source, issues, and contribution guides are available on GitHub. Companies that want an official adapter can open an issue or contact the project.
This release was made possible by the project's sponsors: Resend, Sequenzy, JetEmail, Primitive, Lettermint, Instatus, and Notra.
The goal for v1 is simple: make a transactional email pipeline easier to change without hiding the decisions that determine whether it is safe.