Here is the file an application hands to sendmail, and the first line of the SMTP conversation it produces. The two addresses are different, and only one of them is in the file.
$ cat receipt.eml
From: Linuxtastic Billing <billing@sender.test>
To: alice@recipient.test
Subject: Your receipt
$ sendmail -t < receipt.eml # running as the unix user www-data
C: MAIL FROM:<www-data@sender.test> SIZE=342 <- the address SPF will judge
From: Linuxtastic Billing <billing@sender.test> <- the address the human readswww-data@sender.test is the envelope sender. It is not a header. It does not appear anywhere in the message the application wrote, it was chosen by the local mail system from the unix account the process happened to be running as, and it is the only address SPF ever looks at. Two consequences, both measured on a lab machine with a correctly published record:
sender.test TXT "v=spf1 include:_spf.relay.test -all" # the record every guide tells you to publish
client 192.0.2.44 MAIL FROM=<anything@list.test>
a machine with no From: Linuxtastic Billing <billing@sender.test>
relationship to you -> spf=pass
client 203.0.113.10 MAIL FROM=<www-data@sender.test>
your own web server, From: Linuxtastic Billing <billing@sender.test>
your own domain -> spf=failRead those two results together. A stranger forging your From: header passes SPF, because SPF has no opinion about the From: header. Your own server, sending as your own domain, fails it — because the record you were told to publish delegates everything to your relay and ends -all. Nothing here is misconfigured. This is SPF doing exactly what RFC 7208 says it does, which is to authorise hosts for the envelope sender’s domain and nothing else.
An outbound email does not have a sender. It has four addresses, and the one you set is the only one no mechanism checks. The envelope sender, chosen by your mail system or your relay. The HELO name, chosen by whichever machine opens the connection. The DKIM d= domain, chosen by whoever holds the private key. And the From: header, chosen by you, which is the one your recipient sees and the one none of SPF, DKIM or the SMTP transaction authenticates. The mechanism that ties any of the other three back to it is called alignment, and it is the word almost every account of this subject leaves out.
What this page does not cover. Getting a relay working, publishing the records and sending your first message is Sending Email From Your Server‘s. Where DNS records live and who serves them is Getting a Domain and Its DNS Right‘s, and when a change to them is over is The Life of a DNS Zone‘s. Running an inbound mail server — spam filtering, mailboxes, IMAP — is not covered anywhere here and is a much larger job than this one. This page is about one message you have already sent, and about which party decided each thing that happened to it.
A message has an order, and at every stage the thing being judged is an address somebody else chose. Nine stages: 1. submission — the envelope sender, and the fact that your application almost certainly did not set it · 2. the relay’s acceptance — the envelope, the sending IP and possibly the signing domain, all replaced · 3. routing — whose machine is asked, and whether it is the recipient’s organisation at all · 4. the hop — encrypted, unauthenticated, and logged as a success · 5. authentication — SPF against the envelope, DKIM against d=, neither against the From: · 6. alignment and policy — whether either authenticated domain is the same organisation as the From: · 7. disposition — what a 250 actually promises · 8. the report — who is told, and when · 9. the recipient, and the only number that changes what happens to your next message.
The hinge: stop asking whether the records are right and ask which addresses a message you actually sent is carrying. Take one delivered copy of one of your own messages — a real one, out of a mailbox you control, not a queued copy and not a test you constructed. The envelope sender is only written into a message at final delivery, so a delivered copy carries all four addresses and a queued one does not.
f=/path/to/a/delivered/copy/of/one/of/your/own/messages
python3 - "$f" <<'EOF'
import email, email.utils, re, sys
m = email.message_from_file(open(sys.argv[1], errors='replace'))
dom = lambda a: (a or '').rsplit('@', 1)[-1].strip('<> \t').lower()
env = dom(m.get('Return-Path'))
frm = dom(email.utils.parseaddr(m.get('From'))[1])
sig = m.get('DKIM-Signature', '')
g = lambda t: (re.search(r'[;\s]%s=([^;\s]+)' % t, ' ' + sig) or [None, ''])[1].lower()
d, s = g('d'), g('s')
al = lambda x: 'yes' if x and (x == frm or x.endswith('.' + frm)) else 'NO'
print('envelope-from %s' % (env or '(none - this is a queued copy, not a delivered one)'))
print('From: header %s' % frm)
print('DKIM d= / s= %s / %s' % (d or '(unsigned)', s or '-'))
print('spf aligned %s' % al(env))
print('dkim aligned %s' % al(d))
EOFRead which answer comes back, not whether it looks healthy. The last two lines are what DMARC computes; every other line names a party. There is no all-clear state here — even the best possible output names somebody who is not you.
| What comes back | What it means | Who owns the answer | Stage |
|---|---|---|---|
envelope-from domain differs from the From: domain | SPF is authenticating a domain you do not own, and SPF cannot be a DMARC leg for this message however green it looks | your relay | 5, 6, 8 |
(none - this is a queued copy) | the envelope is not in the message; it is written by the final delivery agent. Fetch a copy from the destination mailbox instead | — | re-run it |
DKIM d= equals the From: domain | whoever signed is signing as you; DKIM is an aligned leg, and it survives forwarding | whoever holds the private key | 5, 6 |
DKIM d= differs from the From: domain | your relay is signing as itself. dkim=pass will be reported and it is not a DMARC leg | your relay | 6 |
| both aligned lines say NO | your published p= applies to every message you send, at every receiver that enforces it | the receiver | 6 |
| exactly one says yes | one party’s continued cooperation is the only thing standing between you and your own policy, and nothing will tell you when it stops | that party | 6 |
| both say yes | the only arrangement that survives both a forward and a relay change | — | — |
Two honest limits on that reading. The alignment test above is the relaxed comparison and it approximates the organisational domain by suffix match; DMARC’s strict mode requires an exact match, and true relaxed mode uses the public suffix list. And the command reads references — it tells you which party’s answer decides, not what the answer was. It will report dkim aligned: yes on a message whose signature a mailing list has since broken. It is a localiser, not an oracle.
Stage 1: Submission, and the address your application never set
Almost every application that sends mail does it the way the opening example did: write a file with a From: header and pipe it to sendmail -t. Nothing in that file mentions an envelope sender, so the local mail system invents one from the unix account the process is running under. Here is what actually arrives, from a submission made as www-data:
Received: by mail.sender.test (Postfix, from userid 33)
From: Linuxtastic Billing <billing@sender.test>
Message-Id: <20260904234207.6C798C25A2@mail.sender.test>
Date: Fri, 4 Sep 2026 23:42:07 +0000 (UTC)Three things the application did not choose, and one of them decides the message’s fate. The envelope sender is www-data@sender.test and it is nowhere in the message — not as a header, not as a comment, not derivable from anything in the file. Message-Id and Date were generated by the mail transfer agent, and the identifier carries the MTA’s own hostname and its internal queue ID rather than the From: domain. That identifier is what every abuse report and feedback loop keys on, and it was chosen by a program the application never spoke to.
The only way to set the envelope sender through sendmail is -f, and it does not touch the header:
$ sendmail -f bounces-42@bounces.relay.test -t < receipt.eml
C: MAIL FROM:<bounces-42@bounces.relay.test>
From: Linuxtastic Billing <billing@sender.test>
# and over SMTP submission, they are simply two different arguments:
$ swaks --from bounces-99@bounces.relay.test --h-From '<billing@sender.test>'Stage 2: The relay’s acceptance, and three identities replaced at once
Almost nobody sends production mail straight from their own machine, and the advice not to is sound. But handing the message to a relay is not a delivery step, it is an identity step, and it replaces three things at once:
- The envelope sender becomes a per-message bounce address at the relay’s own domain —
bounces-42@bounces.relay.test— so that the relay receives every bounce and can act on it. Whatever you set at stage 1 is discarded here. - The sending IP address becomes one of the relay’s, shared with every other customer on it. Reverse DNS for it is the relay’s to publish, and receivers require it.
- The DKIM signing domain may become the relay’s too. Providers offer signing as your domain and signing as theirs, and which one you get by default varies. It is the single most consequential setting in the entire subject and it usually lives on a settings page rather than in anything you can grep.
None of those three appears in your server’s configuration, so there is nothing on your machine to inspect. They become visible only in the outputs of later stages — which is why the hinge above runs against a delivered message rather than against a config file. It is the only place the three of them are written down together.
Stage 3: Routing, and whose machine is actually asked
The recipient’s domain decides which machine receives the message, and it can decline to receive mail at all. Three behaviours, measured against Postfix 3.8.6 with a real resolver:
nullmx.test MX 0 . dsn=5.1.0 status=bounced
(Domain nullmx.test does not accept mail (nullMX))
danglingmx.test MX 10 mx.gone.test dsn=5.4.4 status=bounced
(Name service error for name=mx.gone.test type=A)
gateway.test MX 10 mx.filtervendor.test accepted - by a different organisationThe last row is the one to look at before you diagnose anything else. mx.filtervendor.test is not the recipient’s organisation; it is a filtering service in front of it, and the message will be relayed onward from there. That second hop is where a mailing-list-style modification can break a signature, and where SPF is re-evaluated against a new envelope. When mail to one company works and mail to another does not, the MX is the first thing that distinguishes them, and it takes one command:
$ dig +short MX recipient-domain.exampleThe first row deserves a note of its own, because it is the only thing in these nine stages that fails instantly and unambiguously. A null MX (MX 0 ., RFC 7505) is a domain stating that it accepts no mail; Postfix bounced it in seven hundredths of a second with a permanent code and a sentence naming the reason. Everything else in this subject fails slowly, partially, or silently. If you own a domain that sends mail but never receives it, publishing a null MX is the one thing you can do that makes somebody else’s failure fast.
Stage 4: The hop, which is encrypted, unauthenticated, and logged as a success
Postfix’s upstream default for outbound TLS is empty — no encryption at all. Ubuntu 24.04 ships an override in /etc/postfix/main.cf that sets smtp_tls_security_level=may, so opportunistic TLS is what almost every reader actually has without having decided anything. Three behaviours follow from that one word, all measured:
# the receiving server offers STARTTLS with a self-signed certificate
Untrusted TLS connection established to 127.0.0.1:2626:
TLSv1.3 with cipher TLS_AES_256_GCM_SHA384
# the same server, with STARTTLS simply not advertised
status=sent # delivered in cleartext; zero log lines mention TLS at all
# the same server again, with smtp_tls_security_level=encrypt
dsn=4.7.4, status=deferred (TLS is required, but was not offered by host ...)Read the first word of the first line. Untrusted. The connection is encrypted and the certificate was checked against nothing; may accepts any certificate at all, because refusing one would mean falling back to cleartext, which is worse. And the second case is the one worth internalising: remove one line from the server’s greeting and your mail goes out in the clear, is recorded as sent, and produces no log line mentioning TLS to be absent from. There is nothing to alert on, because nothing happened.
A sender cannot make its own mail confidential in transit. The two mechanisms that make TLS mandatory for a hop — DANE (RFC 7672, a TLSA record in a DNSSEC-signed zone) and MTA-STS (RFC 8461, a policy served over HTTPS at mta-sts. the recipient’s domain) — are both published by the recipient. You can obey a policy somebody else chose to publish; you cannot impose one. Postfix 3.8.6 has no built-in MTA-STS client at all, and the resolver package that adds one is not installed by default.
Stage 5: Authentication, which is about two domains and neither of them is the one in the message
This is the stage the three DNS records are for, and it is worth being precise about what each one authenticates. SPF answers “was this host allowed to use this envelope domain”. DKIM answers “does this signature verify against a key published by this d= domain”. Neither of them mentions the From: header. They are two independent claims about two domains that may have nothing to do with each other or with the message your recipient is reading.
SPF: which identity, and five results from one record
One published record, v=spf1 include:_spf.relay.test -all, evaluated against five realistic situations:
| Sending host | MAIL FROM | Result | DNS lookups spent |
|---|---|---|---|
the relay, 198.51.100.25 | www-data@sender.test | pass | 3 |
the relay, 198.51.100.25 | bounces-42@bounces.relay.test | pass | 1 — and it never queried sender.test at all |
a stranger, 192.0.2.44 | anything@list.test, with From: billing@sender.test | pass | 1 |
your own server, 203.0.113.10 | www-data@sender.test | fail | 4 |
the relay, sending a bounce: MAIL FROM:<>, HELO mail.relay.test | — (null reverse-path) | none | 1 |
Row two is the ordinary case for anybody using a relay, and it is worth staring at: the check passed, in one lookup, at bounces.relay.test, without ever consulting the record you published. Your SPF record was not involved in the result. Row four is its mirror image — your own machine, your own domain, and a fail, because -all means “and nothing else”, and your web server is not in your relay’s netblocks.
Row five is the rule almost nobody knows. RFC 5321 §6.1 requires every bounce and every delivery notification to use a null reverse-path, and RFC 7208 §2.4 says that when the reverse-path is null, SPF is evaluated against postmaster@ the HELO name instead. Every bounce in the world is judged on the HELO name of the machine that sent it, which is a fourth address nobody configures. Here that name published nothing, so the result was none — neither a pass nor a fail, but the receiver having no policy to apply.
SPF: the lookup budget, and who is spending it
RFC 7208 §4.6.4 caps an SPF evaluation at ten DNS-querying terms — include, a, mx, ptr, exists and the redirect modifier — and requires permerror if that is exceeded. Separately, void lookups, meaning terms whose query comes back with no answer or with NXDOMAIN, should be limited to two. Here is what the single include: above actually costs:
DNS lookups performed: 4
TXT sender.test <- fetching your own record; not a counted term
TXT _spf.relay.test <- your one include:
TXT _s1.relay.test <- your relay's
TXT _s2.relay.test <- your relay'sThree of the ten are gone, and you can see one of them. The other two live inside a record at a domain you do not own, which can change without notice and without any signal reaching you. Standard advice when you add a second sending service is to merge its include: into your existing record — correct advice, since two SPF records at one domain is a permanent error rather than a union — but merging includes is precisely the act that spends the budget, and the budget is mostly spent by other people.
spf=permerror SPF Permanent Error: Too many DNS lookups
DNS lookups performed: 11The void limit is the more surprising one, because it can discard a mechanism that would have matched:
$ dig +short TXT void.test
"v=spf1 a:nx1.void.test a:nx2.void.test a:nx3.void.test ip4:198.51.100.25 -all"
client 198.51.100.25 -> spf=permerror Void lookup limit of 2 exceededThe client was authorised — by the ip4: term, which never ran, because two hostnames that no longer resolve sat in front of it and aborted the evaluation. A matching mechanism behind two stale names is not a matching mechanism. And permerror is not a fail: RFC 7208 §2.6.7 describes it as a condition requiring operator intervention, but under DMARC it is simply “not an aligned pass”, which with a reject policy is a rejection.
DKIM: what is signed, and what survives being carried
A real signature, added by OpenDKIM running as a milter in front of Postfix:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=sender.test; s=s1;
t=1788565486; bh=ki3g1k3KH/PcOKtwdH9QNNGsfm5QnQpcxQbQpJtj4B4=;
h=From:To:Subject:Date:From; b=...h= is the entire contract, and it is short. Message-Id is not in it. Received is not in it. Return-Path is not in it. Anything not named there can be added, removed or rewritten in transit and the signature still verifies. d= names the domain whose key is used; s= names the selector, a label that picks one key record out of several at that domain. c= is the canonicalisation pair, and it is one token that decides which hops are survivable.
Twelve realistic in-transit modifications, verified against the published key with c=relaxed/relaxed:
| What happened to the message on the way | relaxed/relaxed |
|---|---|
| a mailing-list footer appended to the body | broken |
Subject: prefixed with [linux-users] | broken |
a second From: header added | broken |
From: display name changed, address kept | broken |
| trailing whitespace added to the last body line | pass |
| one interior space became two | pass |
| signed headers physically reordered | pass |
an unsigned header added (X-Spam-Status) | pass |
Message-Id rewritten by a gateway | pass |
internal whitespace in Subject collapsed | pass |
| a signed header refolded onto two lines | pass |
| (control) nothing changed | pass |
The pass rows are the interesting half, and they are the reason “DKIM breaks if anything touches the message” is not a useful model. Relaxed body canonicalisation collapses runs of whitespace and strips trailing whitespace; relaxed header canonicalisation lowercases names, undoes folding and trims. Physical header order does not matter because the verifier selects headers by name in h= order. Adding headers not named in h= does nothing whatsoever. The four that break are the four a mailing list does.
Change c= to simple/simple — a single token, and a legal choice — and five of those pass rows flip to broken: trailing whitespace on the last body line, an interior space becoming two, collapsed whitespace in Subject, a refolded signed header, and a change of header-name case. Nothing about the message differs; one letter in the signature decided which hops it can survive.
And in the other direction, adding an l= tag — a body length, so that only the first n bytes are signed — flips the mailing-list footer row to pass. The footer is appended past the signed length, so the receiver reports dkim=pass on a message somebody else has added text to. RFC 6376 §8.2 says so in as many words: the tag lets an attacker add content without invalidating the signature.
dkim=pass does not mean the recipient is looking at what was signed. RFC 6376 §5.4.2 says signers and verifiers select header instances from the bottom of the header block upward. Mail clients display the first one they meet, reading downward. Those are opposite ends of the same list:
a second Subject: placed ABOVE the signed one -> dkim=pass (and it is the one displayed)
a second Subject: placed BELOW the signed one -> dkim=fail
The documented mitigation is oversigning: naming a header in h= more times than it actually occurs, so that adding another instance breaks the signature. That is why the signature above lists From twice and ends h=From:To:Subject:Date:From. If your signature does not oversign the headers that matter, a header your reader sees is not necessarily a header anybody signed.
The key record, and the failure that is a function of key size
The signature is a reference. s=s1; d=sender.test means “fetch the key at s1._domainkey.sender.test“, and the answer to that query is a TXT record containing a whole public key. Its size matters more than anybody expects:
2048-bit selector, EDNS buffer 1232 : MSG SIZE rcvd: 488 flags: qr aa rd ra
4096-bit selector, EDNS buffer 1232 : MSG SIZE rcvd: 834 flags: qr aa rd ra
4096-bit selector, EDNS buffer 512 : MSG SIZE rcvd: 55 flags: qr aa tc rd ratc on the last line is the truncation bit: the answer did not fit, and the resolver must re-ask over TCP. A receiver whose network permits UDP port 53 and drops TCP port 53 cannot fetch a 4096-bit key, and records temperror or permerror against a perfectly correct signature — for that receiver only, decided by your key size. No amount of re-checking your own DNS will show it, because your own resolver can reach TCP. This is the same fault DNS, All the Way Down describes in general; the DKIM key is its most common real-world instance.
Stage 6: Alignment, which is the word the whole subject turns on
Everything so far authenticated a domain that is not the one your recipient reads. DMARC is the only mechanism that connects them, and the connection has a name. RFC 7489 §3.1: DMARC authenticates use of the From: domain by requiring that it be aligned with either the envelope domain SPF checked, or the d= domain DKIM checked. Relaxed mode compares organisational domains; strict mode requires an exact match. DMARC needs one aligned pass, not three passes. Four cases, all measured, all with the same published policy:
1. the arrangement most setup guides produce - the relay signs as you
spf=pass aligned: no (envelope is bounces.relay.test)
dkim=pass aligned: YES (d=sender.test) => DMARC PASS
2. the same setup with the relay signing as itself - a common default
spf=pass aligned: no
dkim=pass aligned: no (d=relay.test) => DMARC FAIL, p=reject applies
3. the same message and signature, only the policy token differing
adkim=r d=mail.sender.test vs From: sender.test aligned: yes => DMARC PASS
adkim=s d=mail.sender.test vs From: sender.test aligned: no => DMARC FAIL
4. plain forwarding - a mailing list relays the message unchanged
spf=pass aligned: no (envelope is the forwarder's)
dkim=pass aligned: YES => DMARC PASSCase 2 is the important one. SPF passes, DKIM passes, and the message is rejected. Nothing on your server is misconfigured; the d= value was chosen by your relay, on a settings page, and the three greens you were told to look for are all present. Case 1 is the same shape wearing a friendlier face: three greens again, but two of them are one leg, because SPF passed unaligned and contributed nothing. From the words “SPF: PASS, DKIM: PASS, DMARC: PASS” those two situations are indistinguishable, and one of them is one relay setting away from the other.
Case 4 is why “SPF must pass” is the wrong test to teach. After any forward, SPF for your domain does not apply — the envelope belongs to the forwarder — and nothing is wrong. DKIM is the leg that crosses hops. Which leads to the case that ruins people’s week:
5. the same forward, with the list doing what lists do (Subject tag + footer)
spf=pass aligned: no
dkim=FAIL aligned: yes => DMARC FAIL, p=reject appliesThe party that broke the signature is the mailing list. The party that receives the bounce is the mailing list. The party whose domain is named in the rejection, and whose aggregate report will record a failure, is you. RFC 7489 §3.2.3 anticipates this and RFC 7960 is an entire document about it; ARC exists as the intended remedy and requires the intermediary to implement it, which is to say it is not something you can turn on.
Stage 7: Disposition, and what a 250 actually promises
There is nothing to measure on the sending side at this stage, and that is the stage. Once the receiving server has answered 250, RFC 5321 §4.2.5 makes it responsible for delivering the message — and filing it in a spam folder, or discarding it outright, is delivering it as far as SMTP is concerned. Here is a message accepted and thrown away, as it appears to the sender:
sender log: to=<dave@black.test>, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as BLACKHOLE)
sender queue: empty
receiver: 250 2.0.0 Ok: queued as BLACKHOLE
=== DISCARDED ===
messages stored: 0status=sent. dsn=2.0.0. Nothing to retry, nothing to alert on, no string to grep for. On the sending side a discarded message and a delivered one are byte-identical in the log. This is the case the page exists for, and the honest answer is that no query you can run on your own machine distinguishes it from success. Gmail lists “spam foldering” as one of its enforcement actions alongside temporary and permanent failure codes — that is a receiver saying, in its own documentation, that a message it silently filed away is a message it told you was delivered.
Stage 8: The report, which exists in detail and is addressed to somebody else
It is often said that the failure mode of email is silence. That is true of exactly two cases and badly wrong about a third, and the difference matters because the third produces the best diagnostic object anywhere in this subject. A permanent rejection:
postfix/smtp: to=<bob@reject.test>, dsn=5.7.1, status=bounced
(host 127.0.0.1 said: 550 5.7.1 Message rejected: policy)
postfix/bounce: sender non-delivery notification: 09D45C25CF
postfix/smtp: to=<bounces-42@bounces.relay.test>, dsn=2.0.0, status=sentRead the last line. A report was generated, and it went to bounces-42@bounces.relay.test — the envelope sender, which at stage 2 became an address at your relay. The message said From: billing@sender.test. billing@sender.test was told nothing. And what it was not told is this:
Content-Type: multipart/report; report-type=delivery-status
Reporting-MTA: dns; mail.sender.test
Final-Recipient: rfc822; bob@reject.test
Action: failed
Status: 5.7.1
Diagnostic-Code: smtp; 550 5.7.1 Message rejected: policy
... followed by the entire original message, headers and allThat is an RFC 3464 delivery status notification, and it is beautifully designed: machine-readable, carrying the receiver’s exact refusal sentence, the reporting server, the arrival time, an enhanced status code and the whole original message. Its own envelope sender is null, so it cannot itself bounce and loop. Nothing about it is an afterthought. The evidence is not missing. It was generated in full, addressed correctly, transmitted successfully, and delivered to a mailbox you do not own.
A temporary rejection is the genuinely silent case, and it is silent for longer than anybody expects. Stock Postfix defaults:
queue_run_delay = 300s minimal_backoff_time = 300s
maximal_backoff_time = 4000s maximal_queue_lifetime = 5d
bounce_queue_lifetime = 5d delay_warning_time = 0hdelay_warning_time = 0h means no delay notice is ever sent. A 4xx buys five days of complete quiet followed by one report — and when that report finally arrives, it reads like this:
qmgr: status=expired, returned to sender
bounce: sender non-delivery notification: 17B35C25B7
To: bounces-77@bounces.relay.test
Arrival-Date: Fri, 4 Sep 2026 23:50:44 +0000 (UTC)
Final-Recipient: rfc822; carol@defer.test
Action: failed
Status: 4.7.1
Diagnostic-Code: smtp; 451 4.7.1 Try again laterRead the last three lines together. Action: failed — permanent, the queue has given up for good — carrying a 4-series status and the words “Try again later”. The final give-up report quotes the last temporary refusal it saw, so the single notification anybody receives about a five-day failure reads as though the problem is transient. And it is addressed, as always, to the relay.
The whole stage in one table. Every row has a trace; no row has you in the right-hand column.
| What happened | The trace that exists | Who receives it |
|---|---|---|
| 5xx rejection | a full RFC 3464 report with the receiver’s exact words | the envelope-sender domain — your relay |
| 4xx deferral | nothing at all, for five days | nobody |
| the deferral finally expiring | one report, marked failed, quoting “try again later” | your relay |
250 then discarded | status=sent, dsn=2.0.0 — identical to success | you, and it tells you nothing |
| DMARC failure | an aggregate XML report, roughly a day later | whichever address you put in rua= |
So the useful version of “the failure mode of email is silence” is not that no evidence exists. It is that the evidence is addressed to whichever identity the relay chose, which is the same thing stages 1, 2 and 5 were about. If you take one action from this page, make it finding out where your relay puts bounces and whether anybody reads them.
Stage 9: The recipient, and the one number that decides your next message
The mechanism ends at stage 7. The reader’s problem ends two stages later, with a person deciding whether they wanted the message — and, as at every other stage, the decisive fact is held by somebody else. Nothing in this stage is measurable from a sending machine, which is the finding rather than an omission. What the large receivers publish about it is short and specific:
- Keep spam rates below 0.3%, and ideally below 0.10%. That is a percentage of the people who received your mail pressing a button.
- The bulk-sender threshold is more than 5,000 messages a day to that receiver’s users.
- The spam rate is calculated daily, and is visible only through a postmaster tools account you must separately register and prove domain control for.
- Recovery is defined as a clock: eligibility for mitigation after spam rates stay below 0.3% for seven consecutive days.
- And the requirement itself, in the receiver’s own words: the domain in the
From:header must be aligned with either the SPF domain or the DKIM domain. The receivers use the word. Almost nothing written for senders does.
Two things are worth labelling as folklore, because they circulate as though they were published. No large receiver publishes a warm-up schedule — the “fifty on day one, a hundred on day two” tables all originate with relay vendors’ marketing pages. And no receiver publishes how long a new IP address is treated as suspicious; “months” is a number somebody made up. The figures above are the ones a receiver will actually stand behind.
Which leaves the honest shape of the ending. There is no completion event. No query distinguishes “delivered and ignored” from “delivered and destroyed”; the only feedback channel is a stranger pressing a button; the number those buttons produce is aggregated, computed once a day, and held in somebody else’s dashboard; and the clock that says when you have recovered is seven days long and belongs to them. The single thing available to you is to make the absence of your own mail noticeable — send yourself one scheduled message a week from the server, so that a week with no message is a thing you can see.
A worked diagnosis
Password reset emails from an application reach everybody except the staff of one customer. That customer has checked their spam folders; there is nothing there. The application’s logs are unambiguous:
to=<user@bigcustomer.example>, relay=..., dsn=2.0.0, status=sent (250 2.0.0 OK)Stage 7 or later. A 250 means stages 1 through 6 completed at the machine that answered; routing, TLS, authentication and policy all happened and none of them produced a code. Whatever went wrong went wrong after somebody took responsibility for the message. That rules out most of the things people check first.
$ dig +short MX bigcustomer.example
10 mx.filtervendor.example.Stage 3 says the machine that answered was not the customer’s. It was a filtering service, which accepted the message and will relay it onward. There is a second hop, invisible from here, at which authentication is evaluated again — and the customers where mail works do not have one. That is the difference between them.
$ python3 - "$f" <<'EOF' # the hinge, on a delivered copy of a real reset email
...
envelope-from bounces-42@bounces.relay.test
From: header sender.test
DKIM d= / s= sender.test / s2
spf aligned NO
dkim aligned yesStage 6 says there is exactly one leg, and it is DKIM. SPF passes and is not aligned, so it contributes nothing to DMARC; the message survives the second hop only if the signature does. Everything now rests on the key that s2 points at:
$ dig +short TXT s2._domainkey.sender.test | wc -c
836
$ dig TXT s2._domainkey.sender.test +bufsize=512 | grep flags:
;; flags: qr aa tc rd raStage 5 has the answer. Somebody rotated to a 4096-bit key, and the record no longer fits a small UDP response. Your own resolver fetches it over TCP without complaint, which is why every check you have run has passed. A resolver that cannot use TCP port 53 gets a truncated answer, records a temporary or permanent DKIM error, and — with no aligned SPF leg to fall back on — has no aligned pass at all. Your published policy then instructs it to reject, and it does so after the gateway has already answered 250. The bounce goes to bounces.relay.test.
Two fixes, and it is worth doing both. Publish a 2048-bit selector so the key answers inside a normal UDP response, and ask your relay to use a bounce subdomain of your domain rather than of theirs, which turns SPF into a second aligned leg. One leg is one party’s cooperation away from nothing.
What everybody says, and what it actually matches
| The advice | What is actually true | Stage |
|---|---|---|
| Publish SPF so people cannot send as your domain | SPF authorises the SMTP envelope only. A forged From: on an authorised envelope passes. Tying the two together is DMARC’s job, not SPF’s | 5, 6 |
| You want SPF, DKIM and DMARC all passing | DMARC needs one aligned pass. Three passes can still be a DMARC failure, and after any forward SPF fails by design with nothing wrong | 6 |
Merge the new include: into your existing SPF record | Correct, and it has an unmentioned cost: ten DNS-querying terms is the hard cap, and two of your first three are already inside a record your relay owns | 5 |
| DKIM breaks if anything changes the message | Under relaxed/relaxed, whitespace, refolding, header case, reordering and added headers all survive. Under simple/simple five of those break. c= is one token and decides which hops are fatal | 5 |
| A DKIM pass means the message is unmodified | A Subject: prepended above the signed one verifies clean and is the one displayed. With an l= tag, appended body content verifies clean too | 5 |
| The failure mode of email is silence | Only for a deferral and for accept-then-discard. A rejection produces a full report with the receiver’s exact words — addressed to the envelope sender, which your relay owns | 8 |
| A new IP is treated as suspicious for months | Not published by any receiver. The documented numbers are 0.3%, 5,000 a day, and mitigation after seven consecutive days below 0.3% | 9 |
| Check it worked with “Show original” in Gmail | It cannot distinguish an aligned pass from an unaligned one, which is the difference between two legs and none | 6 |
A 250 means it was delivered | It means the receiver took responsibility. Spam foldering is listed by receivers as an enforcement action alongside failure codes | 7 |
The one error, and everything it generates
Every mistake in that table comes out of a single sentence that nobody says out loud, because it seems too obvious to state: an email has a sender.
It does not. It has four addresses, three of which are chosen by machines and one of which is chosen by you, and it is yours that no mechanism checks. Believe there is one sender and the errors write themselves. If there is one sender, then SPF must be about the address you can see — so a forged From: ought to fail, and your own server ought to pass. If there is one sender, then three passes are three confirmations of the same thing rather than two statements about domains you do not own — so “all green” means safe. If there is one sender, then a bounce is obviously addressed to you, so its absence means nothing bounced. If there is one sender, then a 250 is that sender’s message being accepted rather than a receiver taking custody of an object it has not read yet. Every one of those follows perfectly from the premise, and every one of them is wrong.
The one-sentence test for anything else written on this subject: does it name an identity other than the From: header? If the words “envelope sender”, “MAIL FROM“, “Return-Path“, “d=” and “alignment” do not appear anywhere on the page, its author published three DNS records once, watched a test message arrive in their own inbox, and has never read a bounce that went somewhere else.
Three things nothing on your machine can tell you, which is the honest shape of this subject:
- Whether a message that got a
250was read, filed as spam, or destroyed. There is no state of the world in which those look different from here. - Which of your recipients’ resolvers can fetch your DKIM key. You can only observe your own.
- Your complaint rate, which is the only number that changes what happens to your next message, and is a percentage of strangers pressing a button.
And one thing in this system has already noticed the problem and fixed it, which is worth knowing because almost nobody uses the fix. Every stage of this page has ended with evidence addressed to somebody else. DMARC’s designers saw that, and the standard carries a field whose only purpose is to let you name where a copy of the verdict is sent — rua=. It is the single place in the entire subject where the party being judged gets to choose who receives the report. It is also the part of the DMARC record most guides mention last, if at all, usually with a shrug about the reports being unreadable XML. They are unreadable XML. They are also the only channel through which a receiver will ever tell you, unprompted, what it decided about your mail — including from the receivers whose users never see it.
Before you call it done
- The hinge run against a delivered copy of a real message, with both alignment lines read — not a test you constructed, and not a copy out of your own queue
- Two aligned legs, not one: DKIM signing with
d=your own domain, and a bounce address on a subdomain of yours rather than your relay’s rua=published in your DMARC record, pointing somewhere a human or a parser will actually see it- You know where your relay puts bounces, and somebody reads them
- Your DKIM selector answers inside a normal UDP response —
dig +short TXT s1._domainkey.yourdomain | wc -cwell under 500, or a deliberate decision to use 4096 bits and accept the TCP dependency - Your SPF evaluation counted, including what is inside every
include:, with headroom against ten and no dead hostnames in front of a matching term - One scheduled message a week to yourself from the server, so that its absence is something you can notice
Related reading
- Sending Email From Your Server — the build: a relay, the records, and an honest account of what running your own mail server costs
- Getting a Domain and Its DNS Right — where these records live, and what to publish for a domain that sends no mail at all
- The Life of a DNS Zone — why a record you changed is not a record the world is using yet, and when a change is over
- DNS, All the Way Down — the TCP-port-53 fault behind the DKIM key case in stage 5, in its general form
- The Life of a Log Line — why
status=sentin your own log is a claim with an author, and what a gap in a log actually means
