I run several small sites on one Ubuntu VPS. Mail goes out through Postfix, gets signed by OpenDKIM, and then leaves through a shared SMTP relay (Hostinger, which sends onward via MailChannels). I don't send directly from the VPS because a fresh IP has no sending reputation.
DKIM looked fine on paper. opendkim-testkey said key OK. The mail log showed DKIM-Signature field added. And Google's DMARC reports still said dkim=fail.
It turned out to be two separate bugs, with the same cause: the relay changes the message after I sign it.
Bug 1: the body changed
The first failure came from website contact forms. Their emails had long lines and were sent as 8bit. On the way out, the relay re-wrapped those long lines. The body changed, so the body hash (bh=) no longer matched, so DKIM failed.
Fix: send the body as quoted-printable. QP lines are already short (76 characters), so the relay has nothing to re-wrap. In PHP:
$qpBody = quoted_printable_encode(str_replace(["\r\n", "\n"], ["\n", "\r\n"], $body));
// header: Content-Transfer-Encoding: quoted-printable
After that, some domains passed. One still failed.
Bug 2: a header changed
This one was harder, because the body hash was now correct. In Gmail's Show original, the bh= in my DKIM signature matched the bh= in Google's own ARC signature. So the body arrived untouched, and the problem had to be a signed header.
My signature had h=From:To:Subject:Date:From. I compared the headers I sent with the ones Gmail received:
-
Sent:
Date: Thu, 29 Oct 2026 22:17:14 -0000 -
Received:
Date: Thu, 29 Oct 2026 22:17:14 +0000 (UTC)
Same time, different text. The relay had removed my Date header and added its own, in its own format. And Date is a signed header, so one changed character breaks the signature.
Why only some emails? My website forms (PHP mail()) never set a Date header. Postfix adds one in exactly the format the relay uses, so the relay's rewrite produced identical text. Only my Python scripts set their own Date with email.utils.formatdate(), which writes -0000. That's a valid date, just not the same string.
How I proved it
I sent test emails to a Gmail inbox and checked Show original on each:
| Test | Date header | DKIM |
|---|---|---|
| A | own, -0000
|
FAIL |
| B | own, +0000
|
FAIL |
| C | none (Postfix adds it) | PASS |
Even a "correct-looking" +0000 failed, because the relay also appends (UTC).
The fix
Don't set a Date header in scripts that send through the relay. Let Postfix add it. Postfix writes it in the same format the relay produces, so the signed text survives.
The other option is to stop signing Date in OpenDKIM (SignHeaders). I didn't, because Date is one of the headers the DKIM spec recommends signing.
What I'd check first next time
- Read the DMARC aggregate reports. They tell you which selector and which source IP failed.
- In Show original, compare your
bh=with the receiver's ARCbh=. If they match, the body is fine and a header changed. - Diff the headers you sent against the headers received. Any signed header that differs is your bug.
- Remember that a relay can rewrite both the body and the headers.
Mail still got delivered the whole time, because my DMARC policy was p=none. But a failing signature is exactly the kind of thing that quietly moves you into spam, and I'd rather find it from a report than from a client who never got my reply.
I'm Azeem. I build and fix web systems (WooCommerce, Stripe, AI features) at jeemmo.com.
Top comments (0)