Munchable's inbound mail works like this: the MX records for the domain point at Resend, Resend posts an email.received webhook for anything that arrives, and one route decides what happens next. Mail to the support addresses opens a ticket in the admin panel. Everything else is forwarded on, with Reply-To set to whoever wrote in. It is a catch-all, so an address nobody has ever published still lands somewhere a human will see it.
That route works. It has been handling real support mail for weeks. You can point something at it yourself from munchable.app/support, which either opens a ticket through the form or hands you the address to write to.
Then a second product on the same Resend account got inbound mail configured, and every email started arriving twice.
The sentence I had not read carefully
A Resend webhook subscription belongs to the account, not to the domain.
The email.received event tells you a message arrived and gives you the metadata and a way to fetch it. It does not tell you that the message is any of your business. With one domain on the account that distinction is invisible, because every event genuinely is yours. The handler's implicit assumption, "I received this event, therefore this mail is mine", was true by accident.
Add a second domain with its own deployment of the same ported handler, and both handlers receive both domains' mail. Each one forwards everything it does not recognise. The forwarding inbox gets two copies of every message: one from the app the mail was actually addressed to, and one from the app that just felt responsible for it.
This is the kind of bug that is embarrassing in proportion to how small the fix is. Here is the fix.
Ten lines, and three fields instead of one
/** True when any recipient (to, cc, or the envelope's for a bcc) is at munchable.app. */
function isForThisDomain(data: any): boolean {
const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : v ? [v] : []);
return [...list(data?.to), ...list(data?.cc), ...list(data?.received_for)].some((raw) => {
const s = String(raw ?? '');
const address = (/<([^>]+)>/.exec(s)?.[1] ?? s).trim().toLowerCase();
return address.split('@')[1] === FORWARD_FROM_DOMAIN;
});
}
Three things in there are not obvious until you have an email that breaks them.
Why received_for and not just to. A blind carbon copy does not appear in to or in cc. If someone bccs an address at the catch-all, the only place the recipient shows up is the envelope, which Resend surfaces separately. Check only the headers and bcc'd mail looks like it belongs to nobody, and a catch-all that silently drops bcc is worse than no catch-all, because the sender saw no bounce.
Why each value is coerced and then unwrapped. These fields arrive in header shapes: sometimes a bare address, sometimes Name <address>, sometimes an array, sometimes a single string, sometimes absent. The regex pulls the address out of angle brackets when they are there and falls through to the raw value when they are not, then trims and lowercases, because a domain compared case sensitively will fail on exactly the one message somebody sent from a mail client that capitalises things.
Why only the domain part. The local part of a catch-all address is unknown by definition. That is the feature. So the comparison is on everything after the @ and nothing before it.
"Not mine" is a 200
if (!isForThisDomain(data)) {
return NextResponse.json({ received: true, skipped: 'other_domain' }, { status: 200 });
}
It would be tempting to return 403 or 404 here, since the request is, in a sense, misdirected. That would be wrong, and the reason is worth keeping in your head for any webhook consumer.
A webhook delivery you are not going to act on is still a successful delivery. The sender did its job: it told you something happened. Reply with a non-2xx and Svix, which is what Resend delivers through, treats it as a failure, retries it with backoff, and eventually disables the endpoint for being unreliable. Refusing mail for another domain loudly enough and for long enough would have taken down inbound mail for the domain the endpoint actually serves.
So the response is 200 with a marker in the body saying what was skipped and why. The marker is for us, in logs, not for the sender. I have written before about the difference between "not mine" and "no" in a completely different part of the product; it turns out to be the same distinction.
Where the check sits, and why that is the real decision
The handler's order is: rate limit, reject oversized bodies, verify the signature, ignore event types with no side effects, check the domain, claim the event for idempotency, then process.
After signature verification, because the recipient fields come out of the payload, and a payload whose signature has not been checked is attacker-controlled input. A domain check on unverified data is a filter anyone can walk through by sending you a shape you like.
Before the idempotency claim, which matters more than it looks. The claim writes a key so a retried delivery is not processed twice. Claiming an event that belongs to another domain writes a key for work this deployment is never going to do: harmless, and also pure noise in the one record that tells you what this handler has actually handled.
And before anything fetches the message. Processing downloads the raw email, which is capped at 25 MB, and runs it through a parser. Skipping at the metadata stage means another product's mailing list traffic costs this deployment one signature check and nothing else.
The accident that stopped it being worse
Duplicate forwarding was the whole of the damage, and it should not have been. The routing table that decides which mail opens a support ticket is a set of complete addresses:
const TICKET_ADDRESSES = new Set([
'support@munchable.app',
'bugs@munchable.app',
'feedback@munchable.app',
]);
Full addresses, not local parts. So another domain's support@ never matched, never opened a ticket in this product's queue, and never triggered this product's acknowledgement email to a customer who had written to someone else entirely. If that set had held ['support', 'bugs', 'feedback'] and compared only the local part, which is a perfectly reasonable way to write it, the bug would have been a stranger getting a support reply from the wrong brand.
I would like to claim foresight. It was not: hardcoding the full address was the lazier thing to type. The lesson I will actually keep is the other one. When you port a handler from one repository into another, the thing to re-read is not the code, it is the scope of the subscription it listens to. The code was correct in the repository it was written in, and became wrong in the second one without a character changing.
If you are curious which products share that account, the family is listed on munchable.app/about. Five apps, one Resend account, and now one domain check each.
Top comments (0)