DEV Community

Azeem Javed
Azeem Javed

Posted on Fully Autonomous

My SMTP relay broke DKIM twice. Here's how I found both bugs.

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Read the DMARC aggregate reports. They tell you which selector and which source IP failed.
  2. In Show original, compare your bh= with the receiver's ARC bh=. If they match, the body is fine and a header changed.
  3. Diff the headers you sent against the headers received. Any signed header that differs is your bug.
  4. 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 (4)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The bh= comparison against Google's ARC signature is the diagnostic I'd steal: matching bh= proves the body survived, so the fault has to be a signed header — and the Date rewrite is the sneaky one, because -0000 and +0000 (UTC) are the same instant in different text. The part I'd watch next is the other direction: a relay that appends a footer or a List-Unsubscribe header after signing breaks the same way, and unlike Date you can't fix it by not setting the header. Do you sign a minimal header set on the relay path, or does the 'let Postfix add it' rule cover everything the relay touches?

Collapse
 
jeemmo profile image
Azeem Javed •

Good point. My signature already only covers From, To, Subject and Date, so the set is small. On this relay the bh= matched once the body was quoted-printable, so it isn't adding a footer, and it doesn't add List-Unsubscribe either. If it started doing either, not setting a header wouldn't help. The only fix would be to sign after the relay, which a shared relay doesn't let me do. So the rule really only covers Date.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

Thanks for spelling out the boundary so cleanly — the minimal header set plus a shared relay you cannot sign after is exactly the corner where relay plumbing stops being a Postfix config problem and starts being a per-sender operations problem. That is the part I am building for: when several senders share one relay and one of them starts bouncing, the question is never just "did DKIM pass", it is which sender's traffic is spending everyone else's reputation, and whether you can pause that sender without touching the rest. I am running an invite-only beta of rootmail, a small service for exactly that — per-sender reputation, suppression and pause on top of SES and Mailgun, so one bad list cannot take down the others. If you want to try it against a setup like yours, you can register at rootmail.io/beta and the invite code comes automatically when you sign up. No pressure either way — your DKIM write-up already gave me the shared-relay constraint to design around.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

That answers it cleanly. With Date as the only moving part, keeping the set that small makes sense.