DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A DMARC report is mail no human reads, and ours was being forwarded anyway

CogniPrep takes inbound mail through a Resend webhook. Someone writes to a support address, Resend posts an event, and our route fetches the raw message, parses the MIME, and re-sends it to the inbox a human actually reads. It is the same inbox the public help form posts into, which is the whole reason the forwarder exists: one place to look.

Then we published a DMARC record with an rua= address on the same domain, and the forwarder started doing something silly.

What a DMARC aggregate report costs to forward

An aggregate report is an XML file describing which IPs sent mail claiming to be your domain, gzipped, attached to a mail whose subject looks like this:

DMARC Aggregate Report Domain: {cogniprep.app} Submitter: {Amazon SES}
Enter fullscreen mode Exit fullscreen mode

No person reads one of those. You feed it to a parser, or you look at a dashboard that has already parsed it. Forwarding it to a human inbox is pure cost, and in our pipeline the cost was not just the forward. Our handleEmailReceived does this, in this order:

  1. fetch the raw message from the provider by id,
  2. follow the signed download_url for the MIME body,
  3. parse it with mailparser,
  4. run a spam classification over the parsed text,
  5. re-send it from our own authenticated domain.

Steps 1 and 2 are two network round trips for a file that can legitimately be tens of megabytes. Step 3 parses attacker-controlled MIME. Step 4 is a model call with a timeout inside a 30 second function budget. Every DMARC report from every receiver that honours your record walks all five.

The fix is six lines, and the placement is the decision

// DMARC reports stay in Resend rather than being forwarded.
if (/dmarc/i.test(String(subject ?? ''))) {
  logDebug(`[resend-webhook] Not forwarding a DMARC report`);
  return;
}

const { data: email } = await resend.emails.receiving.get(emailId);
Enter fullscreen mode Exit fullscreen mode

The subject is on the event payload, which we already have in hand because the signature was verified against it. So the check sits above the receiving.get call, which means a DMARC report now costs us one Redis call for the rate limiter, one signature verification, one idempotency claim, and a return. No fetch, no download, no parse, no model call.

That ordering is the only thing in the change worth arguing about. Putting the same test one line lower, after the fetch, would have read almost identically in a diff and would have kept both round trips and the parse. Putting it after the spam check would have kept the model call too, and the model would have been asked to classify a gzipped XML attachment, which is not a question it has a good answer to.

The general rule we keep relearning: a gate is worth exactly the work it is placed in front of. We wrote the same thing up when we found that five things happen before our API route runs and the order was the design, and this is the same lesson arriving through the mail pipeline.

What it deliberately does not do

It does not drop the mail. Nothing is deleted; the message is still in Resend, retrievable, and the route still answers 200 so the provider does not retry. "Not forwarded" is the whole claim.

It also matches on the word, not on the sender, and that is an accepted trade. Any inbound mail with "dmarc" in its subject now stops at the webhook. A human writing to us about our DMARC record would be caught by it. We took that over the alternatives, because the alternatives are worse than they look: report senders are a long and growing list of receiver domains, so an allowlist of From addresses is a maintenance job that fails silently, and Content-Type: application/gzip is not reliably what every sender uses.

The test that locks it in is the boring kind, and it is the one that would have caught a regression if we had moved the check later for some unrelated reason:

it('does not forward a DMARC report', async () => {
  mocks.verify.mockReturnValue({
    ...EVENT,
    data: { ...EVENT.data, subject: 'DMARC Aggregate Report Domain: {cogniprep.app} Submitter: {Amazon SES}' },
  });

  const response = await postEvent();

  expect(response.status).toBe(200);
  expect(mocks.receivingGet).not.toHaveBeenCalled();
  expect(mocks.send).not.toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode

It asserts two negatives. The status is almost incidental. receivingGet not having been called is the assertion that encodes the placement decision, and it is the one that fails if someone moves the check down the function.

Why the forwarder exists at all

Worth saying, because the usual reaction is that a mail provider should handle this. Ours does handle receiving. What it does not do is put the message in front of a person, and we did not want a second mailbox to check. So the webhook re-sends, which creates the second-order problem that every forwarded message now comes from our own authenticated domain and the receiving inbox's spam filter can no longer help, which is why step 4 exists in the first place.

That is the shape of most of our mail work: each piece is reasonable, and the interactions between them are where the bugs live. If you want to see the product the mail is about, the practice games and the test formats are the two ends of it, and the help form is the thing that puts a message into the pipeline described above.

Top comments (0)