Building an anonymized email relay on Resend
you reply to your own address, not theirs
Sooner or later you'll need two strangers to email each other through your app without either one seeing the other's address. Marketplaces do it for buyers and sellers. Dating apps do it. I needed it for a recruiting platform: a company and a candidate talk over email, and neither one ever learns the other's real address.
That's an email relay. You sit in the middle and pass messages back and forth through aliases, in my case things like thread-7f3k9a2b+c@reply.eduba.io. Think of it as a receptionist who reads you the message out loud and never hands out anyone's number.
Resend can do both halves: sending, and inbound (catching the replies). The basic version is maybe 40 lines of code. And those 40 lines lie to you.
Here's the round trip, so the rest makes sense:
Me ───── opener ─────▶ Candidate's real inbox
From: talent@eduba.io (always the same sender)
Reply-To: thread-7f3k9a2b+c@reply.eduba.io
Candidate hits reply ──▶ thread-7f3k9a2b+c@reply.eduba.io
│
▼ Resend catches it → POST /api/webhooks/resend-inbound
check signature → fetch body → strip → save → forward
│
▼
Me ───── relay ──────▶ Company's real inbox
From: talent@eduba.io
Reply-To: thread-7f3k9a2b+co@reply.eduba.io
Two real inboxes, never shown to each other. The whole game is getting a reply back to the right person without leaking either address. That's the part I actually find neat, so let's start there. Then threading, hostile replies, and a couple of smaller things that'll bite you.
reply to your own address
Here's the idea the whole relay hangs on. It's also the bit I got wrong the first time. When you picture a relay, the obvious move is to hand each person the other person's address to write to. The candidate replies to the company's alias. The company replies to the candidate's. feels right. it's backwards.
Walk it through: Sam (a candidate) gets an intro to Acme. If Sam's reply-to is "Acme's alias," then the address now encodes who Sam is talking to. So when that reply lands, my code has to work backwards: it came in on Acme's address, so who actually sent it? Sam? Someone else on the thread? I'm storing direction and reverse-engineering the sender from the destination. And the second I add a third person (I quietly copy an admin onto every thread), it falls apart. One address can't point at "the other side" when there are two other sides.
Flip it. Each person replies to their own alias. Sam's reply-to is Sam's address. Acme's reply-to is Acme's. Now the address a reply lands on tells me who's sending, not who they're sending to. And once I know the sender, "forward to everyone else on the thread" is trivial.
That one flip is the whole trick. Let me show the pieces.
The alias is just plus-addressing with a side marker. An 8-character random chunk so they're unguessable, plus a letter for the side:
// src/lib/email/alias.ts: thread-{random8}+{c|co|a}@reply.eduba.io
// +c = candidate +co = company +a = admin
const ALIAS_REGEX = /^thread-([a-z0-9]{8})\+(c|co|a)$/;
export function parseAlias(input: string): ParsedAlias | null {
const local = input.includes("@") ? input.split("@")[0] : input;
const m = local.toLowerCase().match(ALIAS_REGEX);
if (!m) return null;
const side = m[2] === "c" ? "candidate" : m[2] === "co" ? "company" : "admin";
return { short: m[1], side, aliasLocal: local.toLowerCase() };
}
Each alias gets stored once, on a thread_participants row, with a unique constraint across every thread. That constraint isn't bookkeeping. it is the router. Because the alias is unique, an inbound email resolves to exactly one person, or to nobody. There's no ambiguity to resolve in code:
// The alias in the To: line tells us who's talking. One row, or none.
const { data: participant } = await admin
.from("thread_participants")
.select("id, thread_id, side, user_id, display_name")
.eq("alias_local", aliasInfo.aliasLocal)
.maybeSingle();
if (!participant) return new NextResponse("alias unknown", { status: 404 });
// participant.side is "candidate" or "company". Forward to everyone else.
So the full path, end to end: Sam hits reply. The To is thread-7f3k9a2b+c@reply.eduba.io, Sam's own address. Resend catches it and pings my webhook. I pull +c out of the recipient, look it up, and the row says "candidate, thread 7f3k9a2b." Now I know it's Sam, I know the thread, and I forward the message to the company at their real inbox, with their reply-to set to their own +co alias, so when they reply, the same thing happens in reverse. Nobody ever types the other person's address. Nobody ever sees it.
One more decision that surprises people: the From address never changes. Every relayed email is from one verified sender (talent@eduba.io). Only the Reply-To moves around. I don't fake a per-person From like Sam <thread-…@reply.eduba.io>, even though it'd look nicer in the inbox. Two reasons:
- Deliverability. A steady
Fromon a domain with proper SPF/DKIM/DMARC is what keeps you out of spam. Swapping the sender on every message is how you get throttled or flagged. - The name goes in the body, not the envelope. The reader sees "reply from Sam" rendered inside the message. The envelope stays boring and verified. The routing rides entirely on
Reply-To.
So that's the shape of it: one verified sender, one alias per person that doubles as their ID, one unique constraint holding the whole thing together. Reply to yourself. weird the first time you say it out loud, obvious once it's running.
one thread across gmail, outlook and apple mail is a fight
You set In-Reply-To and References, you assume every client now groups the messages, you move on. Then you test in Gmail and your nice clean thread shows up as five separate emails.
The headers matter, but they're not enough, and the reason is specific. Gmail groups on the References chain and the subject, and from what I can tell it only trusts a References id that points at a message it actually delivered. In a relay, the "root" of the thread was never a real delivered message. it's an anchor I made up. So Gmail ignores it and leans on the subject instead. Outlook and Apple Mail trust References more. No single signal works everywhere, which is annoying as hell.
So you send both, the same way, every time:
// src/app/admin/threads/[id]/actions.ts (trimmed)
// A made-up root id, one per thread. It was never delivered, but replying
// clients copy it into their own References chain, so the round trip stays
// threaded. It's per-thread, so two different intros never merge into one
// conversation even if their subjects happen to match.
function threadRootMessageId(threadId: string): string {
return `<thread-${threadId}@${replyDomain()}>`;
}
function threadHeaders(thread, side): Record<string, string> {
const root = threadRootMessageId(thread.id);
return { "X-Eduba-Thread": thread.id, "X-Eduba-Side": side, References: root, "In-Reply-To": root };
}
// One subject per thread. Never built from a per-message field. Gmail strips a
// leading "Re:" when it matches, so I add it once and every message (opener,
// relay, inbound reply) uses the exact same string.
function threadEmailSubject(thread): string {
const base = thread.subject?.trim() || "Your Eduba intro";
return /^re:\s/i.test(base) ? base : `Re: ${base}`;
}
What I learned the hard way:
- The subject does most of the work in Gmail, like it or not. Pick one when the thread starts and reuse it character for character. Let a per-message subject slip through and Gmail splits the thread.
- Add
Re:once. Gmail ignores a single leadingRe:when matching. Stacking upRe: Re: Re:(or none) breaks it. - Make the root id per-thread. Share one global root and every conversation in a busy inbox collapses into one giant thread.
I'll be honest: I can't point you at a Google doc that spells out the "delivered id only" rule. That's just what I saw across a pile of test sends. But sending both signals fixed it everywhere, so that's what shipped.
treat every reply like it's out to get you
The part the person actually typed is the top fifth of the email. The rest is the whole conversation quoted underneath, plus signatures, plus (in a relay) your own footer from the last message.
Here's how I found out. I was testing with two of my own email accounts. I replied from a Zoho address. The relay forwarded my reply to the other side, fine. but it carried my own "never share your direct email" footer quoted right underneath it. My stripper didn't know Zoho's quote format, so it forwarded the lot.
Forward the raw body and three things go wrong:
- Every message balloons with the full history quoted again.
- You leak the other side's alias and your own footer right back at them.
- Raw HTML brings along whatever tracking pixels and remote images the sender's client stuffed in.
So inbound gets treated as hostile. Strip it down to the new text, then re-render that plain text in your own template. The raw HTML gets saved for the record, but it never goes back out.
Quote markers differ per client, and you won't guess them all up front:
// src/lib/email/quoted-stripper.ts (trimmed)
const PATTERNS: RegExp[] = [
/^[> ]*On\s.+\swrote:\s*$/im, // Gmail: "On <date> … wrote:"
/^[> ]*On\s+[\w,\s]+,\s*\d{1,2}:\d{2}\s+(?:AM|PM)?.*$/im, // Apple Mail
/^[ \t]*From:[ \t].*(?:\r?\n.*){0,4}?\r?\n[ \t]*(?:Sent|To|Date|Subject):[ \t]/im, // Outlook / Zoho
/^Begin forwarded message:/im,
/^-+\s*Original Message\s*-+/im,
];
export function stripQuotedHistory(body: string): string {
if (!body) return "";
const cut = firstQuoteIndex(body); // earliest marker across all patterns
const cropped = cut >= 0 ? body.slice(0, cut) : body;
return stripTrailingQuoteBlock(cropped).trim(); // also drop trailing "> …" lines
}
That Outlook/Zoho pattern looks oddly specific (a From: line, then Sent:/To:/Date:/Subject: within a few lines, with room for extra spaces) because it comes straight from that bug. My test for it is the real reply, padded spaces and all:
Helllllllooo tanamay!!
From: <relay@itskay.co>
To: <hey@itskay.co>
Date: Mon, 01 Jun 2026 14:00:34 +0530
Subject: Re: Kay <> Tanmay
...
The test says the result should be exactly "Helllllllooo tanamay!!". no quoted addresses, no leftover footer. So: back your stripper with real replies you've captured, not made-up ones. Zoho, Proton, and corporate Outlook will all surprise you.
One more hostile case: auto-replies loop. Relay an out-of-office and the other side's out-of-office can fire back, which you relay, which trips theirs again. Or you cheerfully forward a mailer-daemon bounce as if a human sent it. Catch them and drop them:
const AUTO_REPLY_HINTS = [/out of office/i, /auto[- ]?reply/i, /vacation/i,
/mailer[- ]?daemon/i, /delivery status notification/i, /undeliverable/i];
export function looksLikeAutoReply(input): boolean {
const haystack = [input.subject, input.fromHeader, input.body?.slice(0, 500)].join("\n");
if (AUTO_REPLY_HINTS.some((re) => re.test(haystack))) return true;
// RFC 3834: well-behaved auto-mailers set this header.
const auto = input.headers?.["Auto-Submitted"] ?? input.headers?.["auto-submitted"];
return Boolean(auto && auto.toLowerCase() !== "no");
}
Keywords catch the common stuff. The Auto-Submitted header (RFC 3834) catches the polite senders. Drop both quietly, with a 200.
the boring stuff that actually holds it together
Three habits turned a working demo into something I'd trust:
- Save before you send. The handler writes the message row before it forwards. If the forward throws, the message still exists and I can re-send. No gaps in the record.
- Return
200fast, even when you drop the message. Closed thread, empty body after stripping, detected auto-reply, they all ack with200. Anything that isn't a 2xx tells Resend to retry, and there's nothing to retry. - The mask lives in the database, not the email. The relay runs as a service-role client that skips row-level security. Regular users get no read access to the messages or participants tables at all. The aliases and raw bodies never sit anywhere a user can query. If your anonymity only exists in the UI, it's not anonymity. it's one API call away from leaking.
And I store Resend's resend_id on every outbound message, so the delivery and open webhooks map straight back to the row they belong to. That's how "they opened it at 2:14pm" becomes a fact I can prove instead of a guess.
a couple more things that'll bite you
These two actually happen first in the real request flow, before any of the above. They're just the least interesting bits, so I stuck them at the end.
The webhook doesn't include the email. The email.received payload is metadata only: from, to, subject, a list of attachment names. No body, no headers. That's on purpose: webhook endpoints have small body limits and attachments can be huge, so you fetch the body yourself with a second call, resend.emails.receiving.get(emailId). Two knock-on effects worth knowing: route from the metadata first (a reply to an unknown alias gets a cheap 404 and never costs you an API call), and if that second fetch fails, still return 200, because anything that isn't a 2xx makes Resend retry an email it can't fix. If you got here googling "resend inbound webhook body missing", hi. it's not missing. it's a second GET.
Verify the signature on the raw body. Your inbound URL is public and it writes to your database, so check the signature Resend sends. It signs with Svix: svix-id, svix-timestamp, svix-signature. Two gotchas. Check the raw request body before you parse it, because JSON.parse plus re-serialize shifts the bytes and your HMAC won't match (in Next.js, await request.text() first). And the secret is base64 behind a whsec_ prefix, so strip and decode it before using it as the key. Resend now ships resend.webhooks.verify() that does all this, so use it. I only hand-rolled mine so my tests could forge valid requests and hit every branch offline. Add a timestamp window for replay protection and a constant-time compare, and you're done.
so, is Resend good for this?
Yeah, mostly. It hands you the hard infrastructure (MX, parsing, signed webhooks, a clean send API) and stays out of your way. But the relay itself (routing, threading, stripping, killing loops, the privacy line) is yours to build, and none of it is in the quickstart.
The stuff worth remembering, roughly in order of how much it actually mattered:
- The alias says who's talking. Each person replies to their own address, and that address is how you know who sent it. Keep
Fromsteady, route onReply-To. - Thread with
Referencesand one fixed subject. No client trusts just one. - Treat replies as hostile. Strip the quoted history (test with real ones), drop auto-replies, re-render in your own template, never forward raw HTML.
- The webhook is metadata only: fetch the body with a second
receiving.get, and handle it failing. - Check the signature on the raw body,
whsec_-decoded, constant-time, inside a time window.
Get those right and the same pattern drops into any two-sided app where the two sides shouldn't have each other's address. The recruiting part was incidental. The plumbing is the same everywhere.
Wiring it up: point a subdomain's MX records at Resend inbound, never your root domain, or all your mail routes to Resend. Add two webhooks (inbound, and delivery events), each with its own secret. The env vars are few: RESEND_API_KEY, RESEND_FROM_EMAIL, RESEND_REPLY_DOMAIN, RESEND_INBOUND_SECRET, RESEND_WEBHOOK_SECRET.
Reading I leaned on: