unemail — One API for every email provider
unemail
Driver-based, zero-dependency TypeScript email library.
Send, batch, schedule, dedupe, render, parse, verify, and sign — one unified API across every runtime.
npm version
npm downloads
bundle size
license
sponsors
| Goal | How unemail delivers |
|---|---|
| One API, many transports | createEmail({ driver }) — 15+ built-in drivers (SMTP, Resend, SES, Postmark, SendGrid, Mailgun, Mailtrap, Brevo, MailerSend, Loops, Zeptomail, MailChannels, Cloudflare Email, ...) |
| Cross-runtime | Node, Bun, Deno, Cloudflare Workers, browser — core is zero-dep and Web-API only. No axios, ever. |
| Compliance-ready | RFC 8058 one-click List-Unsubscribe, DKIM + ARC signing, suppression/preference stores, DMARC + TLS-RPT + ARF parsers |
| Resilient by default | Idempotency, retry w/ jitter, per-provider rate-limit, circuit breaker, dedupe, dead-letter, provider fallback |
| Unified observability | Structured logging, OpenTelemetry, Prometheus metrics, normalized EmailEvent stream across send + webhook paths |
| Modern DX | { data, error } Result discriminated union, typed Address primitive, react:/mjml:/handlebars:/liquid: props |
| Testing-first | 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 mailtrap from "unemail/driver/mailtrap" const email = createEmail({ driver: mailtrap({ apiKey: process.env.MAILTRAP_API_KEY!, inboxId: process.env.MAILTRAP_INBOX_ID, sandbox: process.env.MAILTRAP_USE_SANDBOX === "true", }), }) await email.send({ from: "a@b.com", to: "c@d.com", subject: "Test", text: "hi", sandbox: true })
See docs/drivers.md for Email API vs sandbox routing.
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 })
Gmail + Yahoo 2024 bulk-sender compliance is one line:
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.
DKIM 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 }, })
Suppression + 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 }) })
Other deliverability utilities:
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) reports
Eight 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) }
React 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()))
HTML 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 ), )
i18n dispatches per-locale renderers:
import { i18nRenderer } from "unemail/render/i18n" email.use( withRender( i18nRenderer({ fallback: handlebarsRenderer({ /* defaults */ }), byLocale: { tr: handlebarsRenderer({ /* tr */ }), en: handlebarsRenderer({ /* en */ }), }, }), ), )
Calendar 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 */ }, })
Reply-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
Webhook signature verification — Resend, Postmark, Mailgun,
SendGrid, SES, plus a zero-dep Standard Webhooks
(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
- docs/drivers.md — driver matrix + authoring guide + error taxonomy
- docs/rendering.md — React Email / jsx-email / MJML / Handlebars / Liquid / i18n / HTML pipeline
- docs/inbound.md —
unemail/parse+ unified inbound handler + reply stripper + thread stitcher - docs/webhooks.md — signature verification for 6 providers + Standard Webhooks
- docs/deliverability.md — DKIM + ARC + List-Unsubscribe + DMARC + MTA-STS + suppression + preferences
- docs/testing.md —
createTestEmail,waitFor, 5 Vitest matchers + snapshots - docs/observability.md — logging + OTel + Prometheus metrics + unified event stream
- docs/queue.md — memory / unstorage / BullMQ / pg-boss / SQS
- docs/rfcs/001-packaging.md — why single package with sub-paths
- MIGRATION.md — upgrading from v0.x
Published under the MIT license. Made by @productdevbook and community.
Architecture inspired by unjs/unstorage.