DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our support inbox is a webhook, and two angle brackets were breaking the images

Notifio has no helpdesk. Mail to support@notifio.app arrives as a webhook, gets parsed on our server, and is forwarded to a real inbox with Reply-To set to whoever wrote in. Replying is replying. There is no ticket, no status field, and no second place to check.

I have written before about threading support mail onto tickets with plus addressing, which is the version of this problem where you do have a database of tickets. This is the smaller version, and the interesting parts turned out to be the ones nobody warns you about: which addresses you accept, and what happens to an inline image on the way through.

The pipeline, and why it needs thirty seconds

// Force dynamic rendering - handles webhooks
export const dynamic = "force-dynamic";
export const revalidate = 0;
// Allow up to 30s for the 3-step email fetch → parse → forward pipeline
export const maxDuration = 30;
Enter fullscreen mode Exit fullscreen mode

The webhook body does not contain the email. It contains an id. Handling one inbound message is three network steps: fetch the metadata for that id, download the raw MIME from the URL it gives you, then send the forward. On a message with a couple of photo attachments that is not instant, and the default function timeout is the kind of limit you discover through a customer saying "I emailed you last week".

Signature first, with a deliberate development hole

if (webhookSecret && svixId && svixTimestamp && svixSignature) {
  try {
    payload = resend.webhooks.verify({
      payload: rawBody,
      headers: { id: svixId, timestamp: svixTimestamp, signature: svixSignature },
      webhookSecret,
    });
  } catch (verifyError) {
    console.error("[resend-webhook] Signature verification failed:", verifyError);
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }
} else {
  // If no webhook secret configured, parse the body directly (dev mode)
  payload = JSON.parse(rawBody);
}
Enter fullscreen mode Exit fullscreen mode

Reading the raw body as text before anything else is not optional, because a signature is over bytes and a parsed and re-serialised object is different bytes.

The else branch is a compromise I would defend in this shape and nowhere else. Without it, testing locally means a real secret in a local .env. With it, an unsigned request is trusted whenever the secret is absent. What makes that acceptable here is the blast radius: the worst an attacker achieves is causing us to forward an email to ourselves. If this endpoint wrote to a database or granted anything, the hole would have to close and local testing would have to happen against a real secret.

Events we ignore, on purpose, with a 200

case "email.delivered":
case "email.bounced":
case "email.delivery_delayed":
case "email.complained":
  // These events are logged but don't require action
  console.log(`[resend-webhook] Event: ${type}`, data);
  break;

default:
  // Unknown event type - ignore
  console.log(`[resend-webhook] Unknown event: ${type}`);
  break;
Enter fullscreen mode Exit fullscreen mode

Then { success: true }, 200.

Returning a non-2xx for an event you do not handle teaches the provider to retry something you will never handle, and eventually to disable the endpoint. Log it and accept it. Bounces are enumerated separately from the default case for one reason: they are the events I expect to act on next, when a bouncing alert address becomes worth surfacing in the app, and having them already visible in logs is how I will know what that looks like.

The allow list is the actual security boundary

Our domain has a catch-all. Anything at notifio.app reaches this webhook, which means the interesting question is not authentication, it is what we are willing to forward:

const firstRecipient = recipients[0].toLowerCase();
const allowedAddresses = [
  "ceo@notifio.app",
  "hello@notifio.app",
  "support@notifio.app",
  "notifications@notifio.app",
  "noreply@notifio.app",
  "no-reply@notifio.app",
];

if (!allowedAddresses.includes(firstRecipient)) {
  console.log(`[resend-webhook] Ignoring email to unauthorized address: ${firstRecipient}`);
  return; // Silently ignore unauthorized addresses
}
Enter fullscreen mode Exit fullscreen mode

A catch-all with no filter is a spam funnel: admin@, billing@, postmaster@ and a thousand addresses from scraped lists all become mail you personally read. Six addresses we publish or send from are worth forwarding. Everything else is dropped without a bounce, because bouncing tells a spammer the address was real enough to be processed.

noreply@ and no-reply@ are on the list deliberately, even though nobody is supposed to write to them. People reply to automated mail constantly, and a customer replying to their own alert email with "this listing was a scam" is doing exactly the sensible thing. Dropping that reply because the address says no-reply is a self-inflicted support failure.

The recipient also becomes the label:

let category = "General";
if (firstRecipient.includes("ceo@")) category = "CEO";
else if (firstRecipient.includes("support@")) category = "Support";
else if (firstRecipient.includes("hello@")) category = "Contact";
Enter fullscreen mode Exit fullscreen mode

That category ends up in both the display name and the subject prefix of the forward, so the destination inbox can filter on it without any of our own headers:

from: `${category} <noreply@notifio.app>`,
subject: `[${category}] ${subject || "(no subject)"}`,
Enter fullscreen mode Exit fullscreen mode

The two angle brackets

Here is the detail I actually wanted to write down. Inline images in an email are not attachments that happen to be pictures. The HTML refers to them with cid: URLs, and the attachment declares a matching Content-ID header. MIME requires that header to be wrapped in angle brackets, so the parsed value comes back as <abc123@example.com> while the HTML says src="cid:abc123@example.com".

Pass the parsed value straight back into a send and the two never match. The image becomes a broken icon in the middle of a screenshot somebody sent you to explain their problem, which is the single most useful thing in the whole message.

const attachments = parsed.attachments.map((attachment) => {
  // Strip < and > from content IDs for proper inline image handling
  const contentId = attachment.contentId ? attachment.contentId.replace(/^<|>$/g, "") : undefined;

  return {
    filename: attachment.filename,
    content: attachment.content.toString("base64"),
    content_type: attachment.contentType,
    content_id: contentId || undefined,
  };
});
Enter fullscreen mode Exit fullscreen mode

Two characters. The kind of bug you only find by mailing yourself a screenshot and looking at it, which is now a step I do whenever I touch this file.

The rest of that block is small decisions with the same flavour. Base64, because we are handing bytes to a JSON API. contentId || undefined rather than an empty string, because an empty Content-ID is a claim that the attachment is inline when it is not. And the parse itself runs with skipImageLinks: true, so remote image URLs in the original are not followed while we handle it.

Reply-To is the entire product

const { error: sendError } = await resend.emails.send({
  from: `${category} <noreply@notifio.app>`,
  to: [forwardTo],
  replyTo: from, // This allows you to reply directly to the original sender
  subject: `[${category}] ${subject || "(no subject)"}`,
  html: parsed.html || undefined,
  text: parsed.text || undefined,
  attachments: attachments.length > 0 ? attachments : undefined,
});
Enter fullscreen mode Exit fullscreen mode

from has to be our own verified domain, or the forward fails SPF and DKIM and lands in spam, which is the classic mistake when forwarding mail programmatically: you cannot send as the customer. replyTo is what makes the message behave like the customer wrote to you anyway.

html: parsed.html || undefined and the matching text line preserve whichever parts the original had. Forcing an empty string where a part was absent produces a message with a blank HTML body, and some clients will happily render the blank one instead of the text.

What I am knowingly not doing

A failed forward throws, and the throw becomes a 500, which asks the provider to retry. There is no record of which email_id values have already been forwarded, so a retry after a forward that actually succeeded will deliver it twice.

That is a deliberate trade at this volume. Duplicate support mail is a mild annoyance in one inbox. A silently dropped customer email is a person who thinks nobody is home. If the volume ever makes the duplicates worse than the risk, the fix is a set of processed ids with a short expiry, which is about ten lines, and I would rather add those ten lines when there is a reason than carry the state now.

See it for yourself

The address this all exists to serve is on the about page, and the help page is the page most people read instead of writing in, which is the point of it. What the product does with the mail it sends in the other direction is covered on the alerts overview, and the app is at notifio.app/download.

Top comments (0)