npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

unemail

v0.4.1

Published

Driver-based TypeScript email library — send, parse, render, verify. Zero-deps core; works on Node, Bun, Deno, Cloudflare Workers, and the browser.

Readme

Design goals

| Goal | How unemail delivers | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | One API, many transports | createEmail({ driver }) — 15+ built-in drivers (SMTP, Resend, SES, Postmark, SendGrid, Mailgun, 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 <[email protected]>",
  to: "[email protected]",
  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.

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: "[email protected]",
  },
})
// → 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: "[email protected]", variables: { name: "Ada" } },
    { to: "[email protected]", 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: "[email protected]",
      start: new Date("2026-05-01T10:00:00Z"),
      end: new Date("2026-05-01T11:00:00Z"),
      summary: "Design sync",
      organizer: { email: "[email protected]" },
      attendees: [{ email: "[email protected]", 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 <[email protected]>")
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("[email protected]")
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.