The Life of an Outbound Email: Four Addresses, and the One You Set Is the One Nobody Checks

ByLinuxtastic

September 5, 2026

An application writes a file with a From: header and pipes it to sendmail. The first line of the SMTP conversation carries a different address, and it is the only one SPF will ever judge:

$ 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 reads

www-data@sender.test is the envelope sender. It is not a header, it appears nowhere in the file, and the local mail system chose it from the unix account the process ran as. 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=fail

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 is misconfigured. RFC 7208 authorises 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 your recipient sees and which none of SPF, DKIM or the SMTP transaction authenticates. The mechanism that ties any of the other three back to it is called alignment. Getting a relay working and publishing the records is Sending Email From Your Server‘s; when a DNS change is over is The Life of a DNS Zone‘s. This page is about one message you have already sent, and 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))
EOF

Read 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 backWhat it meansWho owns the answerStage
envelope-from domain differs from the From: domainSPF is authenticating a domain you do not own, and SPF cannot be a DMARC leg for this message however green it looksyour relay5, 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: domainwhoever signed is signing as you; DKIM is an aligned leg, and it survives forwardingwhoever holds the private key5, 6
DKIM d= differs from the From: domainyour relay is signing as itself. dkim=pass will be reported and it is not a DMARC legyour relay6
both aligned lines say NOyour published p= applies to every message you send, at every receiver that enforces itthe receiver6
exactly one says yesone party’s continued cooperation is the only thing standing between you and your own policy, and nothing will tell you when it stopsthat party6
both say yesthe only arrangement that survives both a forward and a relay change——

Two limits on that reading. The alignment test is the relaxed comparison approximated 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 reports dkim aligned: yes on a message whose signature a mailing list has since broken.

Stage 1: Submission

Nothing in a file piped to sendmail -t mentions an envelope sender, so the local mail system invents one from the unix account. What 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)

The envelope sender is www-data@sender.test and it is nowhere in the message. Message-Id and Date were generated by the mail transfer agent, and the identifier carries the MTA’s hostname and queue ID rather than the From: domain; every abuse report and feedback loop keys on it, and a program the application never spoke to chose it. 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

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 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 the three appears in your server’s configuration, so there is nothing on your machine to inspect. They become visible only in later stages, which is why the hinge runs against a delivered message rather than a config file: it is the only place all three are written down together.

Stage 3: Routing

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 organisation

The last row is the one to look at first. mx.filtervendor.test is not the recipient’s organisation but a filtering service in front of it, and the message will be relayed onward from there. That second hop is where a 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:

$ dig +short MX recipient-domain.example

The first row 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. If you own a domain that sends mail but never receives it, publishing a null MX makes somebody else’s failure fast.

Stage 4: The hop

Postfix’s upstream default for outbound TLS is empty: no encryption. Ubuntu 24.04 ships an override in /etc/postfix/main.cf setting smtp_tls_security_level=may, so opportunistic TLS is what almost every reader has without having decided anything. Three behaviours follow from that one word:

# 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, because refusing one would mean cleartext. The second case is the one to internalise: 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. 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. Postfix 3.8.6 has no built-in MTA-STS client, and the resolver package that adds one is not installed by default.

Stage 5: Authentication

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 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

One published record, v=spf1 include:_spf.relay.test -all, evaluated against five realistic situations:

Sending hostMAIL FROMResultDNS lookups spent
the relay, 198.51.100.25www-data@sender.testpass3
the relay, 198.51.100.25bounces-42@bounces.relay.testpass1 — and it never queried sender.test at all
a stranger, 192.0.2.44anything@list.test, with From: billing@sender.testpass1
your own server, 203.0.113.10www-data@sender.testfail4
the relay, sending a bounce: MAIL FROM:<>, HELO mail.relay.test— (null reverse-path)none1

Row two is the ordinary case for anybody using a relay: the check passed, in one lookup, at bounces.relay.test, without ever consulting the record you published. Row four is its mirror: 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 to use a null reverse-path, and RFC 7208 §2.4 says SPF is then evaluated against postmaster@ the HELO name. Every bounce in the world is judged on the HELO name of the machine that sent it, an address nobody configures. Here that name published nothing, so the result was none: no policy to apply.

SPF: the lookup budget

RFC 7208 §4.6.4 caps an SPF evaluation at ten DNS-querying terms (include, a, mx, ptr, exists, redirect) and requires permerror beyond that. Void lookups, terms whose query returns no answer or NXDOMAIN, should be limited to two. What the single include: above 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's

Three 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. Merging a second service’s include: into your record is correct advice, since two SPF records at one domain is a permanent error, but merging is precisely what spends the budget, and the budget is mostly spent by other people:

spf=permerror   SPF Permanent Error: Too many DNS lookups
DNS lookups performed: 11

The void limit 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 exceeded

The client was authorised, by the ip4: term, which never ran, because two hostnames that no longer resolve sat in front of it. And permerror is not a fail: RFC 7208 §2.6.7 calls it 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 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, Received and Return-Path are not in it; anything not named there can be rewritten in transit and the signature still verifies. d= names the domain whose key is used, s= the selector that picks one key record at that domain, and c= the canonicalisation pair, one token that decides which hops are survivable. Twelve in-transit modifications, verified with c=relaxed/relaxed:

What happened to the message on the wayrelaxed/relaxed
a mailing-list footer appended to the bodybroken
Subject: prefixed with [linux-users]broken
a second From: header addedbroken
From: display name changed, address keptbroken
trailing whitespace added to the last body linepass
one interior space became twopass
signed headers physically reorderedpass
an unsigned header added (X-Spam-Status)pass
Message-Id rewritten by a gatewaypass
internal whitespace in Subject collapsedpass
a signed header refolded onto two linespass
(control) nothing changedpass

Relaxed body canonicalisation collapses runs of whitespace and strips trailing whitespace; relaxed header canonicalisation lowercases names, undoes folding and trims. Header order does not matter because the verifier selects headers by name. Adding headers not named in h= does nothing. The four that break are the four a mailing list does. Change c= to simple/simple and five of the 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. Add an l= tag, a body length, and the mailing-list footer row flips to pass: the footer is appended past the signed length, and RFC 6376 §8.2 says in as many words that 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

s=s1; d=sender.test means “fetch the key at s1._domainkey.sender.test“, 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 ra

tc 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 correct signature, for that receiver only, decided by your key size. Your own resolver can reach TCP, so no check you run will show it. DNS, All the Way Down describes the fault in general; the DKIM key is its commonest real-world instance.

Stage 6: Alignment

Everything so far authenticated a domain that is not the one your recipient reads. DMARC connects them. RFC 7489 §3.1: DMARC authenticates use of the From: domain by requiring it to be aligned with either the envelope domain SPF checked or the d= domain DKIM checked; relaxed mode compares organisational domains, strict requires an exact match. DMARC needs one aligned pass, not three passes. Four cases, 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 PASS

Case 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. Case 1 is the same shape wearing a friendlier face: three greens, but two of them are one leg, because SPF passed unaligned and contributed nothing. From “SPF: PASS, DKIM: PASS, DMARC: PASS” the two are indistinguishable, and one is a relay setting away from the other.

Case 4 is why “SPF must pass” is the wrong test to teach. After any forward the envelope belongs to the forwarder, SPF for your domain does not apply, 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 applies

The 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 records a failure, is you. RFC 7489 §3.2.3 anticipates this and RFC 7960 is an entire document about it; ARC is the intended remedy and requires the intermediary to implement it.

Stage 7: Disposition

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 is delivering it as far as SMTP is concerned. A message accepted and thrown away, as the sender sees it:

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: 0

status=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. Gmail lists “spam foldering” as an enforcement action alongside temporary and permanent failure codes: 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

“The failure mode of email is silence” is true of two cases and badly wrong about a third, and the third produces the best diagnostic object in the 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=sent

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, and billing@sender.test was told nothing. What it was not told:

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 all

That is an RFC 3464 delivery status notification: machine-readable, carrying the receiver’s exact refusal sentence, the reporting server, the arrival time, an enhanced status code and the whole original message, with a null envelope sender so it cannot itself bounce. 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 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 = 0h

delay_warning_time = 0h means no delay notice is ever sent. A 4xx buys five days of quiet followed by one report, and that report 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 later

Action: failed, permanent, carrying a 4-series status and the words “Try again later”: the give-up report quotes the last temporary refusal it saw, so the one notification anybody receives about a five-day failure reads as transient. And it is addressed, as always, to the relay.

What happenedThe trace that existsWho receives it
5xx rejectiona full RFC 3464 report with the receiver’s exact wordsthe envelope-sender domain — your relay
4xx deferralnothing at all, for five daysnobody
the deferral finally expiringone report, marked failed, quoting “try again later”your relay
250 then discardedstatus=sent, dsn=2.0.0 — identical to successyou, and it tells you nothing
DMARC failurean aggregate XML report, roughly a day laterwhichever address you put in rua=

So the useful version of the saying is not that no evidence exists but that the evidence is addressed to whichever identity the relay chose. Find out where your relay puts bounces and whether anybody reads them. The one exception is DMARC’s rua=: the single place in the subject where the party being judged names who receives the report. The aggregate reports are unreadable XML, and they are the only channel through which a receiver will ever tell you, unprompted, what it decided about your mail.

Stage 9: The recipient

The mechanism ends at stage 7. The reader’s problem ends two stages later, with a person deciding whether they wanted the message, and nothing in this stage is measurable from a sending machine. 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 circulate as though published and are not. No large receiver publishes a warm-up schedule; the “fifty on day one, a hundred on day two” tables come from 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.

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 computed once a day in somebody else’s dashboard; and the recovery clock is seven days long and belongs to them. The one 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. 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 to 6 completed at the machine that answered. Whatever went wrong went wrong after somebody took responsibility for the message, which rules out most of what 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 relays it onward. There is a second hop at which authentication is evaluated again, and the customers where mail works do not have one.

$ 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    yes

Stage 6 says there is exactly one leg, and it is DKIM. SPF passes and is not aligned, so it contributes nothing; the message survives the second hop only if the signature does. Everything 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 ra

Stage 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, which is why every check you ran passed. A resolver that cannot use TCP port 53 gets a truncated answer, records a DKIM error, and with no aligned SPF leg has no aligned pass at all. Your published policy tells it to reject, after the gateway has already answered 250, and the bounce goes to bounces.relay.test.

Two fixes, and do 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 theirs, which turns SPF into a second aligned leg. One leg is one party’s cooperation away from nothing.

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 -c well 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