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

emailkit

v3.0.0

Published

Unified email API for multiple providers.

Readme

EmailKit

Unified email SDK with pluggable drivers (Gmail, Mailgun, Resend, AIInbx, Outlook) and optional Next.js helpers.

Install

npm i emailkit

Migrating from 2.x to 3.0

Webhook verification is stricter:

  • Resend and AIInbx verifyWebhook now require rawBody (the unparsed request text) on the webhook request and throw MISSING_RAW_BODY without it. Custom adapters must capture the body as text before parsing; the built-in Next.js adapter already does.
  • Mailgun and AIInbx reject webhooks whose signed timestamp is more than 5 minutes from server time (replay protection).
  • Outlook drivers using webhookAuthResolver without autoSubscribeInbound must set an explicit webhookClientState — construction throws otherwise, and Graph subscriptions created without that clientState must be recreated before notifications verify.

Usage

import { EmailKit, MailgunDriver, ResendDriver } from "emailkit";

const emailkit = EmailKit({
  emailDrivers: [
    ResendDriver({
      id: "resend",
      apiKey: process.env.RESEND_API_KEY!,
      webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
    }),
    MailgunDriver({
      id: "mailgun",
      apiKey: process.env.MAILGUN_API_KEY!,
      webhookSigningKey: process.env.MAILGUN_WEBHOOK_SIGNING_KEY!,
    }),
  ],
  resolveEmailDriver: async (ctx) => {
    if (ctx.operation === "sendEmail") {
      return ctx.message.from.email.endsWith("@mg.example.com")
        ? { emailDriver: "mailgun" }
        : { emailDriver: "resend" };
    }

    if (
      "input" in ctx &&
      ctx.input &&
      "domain" in ctx.input &&
      ctx.input.domain?.endsWith("mg.example.com")
    ) {
      return { emailDriver: "mailgun" };
    }

    return "resend";
  },
  hooks: {
    email: {
      onInbound: async (event) => {
        console.log("Received inbound email:", event);
      },
      onOpened: async (event) => {
        console.log("Email opened:", event);
      },
    },
  },
});

await emailkit.sendEmail({
  from: { email: "[email protected]", name: "Sender" },
  to: { email: "[email protected]" },
  subject: "Hello",
  text: "Hello world",
});

EmailKit secret

Drivers that sign callback state or run OAuth mailbox connection flows need an EmailKit secret. EmailKit automatically reads EMAILKIT_SECRET when secret is omitted:

EMAILKIT_SECRET="replace-with-a-long-random-secret"

You can still pass secret explicitly, and it takes precedence over EMAILKIT_SECRET:

const emailkit = EmailKit({
  emailDrivers: [OutlookDriver({ clientId, clientSecret })],
  secret: process.env.AUTH_SECRET,
});

EmailKit also reads PUBLIC_BASE_URL automatically for public webhook and callback URLs. With PUBLIC_BASE_URL=https://app.example.com, the default driver route is https://app.example.com/api/email/:emailDriverId. Passing publicRoutes.baseUrl or publicRoutes.route overrides those defaults.

Mailboxes

Mailbox objects are durable identity only. Store provider OAuth material from hooks.mailbox.onConnected, then pass it back as a separate auth value when sending or managing mailbox-scoped webhooks:

import { EmailKit, OutlookDriver } from "emailkit";

const emailkit = EmailKit({
  secret: process.env.EMAILKIT_SECRET!,
  emailDrivers: [
    OutlookDriver({
      id: "outlook",
      clientId: process.env.OUTLOOK_CLIENT_ID!,
      clientSecret: process.env.OUTLOOK_CLIENT_SECRET!,
      webhookClientState: process.env.OUTLOOK_WEBHOOK_CLIENT_STATE!,
      webhookAuthResolver: async ({ mailbox }) => {
        return mailbox?.id ? await loadOutlookAuth(mailbox.id) : undefined;
      },
    }),
  ],
  hooks: {
    mailbox: {
      onConnected: async ({ mailbox, auth }) => {
        if (mailbox && auth) await saveOutlookAuth(mailbox.id, auth);
      },
    },
  },
});

await emailkit.mailboxes.connect({
  emailDriver: "outlook",
  email: "[email protected]",
});

const mailbox = await loadMailbox("[email protected]");
const auth = await loadOutlookAuth(mailbox.id);

await emailkit.sendEmail({
  from: { email: mailbox.email },
  to: { email: "[email protected]" },
  subject: "Hello",
  text: "Hello",
  sender: { emailDriver: "outlook", mailbox, auth },
});

await emailkit.mailboxes.webhooks.setup({
  emailDriver: "outlook",
  mailbox,
  auth,
  events: ["inbound"],
});

Gmail

Gmail uses delegated Google OAuth mailbox auth. The same EmailKit mailbox, send, webhook, sync, attachment, and providerFetch APIs work across Gmail and other drivers; Gmail-specific setup is contained in the driver config.

Send-only Gmail needs a Google Cloud project with the Gmail API enabled, an OAuth consent screen, and an OAuth client configured with your EmailKit callback URL:

EMAILKIT_SECRET="replace-with-a-long-random-secret"
PUBLIC_BASE_URL="https://app.example.com"
GOOGLE_CLIENT_ID="..."
GOOGLE_CLIENT_SECRET="..."

With the default route template, register this OAuth redirect URI:

https://app.example.com/api/email/gmail
import { EmailKit, GmailDriver } from "emailkit";

export const emailkit = EmailKit({
  emailDrivers: [
    GmailDriver({
      id: "gmail",
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  hooks: {
    mailbox: {
      onConnected: async ({ mailbox, auth }) => {
        if (mailbox && auth) await saveGmailAuth(mailbox.email, auth);
      },
      onAuthUpdated: async ({ mailbox, auth }) => {
        if (mailbox?.email) await saveGmailAuth(mailbox.email, auth);
      },
    },
  },
});

Connect a mailbox once, persist the returned auth in onConnected, then send through the universal sendEmail API:

const { redirectUrl } = await emailkit.mailboxes.connect({
  emailDriver: "gmail",
  email: "[email protected]",
});

const mailbox = await loadMailbox("[email protected]");
const auth = await loadGmailAuth(mailbox.email);

await emailkit.sendEmail({
  from: { email: mailbox.email },
  to: { email: "[email protected]" },
  subject: "Hello from Gmail",
  text: "Same EmailKit API, Gmail underneath.",
  sender: { emailDriver: "gmail", mailbox, auth },
});

Inbound Gmail needs one extra Google Cloud path because Gmail publishes mailbox changes through Cloud Pub/Sub:

  1. Enable the Gmail API and Cloud Pub/Sub in the same Google Cloud project.
  2. Create a topic, for example projects/my-project/topics/emailkit-gmail.
  3. Grant [email protected] the Pub/Sub Publisher role on that topic.
  4. Create a push subscription whose endpoint is your EmailKit webhook route with a token query parameter, for example https://app.example.com/api/email/gmail?token=secret-token.
  5. Set the same topic and token on the driver:
GOOGLE_PUBSUB_TOPIC="projects/my-project/topics/emailkit-gmail"
GMAIL_WEBHOOK_TOKEN="secret-token"
GmailDriver({
  id: "gmail",
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  pubsubTopic: process.env.GOOGLE_PUBSUB_TOPIC!,
  verificationToken: process.env.GMAIL_WEBHOOK_TOKEN!,
  autoSubscribeInbound: true,
  webhookAuthResolver: async ({ mailboxEmail }) => {
    return mailboxEmail ? await loadGmailAuth(mailboxEmail) : undefined;
  },
  onAuthUpdated: async ({ mailbox, auth }) => {
    if (mailbox?.email) await saveGmailAuth(mailbox.email, auth);
  },
});

Gmail push notifications only include the mailbox email and a history cursor. EmailKit hydrates those notifications into normal onInbound events, but it must be able to load and update the mailbox auth. Wire hooks.mailbox.onConnected, hooks.mailbox.onAuthUpdated, and the Gmail driver's onAuthUpdated to the same durable store. Gmail watches expire after about 7 days; EmailKit renews active watches opportunistically and reports renewAfter for scheduled refreshes.

Outlook

Outlook uses delegated Microsoft Graph mailbox auth. Register your EmailKit route as a Web redirect URI in Microsoft Entra, then configure the driver:

import { EmailKit, OutlookDriver } from "emailkit";

export const emailkit = EmailKit({
  secret: process.env.EMAILKIT_SECRET!,
  emailDrivers: [
    OutlookDriver({
      id: "outlook",
      clientId: process.env.OUTLOOK_CLIENT_ID!,
      clientSecret: process.env.OUTLOOK_CLIENT_SECRET!,
      tenant: process.env.OUTLOOK_TENANT ?? "common",
      scopes: ["offline_access", "User.Read", "Mail.Send", "Mail.Read"],
      webhookClientState: process.env.OUTLOOK_WEBHOOK_CLIENT_STATE!,
    }),
  ],
  publicRoutes: {
    connectLandingRoutes: {
      success: "/settings/email",
      failure: "/settings/email",
    },
  },
});

The Outlook driver exposes only the send features it can map to Microsoft Graph: CC, BCC, Reply-To addresses, attachments with content, and custom headers that begin with X-. It does not expose templates, scheduling, unsubscribe, tracking controls, tags, metadata, idempotency, or domain APIs. Shared mailbox/send-as is not supported by the normalized driver; use providerFetch for Graph-specific escape hatches.

Outlook webhook setup uses the auth you pass to create the Microsoft Graph subscription, but live webhook parsing happens later in a separate request. Configure webhookAuthResolver or webhookAuth so EmailKit can hydrate Graph notifications when those webhooks arrive. Notifications without a matching clientState are rejected with 401: the driver stamps a derived default onto subscriptions it creates, and when you create subscriptions yourself (webhookAuthResolver without autoSubscribeInbound) you must set webhookClientState and include the same value in those subscriptions — the driver throws at construction otherwise.

Reply threading is native (nativeReplyThreading capability): Microsoft Graph cannot set In-Reply-To/References headers on outbound mail, so the driver maps reply.messageId onto Graph createReply instead and lets Exchange wire up conversation threading and reply headers itself. This requires sendEmailMode: "draft" (Mail.ReadWrite), and the capability is derived from the configuration: only a driver constructed with sendEmailMode: "draft" advertises nativeReplyThreading, so reply.messageId is a compile error on a driver using the default "sendMail" mode. A per-message provider.sendEmailMode: "sendMail" override on a draft-configured driver still throws NOT_SUPPORTED at runtime when combined with reply.messageId. The driver looks the source message up by internetMessageId in the connected mailbox, creates a reply draft, patches in the outgoing message, uploads attachments, and sends it. The send result reports replyThreading: "applied" on success, or replyThreading: "skipped" when the source message was not found and the message was sent unthreaded instead (an unthreaded reply beats a failed send). reply.references and reply.threadId stay unsupported because Exchange derives the reference chain itself. Custom headers cannot be combined with reply.messageId: Graph createReply does not accept internetMessageHeaders and drafts cannot receive them after creation, so the driver throws NOT_SUPPORTED instead of risking a silently dropped header — send without reply.messageId or without custom headers.

Domains

Use domain names or provider ids. EmailKit resolves the provider-specific identifier for you:

const existing = await emailkit.domains.getOrNull({ domain: "mg.example.com" });

const { domain, created } = await emailkit.domains.ensure({
  emailDriver: "mailgun",
  domain: "mg.example.com",
});

console.log(domain.status, created);

get() stays strict and throws on missing domains. getOrNull() is the normalized lookup path when you want "find this domain if it exists".

Domain operation context is carried separately from the provider identifier and is surfaced in hooks:

await emailkit.domains.verify({
  domain: "mg.example.com",
  context: { tenantId: "tenant_123" },
});

Drivers can advertise method-level domain support:

const capabilities = {
  domains: {
    list: true,
    create: true,
    get: true,
    verify: true,
    delete: true,
    identifier: "domainId",
  },
} as const;

Unsupported domain methods are omitted from the typed facade where possible and throw NOT_SUPPORTED if called dynamically.

Inbound attachments

For inbound email, treat event.attachments as metadata plus optional eager content. The stable retrieval path is emailkit.attachments.getContent(...):

hooks: {
  email: {
    onInbound: async (event) => {
      for (const attachment of event.attachments ?? []) {
        const content = await emailkit.attachments.getContent(attachment);
        await saveAttachment(attachment.filename, content);
      }
    },
  },
}

If a driver can hydrate stored attachments before your hook runs, it will populate attachment.content eagerly. getContent() still works either way for drivers that declare providerFetch, so app code does not need provider-specific fetch/auth logic.

Inbound attachments are stamped with attachment.emailDriver by the EmailKit client. That means stored attachment records can be persisted and later passed back to emailkit.attachments.getContent(attachment) without also passing { emailDriver }. If you do pass an explicit { emailDriver }, it must match the attachment stamp.

Sync (replay missed events)

If your server was down and webhooks were lost, ask the provider to replay the window. Synced events are re-pulled from the provider and dispatched through the same hooks as live webhooks (onInbound, onDelivered, ...), so nothing else in your app changes. The only knob is since:

// Mailbox-scoped (Outlook): same mailbox + auth as other mailbox operations
const result = await emailkit.mailboxes.sync({
  emailDriver: "outlook",
  mailbox,
  auth,
  since: outageStartedAt,
});

// Domain-scoped (Mailgun): replays inbound and outbound tracking events
await emailkit.domains.sync({ emailDriver: "mailgun", domain: "mg.example.com", since });

// Account-scoped (Resend, AIInbx): replays inbound email only
await emailkit.sync({ emailDriver: "resend", since });
await emailkit.sync({ emailDriver: "aiinbx", since });

console.log(result.dispatched, result.syncedFrom);

Semantics:

  • Events are replayed oldest-first and are indistinguishable from live webhook events. Sync is at-least-once: handle inbound idempotently (upsert by messageId) and replays cost nothing.
  • Event coverage is bounded by what the provider's API can list after the fact. Mailgun replays inbound and outbound tracking events; Outlook queries the mailbox message collection by default so moved or archived received mail can still replay, while skipping messages sent by the synced mailbox; Resend and AIInbx replay inbound email only — their APIs expose no tracking-event history, so onDelivered/onOpened webhooks missed during the outage are not recoverable there.
  • result.syncedFrom is the earliest time the provider data actually covered. When provider retention cuts the window short (Mailgun stores events for a bounded window), syncedFrom is later than since — reported, never thrown. For Mailgun, set eventsRetentionDays in the driver config to match your plan (default 3, the documented minimum) so coverage isn't under-reported.
  • Optional until bounds the window (exclusive), signal aborts long syncs, and context is surfaced on the email.onAll envelope for replayed events so you can suppress side effects like auto-replies during a replay.
  • If a hook throws mid-replay, sync throws EmailKitSyncError carrying dispatched and lastEventTimestamp; resume with since: error.lastEventTimestamp.

Like every other facade, sync is typed by capability (sync: { mailbox: true } etc.) — the method only exists on scopes your configured drivers support.

Webhooks (framework-agnostic)

const handle = emailkit.handler();
const res = await handle({ method, headers, body, rawBody });

rawBody is the unparsed request text. Drivers that sign the request body (Resend, AIInbx) require it and fail verification with a MISSING_RAW_BODY error when a custom adapter omits it — capture the body as text before parsing it.

Provider webhooks are verified before dispatch and rejected with 401 when verification is not possible — there is no permissive fallback. Configure each driver's webhook signing secret (webhookSecret, webhookSigningKey, Outlook webhookClientState, or Gmail verificationToken) before accepting production webhook traffic. Gmail additionally requires the Pub/Sub push subscription endpoint URL to carry the same value as ?token=<verificationToken>; a Gmail driver configured for inbound webhooks without a verificationToken throws at construction. Outlook derives a default clientState for subscriptions the driver creates itself, so an explicit webhookClientState is only required when subscriptions are created outside the driver (webhookAuthResolver without autoSubscribeInbound) — that misconfiguration also throws at construction. Drivers that sign the request body (Resend, AIInbx) require rawBody on the webhook request; the built-in Next.js adapter provides it automatically for every content type. Mailgun and AIInbx additionally reject webhooks whose signed timestamp is more than 5 minutes from server time (replay protection) — rejections are logged so clock skew is distinguishable from a bad signature.

Provider fetch helper

Call provider-specific endpoints while reusing the configured authentication and base URL. This advanced escape hatch is only surfaced when at least one configured driver declares providerFetch:

const response = await emailkit.providerFetch("/v4/domains", {
  emailDriver: "mailgun",
  method: "GET",
  searchParams: { limit: 25 },
});

if (!response.ok) throw new Error("Provider API failed");
const domains = await response.json();

The helper accepts any fetch options plus an optional searchParams object. Use relative paths for provider APIs, or absolute URLs that stay inside the provider API origin. Stored attachment downloads should go through emailkit.attachments.getContent(...); drivers avoid sending provider bearer tokens to unrelated absolute URLs.

Next.js adapter (optional)

Single-driver route:

import { createNextEmailKitHandler } from "emailkit/nextjs";

export const { GET, POST } = createNextEmailKitHandler(emailkit);

Multi-driver route, for example app/api/email/[emailDriver]/route.ts:

import { createNextEmailKitHandler } from "emailkit/nextjs";

export const { GET, POST } = createNextEmailKitHandler(emailkit, {
  emailDriver: async (_request, context) => {
    const params = await context.params;
    const emailDriver = params?.emailDriver;
    return typeof emailDriver === "string" ? emailDriver : undefined;
  },
});