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

resend-inbound-kit

v0.3.0

Published

Turn Resend inbound email into one function: verify the webhook, fetch the full body + attachments, and forward to any inbox with threading preserved. TypeScript-first, ESM + CJS.

Readme

resend-inbound-kit

Receive email at your domain with Resend, in one function. Verify the webhook, fetch the full body + attachments, and forward it to any inbox (Gmail, etc.) with email threading preserved. TypeScript-first. ESM + CJS.

createInbound({
  apiKey, webhookSecret,
  onEmail: (email) => email.forwardTo("[email protected]"),
});

Status: 0.3.0. Built against the Resend Node SDK v6 and the 2026 inbound docs. Requires Node 20+.


Why

Resend can receive email, but inbound is webhook-only and the webhook gives you metadata only (sender, subject, attachment list, ids). To do anything useful you hand-write a 5-step chain:

Before (the chain you write yourself)

// 1. Verify the Svix-style webhook signature (or anyone can POST you fake mail)
const event = resend.webhooks.verify({ payload, headers, webhookSecret });

// 2. The webhook has NO body. Fetch the real email.
const { data: email } = await resend.emails.receiving.get(event.data.email_id);

// 3. The body has NO attachment bytes. List them...
const { data: list } = await resend.emails.receiving.attachments.list({ emailId });
// ...then download each one from its short-lived signed URL.
const files = await Promise.all(list.data.map(async (a) => ({
  filename: a.filename,
  content: Buffer.from(await (await fetch(a.download_url)).arrayBuffer()),
  contentId: a.content_id ?? undefined,
})));

// 4. Re-send it outbound to your inbox...
// 5. ...and remember Message-ID / In-Reply-To / References or Gmail breaks the thread.
await resend.emails.send({
  from, to: "[email protected]", subject: email.subject, html: email.html, text: email.text,
  attachments: files, replyTo: email.from,
  headers: { "In-Reply-To": email.message_id, References: /* ...build the chain... */ },
});

After

import { createInbound } from "resend-inbound-kit";

const handler = createInbound({
  apiKey: process.env.RESEND_API_KEY!,
  webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
  forwardFrom: "Inbox <[email protected]>",
  onEmail: async (email) => {
    await email.forwardTo("[email protected]");
  },
});

Signature verification, body fetch, attachment download + re-attach, and threading headers are all handled.


What about Resend's own forward()?

The Resend SDK ships a blind forwarder:

await resend.emails.receiving.forward({
  emailId: event.data.email_id,
  to: "[email protected]",
  from: "Inbox <[email protected]>",
});

Use it when you want mail moved and nothing else. Use this kit when you need the email itself.

| | SDK forward() | this kit | | --- | --- | --- | | Signature verification | no (you still write it) | yes | | Hands you the parsed email | no — id in, id out | yes | | Reply-To the real sender | no option | yes, by default | | Threading / custom headers | no | yes | | Original headers readable in your code | no | yes, as email.headers | | Per-file and per-message size cap | no; an oversized send just fails | yes, with the files handed back | | Needs Resend to still hold the raw .eml | yes, errors without it | no |

The line that matters: forward() never gives you the email. Routing by sender, filtering spam, loop guards, saving to your database — none of it is possible without calling .get() yourself, at which point you are rebuilding this package by hand.

To be precise about that headers row: neither option re-sends the sender's original headers on the forwarded copy. The difference is that this kit hands them to you as email.headers, so you can read, log or act on them.

There is no speed advantage either. forward() downloads the raw .eml into your function, parses it there, and posts it back up — the same round trip this kit makes.


Install

npm install resend-inbound-kit resend

resend is a peer dependency, so you control its version.


Quick start (Next.js App Router)

Create app/api/inbound/route.ts:

// app/api/inbound/route.ts
import { createInboundRoute } from "resend-inbound-kit/next";

export const runtime = "nodejs"; // attachments need Node's Buffer

export const { POST } = createInboundRoute({
  apiKey: process.env.RESEND_API_KEY!,
  webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
  forwardFrom: "Inbox <[email protected]>", // a verified Resend domain
  onEmail: async (email) => {
    await email.forwardTo("[email protected]");
  },
});

That is the whole route. See examples/nextjs-forward.

Top-level like that is fine: createInbound does not throw when the env vars are missing, so next build — which evaluates route modules without your runtime secrets — won't fail. Missing config surfaces as a 500 on the first real request instead.

Then in Resend: add a webhook pointing at https://your-app.com/api/inbound for the email.received event, and configure your inbound domain (point its MX records as Resend instructs).


Other frameworks

The core createInbound returns a framework-agnostic handler (req) => Promise<InboundResult> where req = { rawBody, headers }. Always pass the raw request body string (not parsed JSON), or signature verification will fail.

Hono

import { Hono } from "hono";
import { inboundHono } from "resend-inbound-kit/hono";

const app = new Hono();
app.post("/inbound", inboundHono({
  apiKey: process.env.RESEND_API_KEY!,
  webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
  forwardFrom: "Inbox <[email protected]>",
  onEmail: async (email) => { await email.forwardTo("[email protected]"); },
}));

Express

Mount a raw body parser before the handler so the bytes are untouched:

The one mistake to avoid. If express.json() parses the body first, the exact bytes Resend signed are gone, and no amount of re-stringifying gets them back (key order and spacing differ). This adapter refuses a parsed body with 500 / "config_error" and a message naming the middleware order. It does not pass the mangled bytes through to verification, because that fails as "invalid_signature" and sends you debugging a signing secret that was fine all along.

import express from "express";
import { inboundExpress } from "resend-inbound-kit/express";

const app = express();
app.post(
  "/inbound",
  express.raw({ type: "*/*" }), // gives req.body as a Buffer
  inboundExpress({
    apiKey: process.env.RESEND_API_KEY!,
    webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
    forwardFrom: "Inbox <[email protected]>",
    onEmail: async (email) => { await email.forwardTo("[email protected]"); },
  }),
);

Anything else (core handler)

import { createInbound } from "resend-inbound-kit";

const handler = createInbound({ /* ...options... */ });

// In your own route:
const rawBody = await readRawBodyAsString(request);
const result = await handler({ rawBody, headers: request.headers });
// result.status -> send it back; result.outcome -> "ok" | "invalid_signature" | ...

API reference

createInbound(options)

| Option | Type | Default | What it does | | --- | --- | --- | --- | | apiKey | string | — | Your Resend API key (server-side). | | webhookSecret | string | — | This endpoint's signing secret (whsec_…) from the Resend dashboard. | | onEmail | (email: InboundEmail) => void \| Promise<void> | — | Your handler. Runs after the email is fully fetched. | | shouldProcess | (m: InboundMetadata) => boolean \| Promise<boolean> | — | Cheap gate that runs before the body fetch. Return false to drop the message. | | onError | (ctx: InboundErrorContext) => void \| { status?, message? } | — | Log, and choose the HTTP status — which decides whether Resend retries. | | forwardFrom | string | — | Default "from" for forwards/replies/sends. Must be a verified Resend domain. | | maxAttachmentBytes | number | 20 MB | Max bytes to inline per file. Bigger ones are surfaced, not crashed. | | maxTotalBytes | number | 18 MB | Max bytes per message, across all attachments. Also caps how much is held in memory during download. | | downloadAttachments | boolean | true | Set false to keep attachment metadata + URLs without downloading bytes. | | attachmentTimeoutMs | number | 30000 | Timeout for a single attachment download. | | resend | ResendLike | — | Inject a Resend client (for tests). | | fetch | typeof fetch | global fetch | Inject a fetch (for tests / custom runtimes). |

Returns a handler (req: { rawBody: string; headers }) => Promise<InboundResult>.

Config is validated on the first request, not at construction — so a top-level export const { POST } = createInboundRoute({...}) doesn't break next build, where the env vars usually aren't set. A misconfigured endpoint answers 500 / "config_error".

result.outcome is one of "ok", "ignored_event", "skipped", "invalid_signature", "bad_request", "config_error", "handler_error".

Skipping mail before the expensive work

onEmail runs only after the body is fetched and every attachment is downloaded. If you want to drop a message — a loop guard, a blocklist, the wrong recipient — pay for none of that:

import { createInbound, isSameAddress } from "resend-inbound-kit";

createInbound({
  // Runs right after signature verification. No API calls made yet.
  shouldProcess: (m) =>
    !isSameAddress(m.from, "[email protected]") &&  // don't forward our own mail
    m.to.some((addr) => addr.endsWith("@yourdomain.com")),
  onEmail: async (email) => { await email.forwardTo("[email protected]"); },
});

InboundMetadata is what the webhook itself carried: emailId, from, to, cc, bcc, subject, messageId, createdAt, attachments (metadata only — no sizes, bytes or URLs), and raw. A false return answers 200 / "skipped", so Resend doesn't retry.

InboundEmail

interface InboundEmail {
  id: string;
  from: string;
  to: string[];
  cc: string[];
  bcc: string[];
  replyTo: string[];
  subject: string;
  text: string | null;
  html: string | null;
  headers: Record<string, string>; // lowercased keys
  messageId: string;
  inReplyTo: string | null;
  references: string | null;
  attachments: InboundAttachment[];

  forwardTo(address: string | string[], opts?: ForwardOptions): Promise<SendResult>;
  reply(opts: ReplyOptions): Promise<SendResult>;
  send(opts: SendOptions): Promise<SendResult>;
}

interface InboundAttachment {
  id: string;
  filename: string;
  contentType: string;
  size: number;                                // bytes
  contentDisposition: "inline" | "attachment";
  contentId: string | null;                    // for inline images (cid:)
  content?: Buffer;                            // bytes, if downloaded and not oversized
  downloadUrl: string;                         // Resend's short-lived signed URL
}

email.forwardTo(address, opts?)

Builds and sends a forwarded copy via Resend. Body + attachments + threading by default. Reply-To defaults to the original sender, so replying in Gmail goes back to the real person.

interface ForwardOptions {
  from?: string;                  // override forwardFrom
  html?: string | null;           // default: the original email.html
  text?: string | null;           // default: the original email.text
  subject?: string;               // default: original subject
  cc?: string | string[];
  replyTo?: string | string[];    // default: original sender
  headers?: Record<string, string>;
  preserveThreading?: boolean;    // default: true
  includeAttachments?: boolean;   // default: true
}

html / text let you wrap the original in your own template:

await email.forwardTo("[email protected]", {
  html: `<div class="banner">From ${email.from}</div>${email.html ?? ""}`,
});

Bodyless mail. Attachment-only and empty messages arrive with html and text both null, and Resend rejects a send with neither. This kit substitutes a single invisible space so the forward still goes out. Pass html or text if you'd rather say something.

email.reply(opts)

Reply in the same thread. Defaults: to = original sender, subject = Re: <subject>, threading on.

interface ReplyOptions {
  text?: string;
  html?: string;
  from?: string;                  // default: forwardFrom
  to?: string | string[];         // default: original sender
  subject?: string;               // default: "Re: <subject>"
  cc?: string | string[];
  headers?: Record<string, string>;
  preserveThreading?: boolean;    // default: true
}

email.send(opts)

A brand-new message. Not a forward, not a reply: no threading, no inherited body, no default recipient, no Re: prefix. It reuses the Resend client and the default from, and nothing else.

interface SendOptions {
  to: string | string[];          // required
  subject: string;
  html?: string;
  text?: string;
  from?: string;                  // default: forwardFrom
  cc?: string | string[];
  replyTo?: string | string[];    // NOT defaulted to the sender
  headers?: Record<string, string>;
  attachments?: InboundAttachment[]; // pass a subset of email.attachments
}

The notice-to-yourself case, which used to need four overrides on reply():

const { oversizedAttachments } = await email.forwardTo("[email protected]");
if (oversizedAttachments.length > 0) {
  await email.send({
    to: "[email protected]",
    subject: "Heads up: a file was too big to forward",
    text: oversizedAttachments.map((a) => a.filename).join(", "),
  });
}

SendResult

interface SendResult {
  id: string;                          // the outbound email id from Resend
  oversizedAttachments: InboundAttachment[]; // what wasn't inlined
}

Oversized attachments

Two caps, because one isn't enough:

  • maxAttachmentBytes (default 20 MB) rejects a single big file.
  • maxTotalBytes (default 18 MB) rejects a pile of medium ones. Three 15 MB files each pass a 20 MB per-file check and produce a 45 MB message that bounces at the far end. 18 MB on the wire is ~24 MB after base64, under Gmail's ~25 MB inbound limit and well under Resend's ~40 MB.

Files are inlined in order while the budget holds. Anything that doesn't fit is not sent and does not crash the forward — it comes back in result.oversizedAttachments, each with a downloadUrl you can use to stash the file in your own storage and link it:

const { oversizedAttachments } = await email.forwardTo("[email protected]");
for (const a of oversizedAttachments) {
  // e.g. upload a.downloadUrl's bytes to S3 and append a link to the email
}

Address helpers

email.from is the raw display string, "Jon Doe" <[email protected]>. These are exported so you don't rewrite the regex:

import {
  parseAddress, parseAddressList, getAddress, isSameAddress,
} from "resend-inbound-kit";

parseAddress('"Jon Doe" <[email protected]>'); // { name: "Jon Doe", address: "[email protected]" }
parseAddressList('"Doe, Jon" <[email protected]>, [email protected]'); // 2 entries; the quoted comma is not a separator
getAddress(email.from);                                // "[email protected]"
isSameAddress(email.from, "[email protected]");   // loop guard

Retries and double-sends

A throw inside onEmail returns 500, and Resend retries the delivery. That's right for a transient failure — but if your handler already forwarded the mail and then threw on something after it, the retry sends the message a second time.

Use onError to decide:

createInbound({
  onEmail: async (email) => {
    await email.forwardTo("[email protected]");
    await saveToDatabase(email);   // if this throws, the forward already went out
  },
  onError: ({ error, stage }) => {
    logger.error(error);
    // "hydrate" = nothing was sent yet, a retry is safe.
    // "handler" = your code ran and may have sent mail; don't invite a re-send.
    return stage === "handler" ? { status: 200 } : undefined;
  },
});

InboundErrorContext is { error, stage: "hydrate" | "handler", emailId, email? }. Returning nothing keeps the default 500. An onError that itself throws is ignored, and the original error still surfaces.


Replay protection (free)

Signature verification goes through Resend's resend.webhooks.verify(), which runs Standard Webhooks. That check includes the timestamp, not just the signature: a request whose svix-timestamp is more than 5 minutes off — old or in the future — is rejected outright.

So a captured webhook can't be replayed at you later. You get that by verifying, with no extra work and no extra dependency.


Reply as your domain inside Gmail (manual setup)

reply() sends from your server. If instead you want to hit Reply in Gmail and have it come from [email protected], set up Gmail "Send mail as" with Resend's SMTP once:

  1. In Resend, create SMTP credentials (Settings → SMTP). You'll get a host (smtp.resend.com), port 465, username resend, and a password (your API key).
  2. In Gmail: Settings → Accounts and Import → "Send mail as" → Add another email address.
  3. Enter your name and [email protected]. Uncheck "Treat as an alias" if you want replies to come back to that address.
  4. SMTP server smtp.resend.com, port 465, username resend, password = your Resend API key, SSL.
  5. Gmail sends a confirmation email to that address. Because your inbound domain forwards to this same Gmail (via this library), the confirmation lands in your inbox. Click the link.

Now Gmail's "From" dropdown includes [email protected], and replies go out through Resend from your domain. (This is a one-time Gmail/Resend setup; the library doesn't automate it.)


How threading works

Email clients group a thread using three headers: Message-ID (unique per message), In-Reply-To (the id you're answering), and References (the whole chain). When forwarding/replying, this library sets In-Reply-To to the original Message-ID and appends it to References, so the copy lands in the same thread. It does not reuse the original Message-ID on the new message (two messages can't share one id); Resend assigns the outbound id.

Turn it off per call with preserveThreading: false.


Notes / assumptions

  • Field names and method paths match the Resend Node SDK v6 types and the official 2026 docs (URLs are in code comments next to each call):
    • Receiving overview & webhook payload — resend.com/docs/dashboard/receiving/introduction
    • Verify webhooks — resend.com/docs/dashboard/webhooks/verify-webhooks-requests
    • Retrieve a received email — resend.com/docs/api-reference/emails/retrieve-received-email
    • List received-email attachments — resend.com/docs/api-reference/emails/list-received-email-attachments
    • Send email — resend.com/docs/api-reference/emails/send-email
  • Signature verification uses Resend's own resend.webhooks.verify() (Svix-style). No extra crypto dependency is needed. The Resend SDK's verify expects header keys id / timestamp / signature; this library maps the incoming svix-id / svix-timestamp / svix-signature (and the vendor-neutral webhook-* aliases) for you.
  • Run on a Node runtime (not Edge) so attachment bytes can use Buffer.

Contributing

Bug reports and pull requests are welcome — see CONTRIBUTING.md. To report a security issue privately, see SECURITY.md.


License

MIT © Lokesh Manchanda