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.
Maintainers
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 resendresend 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 with500/"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 guardRetries 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:
- In Resend, create SMTP credentials (Settings → SMTP). You'll get a host (
smtp.resend.com), port465, usernameresend, and a password (your API key). - In Gmail: Settings → Accounts and Import → "Send mail as" → Add another email address.
- Enter your name and
[email protected]. Uncheck "Treat as an alias" if you want replies to come back to that address. - SMTP server
smtp.resend.com, port465, usernameresend, password = your Resend API key, SSL. - 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
- Receiving overview & webhook payload —
- Signature verification uses Resend's own
resend.webhooks.verify()(Svix-style). No extra crypto dependency is needed. The Resend SDK'sverifyexpects header keysid/timestamp/signature; this library maps the incomingsvix-id/svix-timestamp/svix-signature(and the vendor-neutralwebhook-*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
