Four reports that sound like the protocol is broken. Each has a mundane explanation, and recognising them saves a day of looking in the wrong place.
"SPF passed but the mail was rejected"
Almost always DMARC alignment. SPF passed for the envelope domain, which was a vendor’s; the From header was yours; nothing aligned, and the DMARC policy applied.
Authentication-Results: spf=pass smtp.mailfrom=bounce.vendor.example <- passed, for the vendor dmarc=fail header.from=acme.com <- rejected, for you
The tell is two different domains on those two lines. SPF did its job perfectly; it was simply never asked about the address the recipient saw.
Less commonly: the receiver rejected for content or reputation, and SPF is irrelevant to the rejection entirely. Read the SMTP response rather than assuming.
"It failed from a host that is definitely in our record"
| Cause | Check |
|---|---|
| Evaluation never reached that term | An earlier all, or a match above it. |
| The record is at permerror | Then nothing passes, including listed hosts. Audit it. |
| The envelope domain is a subdomain | SPF is not inherited. mail.acme.com needs its own record. |
| The host sent over IPv6 | The record lists ip4 only. Very common and looks random. |
| A stale cached record | Query the authoritative server directly. |
The IPv6 one catches everybody once
A host with both A and AAAA records will happily connect over IPv6 to a receiver that offers it. If your record has no ip6 term, that connection fails SPF while the same host passes over IPv4 — so it fails for some recipients and not others, with no pattern anyone can see from the sending side.
"It works for Gmail but not Outlook"
- Different resolvers, different caches. One may hold a record you have since fixed.
- Different transport. See above — one receiver offers IPv6, the other does not.
- Receivers apply their own policy on top of the SPF result. The result can be identical and the disposition different.
"Nothing changed and it broke"
Nothing you changed. Records go over the lookup budget on their own when a platform restructures its own record, and an include target that gets decommissioned turns into void lookups without anyone editing anything. This is module 8, and it is the most common explanation for a record that was fine for a year and is not today.