DEV Community

Daniel Pertu
Daniel Pertu

Posted on

We drop DMARC reports by looking for "dmarc" in the subject, and the spec's own subject line does not contain the word

Notifio sends you email when a new rental listing appears, so the domain has to be able to receive email too: licence questions, replies to alerts, and whatever else lands on a domain that publishes addresses on its help page.

Receiving is a 179 line webhook. Resend takes delivery, posts an email.received event to /api/webhooks/resend, and the route forwards the message to one personal mailbox with replyTo set to whoever wrote in. Two weeks ago it got one more guard, in a commit called "Skip DMARC reports in email forwarding":

// DMARC reports stay in Resend rather than being forwarded
if (/dmarc/i.test(String(subject ?? ""))) {
  console.log("[resend-webhook] Not forwarding a DMARC report");
  return;
}
Enter fullscreen mode Exit fullscreen mode

I went to check that against the specification this week, and the specification is not on my side.

What a DMARC report's subject actually says

RFC 7489, section 7.2.1.1, gives an ABNF for the subject of an aggregate report. The rule is named dmarc-subject:

Enter fullscreen mode Exit fullscreen mode

And the example immediately below it:

Subject: Report Domain: example.com
    Submitter: mail.receiver.example
    Report-ID: <2002.02.15.1>
Enter fullscreen mode Exit fullscreen mode

The rule is called dmarc-subject and the subject it produces is the one string in the section that does not contain the word. Those hex literals spell "Report", "Domain:", "Submitter:" and "Report-ID:", and nothing else is required to be there.

I checked whether this had changed, because RFC 7489 was obsoleted in May 2026 by RFC 9990, which rewrote aggregate reporting. It kept the convention exactly:

Enter fullscreen mode Exit fullscreen mode

So a reporter that follows either version of the spec sends us a subject our filter does not match. The filter catches reporters who put "DMARC" in the subject anyway, which plenty do, and I cannot tell you the proportion without sitting and watching the mailbox for a month. What I can tell you is that the guard was written against the mail that was actually arriving, which is a perfectly good reason for a regex to exist and a bad reason to believe it is complete.

The better test is one line up

The webhook payload gives four fields before any network call: from, to, subject and email_id. The route uses three of them and tests only one.

from is the field with the word in it. Report generators tend to send from a local part that says what the mail is, because the mail is machine traffic and the address is the only documentation it gets. Testing both fields, plus the shape the spec asks for, is one more line:

const looksLikeDmarc =
  /dmarc/i.test(subject) ||
  /dmarc/i.test(from) ||
  /^\s*Report\s+Domain:/i.test(subject);
Enter fullscreen mode Exit fullscreen mode

The reason not to go further than the headers is placement. The guard sits above the expensive part of the route, which is three calls deep:

const { data: email } = await resend.emails.receiving.get(email_id);
const rawResponse = await fetch(email.raw.download_url, {
  signal: AbortSignal.timeout(10_000),
});
const parsed = await simpleParser(rawEmailContent, { skipImageLinks: true });
Enter fullscreen mode Exit fullscreen mode

A metadata read, a raw download of the whole message, and a MIME parse, inside a route with maxDuration = 30. The genuinely reliable way to identify an aggregate report is to look at the attachment: the spec asks for application/gzip with a filename like mail.receiver.example!example.com!1013662812!1013749130.gz. But to see the attachment you have to do all three steps, and then re-attach it as base64 if you decide to forward after all. The headers are a cheap test that is sometimes wrong, and the attachment is an accurate test that costs the whole pipeline. Keeping the cheap one and making it less wrong is the right trade here.

The guard behind the guard

Before any of this, the route decides whether the recipient is one it accepts at all:

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;
}
Enter fullscreen mode Exit fullscreen mode

Six addresses, and a report addressed to dmarc@ or postmaster@ never reaches the subject test because this returns first. That is most of why the DMARC filter sees so little: it only ever gets a chance on a report sent to one of these six.

Two things wrong with the block, neither of them fatal.

recipients[0] reads one recipient. A message addressed to a mailing list with support@notifio.app second in the To header is dropped, silently, with a log line nobody is reading. The fix is recipients.some(...), and the reason it has not bitten is that almost all inbound mail to a domain like this has exactly one recipient.

And the category derivation immediately below it cannot reach its own default:

let category = "General";
if (firstRecipient.includes("ceo@")) category = "CEO";
else if (firstRecipient.includes("support@")) category = "Support";
else if (firstRecipient.includes("hello@")) category = "Contact";
else if (firstRecipient.includes("notifications@")) category = "Notifications";
else if (firstRecipient.includes("noreply@") || firstRecipient.includes("no-reply@"))
  category = "No-Reply";
Enter fullscreen mode Exit fullscreen mode

Every one of the six allowed addresses matches one of those five branches, so "General" is unreachable. It is the kind of dead default that is actively useful to keep, because the allowlist and the branches are two lists that have to agree and nothing checks that they do. If a seventh address is added above and no branch is added here, the mail still arrives, labelled General, instead of being mislabelled. Dead code as a safety net rather than dead code as rot.

The category becomes the display name on the forward, so the inbox groups by which address was written to:

await resend.emails.send({
  from: `${category} <noreply@notifio.app>`,
  to: [forwardTo],
  replyTo: from,
  subject: `[${category}] ${subject || "(no subject)"}`,
  ...
});
Enter fullscreen mode Exit fullscreen mode

Meanwhile lib/resend.ts, which is what outbound mail imports, names four addresses in its EmailAddresses constant. The route accepts six. Those two lists have drifted and the drift is harmless, which is exactly how it stays undetected. The related post about publishing one address and labelling sixteen is the other side of this problem in a sibling codebase: there, a catch-all means every local part arrives and gets labelled. Here, six are admitted and the rest are dropped on the floor.

The one that is a real problem

This is the shape of the signature check:

if (webhookSecret && svixId && svixTimestamp && svixSignature) {
  try {
    payload = resend.webhooks.verify({ ... });
  } catch (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

The comment says dev mode. Nothing in the code knows whether it is in dev mode. If RESEND_WEBHOOK_SECRET is unset or misspelled in the deployed environment, the route quietly stops verifying and accepts any POST that looks like a Resend event, and the only symptom is an absence: no 401s in the logs, which is indistinguishable from everything working.

That route is also one of three allowed to answer during database maintenance, along with the download links and the contact form, so it is a path that stays live precisely when nobody is watching the dashboard. The fix is four lines: branch on process.env.NODE_ENV !== "production" for the unsigned path, and refuse with a 500 in production when the secret is missing. An outage because a secret is absent is a much better failure than an open endpoint because a secret is absent.

And the record that asks for the reports

One more thing I found while writing this, which is the part I now like least. dig TXT _dmarc.notifio.app returns nothing. The domain has DKIM at resend._domainkey and SPF on the sending subdomain, and no DMARC policy published, which means nothing is being asked to report anything to us today.

So the filter is in front of a queue that is currently empty, and the order of work is backwards: publish a policy with p=none and a reporting address first, then the handling for the mail it brings. It is a small record and it is now at the top of the list, which is the useful kind of thing to find out from reading your own route.

If you want to see the surfaces this route sits behind, the published addresses and the contact form are on the help page, the app and its installers are on the download page, prices are on the pricing page, and the per-site alert pages are at notifio.app/alerts, including Kamernet and Rightmove.

Top comments (0)