Real mail from Gmail: why did the forward rule skip it?
🌱 seedlingplanted #email#dmarc#regex#lessons
Written for Anyone writing mail filters, classifiers, or any code that looks for a word inside a structured string.
I used to type if "zip" in content_type to guess a file's type without a second thought. After reading a mail server's bug log, I don't dare anymore: the same kind of bug closed on it twice.
It happened on a small mail server that runs the mailboxes for a few websites. Each mailbox keeps its mail, then sends a copy to the site keeper's Gmail. The forward rule is careful: only mail that passes DMARC, the anti-forgery check, goes out. And yet for almost 11 days, any real message from Gmail or Outlook would have got stuck.
The DNS figures and both rules here I re-measured on 9 November with scripts/garden/substring-check.ts. The mail counts come from the log, because I have no access to the mailboxes; I mark those as the log's. AI helped with the measuring and the draft.
Tiếng Việt: Thư thật từ Gmail, sao luật chuyển tiếp lại bỏ qua?.
What the forward rule guards against
DMARC is a line each domain publishes in its own DNS, saying "if mail in my name fails the checks, here is what to do with it". There are three levels: p=none (accept it, just report), p=quarantine (put it in spam), p=reject (refuse it). The receiving server runs the checks and writes the result into the message header, in the Authentication-Results line: dmarc=pass means it matched, dmarc=fail means it didn't, and dmarc=none means the domain publishes no policy at all.
Forwarding only mail that passes is the right call. Forwarding means re-sending a stranger's mail from your own IP address. If a forged message goes out through you, Gmail blames your IP, and refuses it outright with a 550. The rule is written in Sieve, a mail-filtering language that runs on the server, and it says: see dmarc=fail, dmarc=none or one of the two error codes temperror, permerror, and keep it only; don't see dmarc=pass, and keep it only too; anything else goes out.
The rule went live on the evening of 28 October. Every message stayed in its mailbox, so nothing was lost. The copy to Gmail just never arrived.
Where dmarc=none was hiding
This server writes the result for a genuine Gmail message like this (I replaced the server name with mx.example.net):
mx.example.net; dmarc=pass header.from=gmail.com policy.dmarc=none
The result is dmarc=pass. But the end of the line adds policy.dmarc=none, a note that says "the sending domain publishes p=none". The old rule asked with Sieve's :contains: "does the line contain dmarc=none?", and those words sit right inside policy.dmarc=none. The rule read a note about the sender's policy and took it for the result of the check.
I looked up DNS this morning: gmail.com, outlook.com and hotmail.com all publish p=none, while yahoo.com publishes p=reject. So Yahoo mail went through fine and Gmail mail got stuck. Whether a message went out had nothing to do with whether it was genuine. It depended on the policy its sender publishes. The most common mailboxes were exactly the ones that got stuck.
Try it below. The table runs those exact two rules, and the highlight is where the old rule stopped. Paste an Authentication-Results line of your own into the last box and watch.
| result line | old rule | new rule |
|---|---|---|
mx.example.net; dmarc=pass header.from=gmail.com policy.dmarc=none | kept only | sent on |
mx.example.net; dmarc=pass header.from=yahoo.com policy.dmarc=reject | sent on | sent on |
mx.example.net; dmarc=fail header.from=gmail.com policy.dmarc=none | kept only | kept only |
mx.example.net; dmarc=none header.from=example.org | kept only | kept only |
Why the tests stayed green
This is the part I think is most worth learning. The rule had two tests, and both passed for all 11 days.
- The first sends itself a clean message through the logged-in submission port. Mail sent that way carries no
Authentication-Resultsline, so the rule lets it through. - The second waits for a reply from an outside mail-checking service. That service's domain publishes
p=quarantine, so the note at the end readspolicy.dmarc=quarantine, with nononeto trip on.
Both tests were correct. Neither used the kind of mail people send most. The bug showed up when someone had to prove that a new mailbox received and forwarded mail from outside. A message from a p=none domain arrived at 07:27:59 UTC on 8 November and just sat in the mailbox. After the fix, the same kind of message sent at 09:07:23 reached Gmail 15 seconds later.
Luckily little mail came in. The log counts: since 28 October, the 8 mailboxes on the old rule received 3 outside messages that passed DMARC. 1 went out (from a p=quarantine domain) and 2 got stuck, both from the same seller of SEO services, whose domain publishes p=none (I looked it up: it does). None came from Gmail, and by the log no reader's mail was missed. I couldn't recount these mail figures myself; I copied them from the log.
The second time, the same trap
The last line of the fix log says: "the substring trap again", meaning a piece of text hiding inside another word, "one protocol over". I went back and found the first time, in August.
The same server has a classifier for incoming reports. Its report mailbox receives two kinds: DMARC reports packed as application/zip, and TLS-RPT reports (which say whether encrypted connections to your server had trouble) packed as application/tlsrpt+gzip. The code recognised DMARC with "zip" in ctype. And gzip contains zip.
So every TLS-RPT report was filed as DMARC, parsed as XML, yielded 0 rows, and raised no error. A gate kept saying "no TLS-RPT report has arrived yet" from the day it was written. Counting the mailbox by hand found 4 reports already sitting there. The DMARC count was wrong too: "10 reports" were 6 real ones plus 4 misfiled. One bug, two wrong numbers, and neither number looked suspicious.
The two times share one thing: the code wanted to ask "what is this value?", but asked "does this string contain these letters?". Those two questions agree until someone puts the same letters somewhere else in the string. A mail header has policy.dmarc=, smtp.mailfrom=, header.from=. A content type has suffixes like +gzip, +json, +xml. Structured strings like these always leave room for a stray word.
Fixing it so it doesn't close a third time
Both times, the fix asks about the right unit. The mail rule now only accepts dmarc=… when it starts the line, or comes right after a ; or whitespace:
(^|[;[:space:]])dmarc=(fail|none|temperror|permerror)
policy.dmarc=none has a dot in front, so it doesn't match. The classifier compares the whole content type, checks TLS-RPT first, and gained an "unclassified" bucket that warns, so the next misfile at least makes a noise.
What I think is worth keeping most are the two habits that came with the fixes:
- Test with the mail people send most. There is now a separate test that sends from a
p=nonedomain, exactly the shape a Gmail message arrives in. - Put the old bug back and see the test go red. In August, the fixer deliberately put
"zip" in ctypeback into the code, and the test went red at once. A test that has never gone red, I can't tell whether it guards anything.
How to search your own code
Find the places that ask "contains" of a structured string. In JavaScript that's .includes(, in Python in, in SQL LIKE '%…%', in Sieve :contains. Then ask each one three questions:
- Is this string structured: a mail header, a content type, a URL, a path, a comma-separated list?
- Can the word I'm looking for sit inside a longer word? Try adding prefixes and suffixes:
policy.,+g,x-. - Does my test use the real value people meet most, or only one I made up?
To be sure, split the string by its own rules, then compare the parts. For a content type, this is enough:
const [type] = ctype.toLowerCase().split(";");
const isDmarc = type.trim() === "application/zip";
const isTlsRpt = type.trim() === "application/tlsrpt+gzip";
What I still don't know
- The new rule still compares text, just more carefully. A reader that parses
Authentication-Resultsto the standard would be sturdier, but I don't know of a neat way to do that in Sieve. - I've only seen the result line one kind of mail server writes. Other servers write the policy differently, as Gmail does with
(p=NONE sp=QUARANTINE dis=NONE)in brackets. A rule that reads lines from many servers has to try each shape, and I haven't. - Mail from a domain that publishes no DMARC still doesn't go out, under the new rule too. That is a cost the rule's author chose and wrote down. How much real mail falls into that group, I don't know.
- And my own code: today I counted 44
.includes(calls across 22 code files in this very garden, and I haven't read them one by one. Surely one of them is waiting to close.
Has a substring ever fooled you? Write me a letter below.
Write papa dragon a letter
If this note stirred something, write to me here. A letter reaches only me and never shows up on the page by itself. If one is lovely and you allow it, I may quote it under the note, without your name.
Works from a terminal too: curl -d 'msg=…' olragon.com/garden/substring-en/letter