I lost a suspect before the investigation properly started. Every mail leaving the site was landing in Gmail's spam folder, and my first accusation went straight to the HTML template: a 600 pixel table, inline styles everywhere, one image pulled from a remote server. That kind of thing is easy to suspect. The problem is that the older version of the same mail, still plain text without a single tag, landed in spam too. The template was cleared, and whatever was wrong lived a layer further down.
The only way to see that layer is to open the raw headers of a message that actually arrived. Sitting there were two verdicts contradicting each other, printed next to one another by the same receiving server:
Return-Path: <noreply@srv0000.shared-hosting.example>
Received: from srv0000.shared-hosting.example (unknown [198.51.100.42])
dkim=none;
spf=pass smtp.mailfrom=noreply@srv0000.shared-hosting.example;
dmarc=fail reason="SPF not aligned (relaxed), No valid DKIM"
header.from=client-site.example
The hostname, the IP and the domain names above are anonymised, but the shape is exactly what I read. One line says it passed, another says it failed, and both are telling the truth.
spf=pass answers a question I never asked
An email carries two different sender addresses, and it is very easy to treat them as one. The first is the envelope address, which shows up as Return-Path and as smtp.mailfrom in the authentication results. That is the address servers use for machinery like returning mail that could not be delivered. The second is the address in the From: header, and that is the only one a recipient ever sees in their inbox.
SPF only checks the first one. Its question is narrow: is the IP that just handed over this message allowed to send on behalf of the envelope domain? On this site, mail went out through PHP's built in mail function on shared hosting, so the envelope domain was the server's own hostname. A server is of course allowed to send on behalf of itself. So spf=pass there was almost incapable of failing, and it said nothing whatsoever about the client's domain.
DKIM was not part of the game at all. dkim=none means there is no signature to check, which is what you would expect when nothing on this path signs anything.
DMARC is the one that collects the rest. It is not satisfied by a pass on its own, it demands alignment: either SPF or DKIM has to pass for the same domain that appears in From:. Relaxed mode is a little more forgiving because it compares organisational domains rather than exact hostnames, but that forgiveness does not help when the comparison is a hosting provider's domain against the site's domain. So SPF passed in the wrong place, DKIM was absent, and DMARC failed. The reason was even spelled out in full in the header, waiting to be read.
The shortcut that looked cheap, and why I threw it away
The first fix that came to mind needed exactly one option. Force Return-Path to use an address at the site's domain, then smtp.mailfrom and header.from become the same domain, alignment is satisfied, DMARC is happy. No new mailbox, no new service.
I am glad I tested it first, because the logic has a hole in it. Alignment is one of DMARC's conditions, but it is not the only one. Whichever check you align still has to pass. The moment Return-Path moves to the site's domain, the record used to judge it is no longer the hosting server's SPF record but the site domain's SPF record, and the question becomes whether this web server's IP appears inside it.
That record was a handful of include: mechanisms, and an include: holds no IPs at all, it points at another record that can point at yet another one. So it cannot be answered by looking at a single line.
dig +short TXT client-site.example
"v=spf1 include:_spf.provider.example include:relay.provider.example ~all"
I wrote a small script that walked the chain recursively and collected the ip4 mechanisms at the far end of it, until no include: was left. The final set came out as five blocks: two /24s, one /22, one /20, and one /32 pointing at a single host. The web server IP that had been doing all the sending was in none of them.
Which means the shortcut was not half a fix, it was a step backwards. The current state was pass but not aligned. Force Return-Path into alignment and the state becomes an outright spf=fail, handing receivers an explicit rejection to act on rather than merely an absence of proof. Satisfying one condition while breaking another lands you below where you started.
The fix: send from a mailbox that genuinely exists on that domain
Since there is no way to make that web server legitimate for the site's domain, I stopped trying to force it. Sending moved to authenticated SMTP through WP Mail SMTP, using a real mailbox on the site's domain, port 465 with SSL, authentication on, and the sender name and address filled in with that same mailbox.
One option I switched on that people often skip is Force From Email. Without it, any plugin is free to set its own From: address at send time, and a single plugin writing an address on some other domain is enough to knock out the alignment you just worked to build. Force From Email keeps the sender uniform no matter what the code above it asks for.
After that everything fell into place by itself. SPF is evaluated against the mail provider's servers for the site's domain, which they are authorised for, and the envelope address and the header address sit on the same domain because they come from the same mailbox. The alignment is not a patch, it is a consequence of how the mail is sent.
The order you switch things on costs more time than the fix
There is an option called Return Path in that same panel, and it is the one that ate the most of my time. The rule is that it has to be off while the PHP mailer is still active, and only switched on once real SMTP is genuinely running.
The reason is the exact shortcut I had already discarded. While the PHP mailer is the active one, that option forces the envelope sender to an address on the site's domain, which the domain's SPF record does not authorise for the web server's IP. Turn it on too early and a state that was passing but unaligned drops straight to failing, precisely when it feels like you are preparing the fix. Configuration that is correct in the final state is not necessarily safe in the intermediate one.
There is one more small trap in that panel that had me convinced the settings were not saving. When you save while the PHP mailer is still selected, the plugin raises a jQuery confirmation dialog, and nothing is actually written until its confirm button is clicked. Several dead ends I had noted down as "the setting will not stick" were all that dialog, over and over.
Do not take your proof from an instrument you control
The plugin's built in test button is useful, but its scope is narrow. If the SMTP credentials are wrong it fails with an authentication error, so a pass proves the connection and the login are right. It proves nothing about which folder the message lands in, and that was the question I was actually chasing.
So I verified through a real form submission instead. There was a home grown trap waiting there: the form has two success messages that read alike and mean the opposite of each other. One means the mail genuinely went out, the other is the response to the honeypot and the timing trap, deliberately made to look successful while nothing is sent at all. Misread which one came back and you can celebrate a fix that never happened. Read the sentence, not the colour.
The best evidence arrived for free and I had not planned for it. The received copy finally rendered the logo in its body, where before there had been an empty box. Gmail only loads remote images for mail it does not consider spam. So that visible logo was not decoration, it was a verdict from the receiving side that I could not touch, could not configure, and could not talk into agreeing with me.
That green line was answering its own question
What made this case slow was not its technical difficulty but a single word in a header that I read as a verdict when it was only a note. SPF, DKIM and DMARC ask three different things, and only the third one cares about the domain a recipient sees. Passing the first two means nothing if you passed them somewhere nobody was asking about.
Two habits came out of it. First, before patching alignment, resolve the SPF chain down to its final list of IPs and check whether your sender is actually covered, because half a fix can land lower than no fix at all. Second, look for evidence your own test tooling is incapable of faking. The test button answers a question I wrote myself. An image the recipient finally chose to load answers the real one.