Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

productdevbook/unemail

Repository files navigation


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

Design goals

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

Install

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

Hello world

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.

Mailtrap (Email API + Email Sandbox)

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.

Message streams (Postmark-style)

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 })

Deliverability & compliance

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 forwarders
  • unemail/dmarc — aggregate (RUA) XML + gzip parser
  • unemail/mta-sts — policy file generator + TLS-RPT JSON parser
  • unemail/parse/arf — RFC 5965 feedback-loop (FBL) reports

Provider-side templates

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.

Personalizations & batch

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)
}

Rendering

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 }],
 }),
 ],
})

Resilience middleware

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()))

OAuth2 (Gmail / Microsoft 365)

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!,
 }),
)

Provider fallback + composition

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" })],
 }),
})

Queues

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.

Inbound + webhooks

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!,
})

Unified event stream

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, ...]

Typed addresses

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"

Testing

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()

Authoring a driver

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 */
 },
}))

Result helpers

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

License

Published under the MIT license. Made by @productdevbook and community.

Architecture inspired by unjs/unstorage.

About

One unified email API across 18 providers (SMTP, Resend, SES, Postmark, SendGrid, Mailgun, ...). Zero deps, RFC 8058 + DKIM ready, edge-first, strict TypeScript.

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

Packages

Contributors

AltStyle によって変換されたページ (->オリジナル) /