One unified email API across 18 providers (SMTP, Resend, SES, Postmark, SendGrid, Mailgun, …). Zero deps, RFC 8058 + DKIM ready, edge-first, strict TypeScript.
unemail
Driver-based, zero-dependency TypeScript email library.
Send, batch, schedule, dedupe, render, parse, verify, and sign — one unified API across every runtime.
| Goal | How unemail delivers |
|---|---|
| undefinedOne API, many transportsundefined | createEmail({ driver }) — 15+ built-in drivers (SMTP, Resend, SES, Postmark, SendGrid, Mailgun, Brevo, MailerSend, Loops, Zeptomail, MailChannels, Cloudflare Email, …) |
| undefinedCross-runtimeundefined | Node, Bun, Deno, Cloudflare Workers, browser — core is zero-dep and Web-API only. No axios, ever. |
| undefinedCompliance-readyundefined | RFC 8058 one-click List-Unsubscribe, DKIM + ARC signing, suppression/preference stores, DMARC + TLS-RPT + ARF parsers |
| undefinedResilient by defaultundefined | Idempotency, retry w/ jitter, per-provider rate-limit, circuit breaker, dedupe, dead-letter, provider fallback |
| undefinedUnified observabilityundefined | Structured logging, OpenTelemetry, Prometheus metrics, normalized EmailEvent stream across send + webhook paths |
| undefinedModern DXundefined | { data, error } Result discriminated union, typed Address primitive, react:/mjml:/handlebars:/liquid: props |
| undefinedTesting-firstundefined | createTestEmail() with inbox + waitFor + 5 Vitest matchers + snapshot helper |
pnpm add unemail
Rendering, queue, and parser entries pull in optional peer deps only
when you import them:
pnpm add @react-email/render # unemail/render/react
pnpm add mjml # unemail/render/mjml
pnpm add handlebars # unemail/render/handlebars
pnpm add liquidjs # unemail/render/liquid
pnpm add juice # htmlPipeline(inlineCss())
pnpm add postal-mime # unemail/parse
pnpm add @opentelemetry/api # withTelemetry
pnpm add unstorage # unstorageQueue / unstorageSuppressionStore
pnpm add bullmq # unemail/queue/bullmq
pnpm add pg-boss # unemail/queue/pg-boss
import { createEmail } from "unemail"
import resend from "unemail/driver/resend"
const email = createEmail({ driver: resend({ apiKey: process.env.RESEND_KEY! }) })
const { data, error } = await email.send({
from: "Acme <hi@acme.com>",
to: "user@example.com",
subject: "Welcome",
text: "Thanks for signing up.",
})
if (error) throw error // error: EmailError — typed { code, status, retryable, ... }
console.log(data.id) // data: EmailResult — TS narrows after the error check
Every driver implements the same contract, so swapping providers is a
one-line change.
import postmark from "unemail/driver/postmark"
import ses from "unemail/driver/ses"
const email = createEmail({ driver: postmark({ token }) })
email.mount("marketing", ses({ region: "us-east-1" }))
await email.send({ stream: "transactional", to, subject, text })
await email.send({ stream: "marketing", to, subject, html })
undefinedGmail + Yahoo 2024 bulk-sender compliance is one line:undefined
await email.send({
from,
to,
subject,
html,
unsubscribe: {
url: `https://app.com/u?t=${token}`, // RFC 8058 one-click
mailto: "unsubscribe@acme.com",
},
})
// → auto-injects List-Unsubscribe + List-Unsubscribe-Post headers.
undefinedDKIM sign outbound SMTP (RSA or Ed25519, pure Web-Crypto):
import smtp from "unemail/driver/smtp"
const driver = smtp({
host: "smtp.acme.com",
dkim: { selector: "s1", domain: "acme.com", privateKey: pem },
})
undefinedSuppression + preferences stop sends before they hit the provider:
import { withSuppression } from "unemail/middleware"
import { memorySuppressionStore } from "unemail/suppression"
const store = memorySuppressionStore()
// webhook handler → store.add(recipient, "bounce")
const email = createEmail({ driver: withSuppression(resend({ apiKey }), { store }) })
undefinedOther deliverability utilities:undefined
unemail/verify/arc — ARC-Set signer (RFC 8617) for forwardersunemail/dmarc — aggregate (RUA) XML + gzip parserunemail/mta-sts — policy file generator + TLS-RPT JSON parserunemail/parse/arf — RFC 5965 feedback-loop (FBL) reportsEight drivers map msg.template into native template APIs:
await email.send({
from,
to,
subject,
template: { id: "tpl_welcome", variables: { name: "Ada" } },
})
// → SendGrid dynamic_template_data, Postmark TemplateModel,
// Mailgun h:X-Mailgun-Variables, Brevo params, MailerSend
// personalization.data, Loops dataVariables, Zeptomail merge_info.
SendGrid-style per-recipient fan-out — one batched API call when the
driver supports it, or an automatic loop when it doesn’t:
await email.send({
from,
subject: "Welcome",
personalizations: [
{ to: "ada@x.com", variables: { name: "Ada" } },
{ to: "bob@x.com", variables: { name: "Bob" }, subject: "Just for Bob" },
],
template: { id: "tpl_welcome" },
})
// Or stream results for huge fan-outs:
for await (const result of email.sendBatchStream(messages)) {
if (result.error) report(result.error)
}
undefinedReact Email / jsx-email / MJML / Handlebars / Liquid all plug in
as renderers:
import { createEmail, withRender } from "unemail"
import reactRender from "unemail/render/react"
import { handlebarsRenderer } from "unemail/render/handlebars"
const email = createEmail({ driver }).use(withRender(reactRender(), handlebarsRenderer()))
undefinedHTML post-processing pipeline — preheader, dark-mode, CID
auto-rewrite, juice inlining:
import {
htmlPipeline,
withPreheader,
cidRewrite,
darkModeHook,
inlineCss,
} from "unemail/render/pipeline"
email.use(
htmlPipeline(
withPreheader(), // reads msg.preheader
cidRewrite(), // <img src="logo.png"> → cid:logo
darkModeHook({ darkCss: "body{background:#000}" }),
inlineCss(), // peer: juice
),
)
undefinedi18n dispatches per-locale renderers:
import { i18nRenderer } from "unemail/render/i18n"
email.use(
withRender(
i18nRenderer({
fallback: handlebarsRenderer({
/* defaults */
}),
byLocale: {
tr: handlebarsRenderer({
/* tr */
}),
en: handlebarsRenderer({
/* en */
}),
},
}),
),
)
undefinedCalendar invites (ICS) attach to any message:
import { icalEvent } from "unemail/ics"
await email.send({
from,
to,
subject: "Design sync",
text: "...",
attachments: [
icalEvent({
uid: "evt-1@acme.com",
start: new Date("2026-05-01T10:00:00Z"),
end: new Date("2026-05-01T11:00:00Z"),
summary: "Design sync",
organizer: { email: "host@acme.com" },
attendees: [{ email: "ada@acme.com", rsvp: true }],
}),
],
})
import {
withRetry,
withCircuitBreaker,
withRateLimit,
rateLimitPresets,
withDedupe,
withLogger,
withTelemetry,
withMetrics,
createMetricsRegistry,
} from "unemail/middleware"
import { trace } from "@opentelemetry/api"
const metrics = createMetricsRegistry()
email
.use(withDedupe({ strategy: "contentHash", ttlSeconds: 60 }))
.use(withRetry({ retries: 3, backoff: "full-jitter", deadLetter: dlqDriver }))
.use(withRateLimit(rateLimitPresets.sendgrid()))
.use(withCircuitBreaker({ threshold: 5, cooldownMs: 30_000 }))
.use(withLogger({ redactLocalPart: true }))
.use(withTelemetry({ tracer: trace.getTracer("unemail") }))
.use(withMetrics({ registry: metrics }))
// Prometheus exposition:
app.get("/metrics", () => new Response(metrics.expose()))
import { oauth2Gmail } from "unemail/middleware"
email.use(
oauth2Gmail({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
refreshToken: process.env.GOOGLE_REFRESH_TOKEN!,
}),
)
import fallback from "unemail/driver/fallback"
import roundRobin from "unemail/driver/round-robin"
import resend from "unemail/driver/resend"
import ses from "unemail/driver/ses"
const email = createEmail({
driver: fallback({
drivers: [resend({ apiKey: process.env.RESEND_KEY! }), ses({ region: "us-east-1" })],
}),
})
In-memory / unstorage / BullMQ / pg-boss / AWS SQS all implement the
same EmailQueue contract. msg.scheduledAt defers the send through
every backend:
import memoryQueue from "unemail/queue/memory"
import { startWorker } from "unemail/queue/worker"
const queue = memoryQueue()
startWorker(email, queue, { concurrency: 5, maxAttempts: 5 }).start()
await queue.enqueue({
from,
to,
subject,
scheduledAt: new Date(Date.now() + 60 * 60 * 1000), // send in 1h
})
Swap for bullmqQueue({ bull }), pgBossQueue({ boss }), or
sqsQueue({ sqs, queueUrl }) for durable multi-process sending.
Pre-normalized handlers for Cloudflare Email, Postmark, SendGrid,
Mailgun, and SES (via SNS):
import { defineInboundHandler } from "unemail/inbound"
import sendgridInbound from "unemail/inbound/sendgrid"
import { defineSesInboundHandler } from "unemail/inbound/ses"
export default defineInboundHandler({
providers: [sendgridInbound()],
onEmail(mail) {
/* ParsedEmail */
},
})
undefinedReply-only text extraction (EN/TR/DE/FR/ES):
import { stripReply } from "unemail/inbound/reply"
import { threadKey } from "unemail/inbound/thread"
const { text, quoted } = stripReply(parsed.text ?? "")
const thread = threadKey(parsed) // canonical root Message-ID
undefinedWebhook signature verification — Resend, Postmark, Mailgun,
SendGrid, SES, plus a zero-dep Standard Webhooksundefined
(standardwebhooks.com) verifier that’s <5 kB (vs Svix’s ~1 MB):
import { verifyStandardWebhook } from "unemail/webhook/standard"
const body = await verifyStandardWebhook(request, {
secret: process.env.WHSEC!,
})
Send events + webhook events converge on one EmailEvent shape:
import { EventBus, withEvents, memoryEventStore } from "unemail/events"
const bus = new EventBus()
const store = memoryEventStore()
bus.on((e) => store.append(e))
const email = createEmail({ driver: withEvents(resend({ apiKey }), bus) })
// later:
const timeline = await store.list!(messageId)
// [send.queued, send.attempt, send.success, delivered, opened, ...]
Validate at system boundaries — rejects malformed input before it
reaches a driver:
import { parseAddress } from "unemail/address"
const { data, error } = parseAddress("Ada <ada@acme.com>")
if (error) throw error
data.local // "ada"
data.domain // "acme.com"
import { createTestEmail, emailMatchers, toEmailSnapshot } from "unemail/test"
import { expect } from "vitest"
expect.extend(emailMatchers)
const email = createTestEmail()
await onboardingFlow(email, user)
expect(email).toHaveSentTo("ada@acme.com")
expect(email).toHaveSentWithSubject(/welcome/i)
expect(email).toHaveSentWithAttachment("invite.ics")
expect(email).toHaveSentMatching((m) => m.metadata?.userId === user.id)
expect(toEmailSnapshot(email.last!)).toMatchSnapshot()
import { defineDriver } from "unemail"
export default defineDriver<{ apiKey: string }>((opts) => ({
name: "my-driver",
options: opts,
flags: { html: true, attachments: true, batch: true, cancelable: true },
async send(msg) {
const res = await fetch("https://api.example.com/send", {
method: "POST",
headers: { authorization: `Bearer ${opts!.apiKey}` },
body: JSON.stringify(msg),
})
if (!res.ok) return { data: null, error: new Error("send failed") as never }
const body = (await res.json()) as { id: string }
return { data: { id: body.id, driver: "my-driver", at: new Date() }, error: null }
},
async cancel(id) {
/* optional */
},
async retrieve(id) {
/* optional */
},
}))
import { isOk, isErr, unwrap, unwrapOr, mapOk, tryAsync } from "unemail/result"
const res = await email.send({ ... })
if (isOk(res)) console.log(res.data.id)
const id = unwrapOr(res, { id: "offline", driver: "mock", at: new Date() }).id
unemail/parse + unified inbound handler + reply stripper + thread stitchercreateTestEmail, waitFor, 5 Vitest matchers + snapshotsPublished under the MIT license. Made by
@productdevbook and
community.
Architecture inspired by unjs/unstorage.
We use cookies
We use cookies to analyze traffic and improve your experience. You can accept or reject analytics cookies.