BitPage

Gmail bounced 1,429 messages in a day: three broken auth mechanisms and a silent OpenDKIM

By  ·   · 13 min read

Short answer: Three independent mechanisms were broken at once, and fixing any one of them changed nothing visible. The domain had no v=spf1 record (only a dead SenderID one), the PTR resolved forward to a different server, and OpenDKIM was signing nothing because KeyTable and SigningTable were attached as refile: while their contents were in plain two-column format. That last one cost the most time: when OpenDKIM can’t find a key it logs absolutely nothing, so the daemon reads active, the config parses, the key is readable, and mail still leaves unsigned.

The server is an outgoing relay for transactional mail from about 20 application hosts: password resets, notifications, alerts. Inbound mail for the domain goes elsewhere, to Google Workspace as MX. Postfix on a legacy container with 753 days of uptime, OpenDKIM 2.10.3, no Ansible, edits by hand only. The daily log summary read bounced=1429 against sent=5. Password reset mail was among the casualties, so people could not get back into their accounts. Nobody had changed anything on this box in years, which turned out to be the whole story.

TL;DR

Why a server nobody touched stopped being deliverable

The requirements moved while the configuration stood still. Gmail began requiring authentication from bulk senders in February 2024 and has been ramping enforcement since, and a config that predated those rules simply stopped qualifying (Email sender guidelines). There was no change window to roll back to, no deploy to blame. The rejection came back verbatim as:

550-5.7.26 Your email has been blocked because the sender is unauthenticated.
550-5.7.26 Gmail requires all senders to authenticate with either SPF or DKIM.
550-5.7.26  Authentication results:
550-5.7.26  DKIM = did not pass
550-5.7.26  SPF [example.org] with ip: [203.0.113.51] = did not pass

Both named mechanisms failing at once should have been the tell that this was not one bug. I read it as one bug anyway and spent the next hour paying for it.

Three of the four facts in my own notes were wrong

I started from a ticket written a day earlier that already contained a diagnosis, and rechecking it by hand killed three of its four claims. The notes said: PTR is fine and forward and reverse match; DNS is hosted on Cloudflare; DMARC is absent.

The PTR pointed at legacy.example.org, whose A record led to 198.51.100.216, a different server entirely. dig NS returned ns-*.awsdns-*, so DNS was on Route 53, not Cloudflare. DMARC was present and had been all along, v=DMARC1; p=none. One claim out of four survived. These were my own notes, from the previous day.

The other rejection message had been telling me about the PTR the whole time:

550-5.7.25 [203.0.113.51] The IP address sending this message does not have a PTR
550-5.7.25 record setup, or the corresponding forward DNS entry does not match the sending IP

The operative half is the second clause. Not “you have no PTR record” but “the forward entry doesn’t match”. I had read to the first familiar phrase, seen a PTR existed, and moved on. RFC 1912 §2.1 states the rule plainly: “Make sure your PTR and A records match. For every IP address, there should be a matching PTR record in the in-addr.arpa domain” (RFC 1912). The same section warns that getting this wrong “can cause loss of Internet services similar to not being registered in the DNS at all”, which is a fair description of bounced=1429.

The fix had two possible shapes. Editing the A record of legacy.example.org to point here would have satisfied the check, but that hostname is live and fronts an unrelated service, so the blast radius covered somebody else’s system. Repointing the PTR to mail.example.org, whose A record already resolved to the sending IP, changed nothing outside this server. I also set Postfix myhostname to mail.example.org so the HELO name matches the PTR.

Why the SPF record that existed didn’t count

There was a TXT record on the domain, and it was worth exactly nothing, because it was a SenderID record rather than an SPF one. It began spf2.0/mfrom,pra. RFC 7208 §4.5 is unambiguous about what a verifier does with that: “Starting with the set of records that were returned by the lookup, discard records that do not begin with a version section of exactly ‘v=spf1’” (RFC 7208).

Discarded means the domain had no SPF record at all as far as Gmail was concerned, which is precisely what SPF [example.org] ... = did not pass was reporting. A record that looks like SPF to a human and is invisible to every verifier is worse than an empty zone, because it stops anyone from looking further. Published in its place:

v=spf1 ip4:203.0.113.51 include:_spf.google.com include:spf.example.net ~all

Why grepping the mail log for DKIM-Signature proves nothing

Postfix does not write message headers to its log, so the count is zero whether signing works or not. My first check was grep -c "DKIM-Signature" /var/log/mail.log, which returned 0, and I took that as evidence that nothing was being signed. It wasn’t evidence of anything. The method cannot distinguish a broken signer from a working one, and I used it as the basis for the next hour of work.

The check that does work is to send a message to a local mailbox and read the headers of the delivered file, /var/mail/nobody. That shows the actual DKIM-Signature header or its absence, with no inference in between. Building that first would have made every subsequent change a one-minute yes or no. I built it fourth.

OpenDKIM reports active, parses clean, and signs nothing

OpenDKIM writes no log line when it fails to find a signing key for a message, so systemctl is-active returning active alongside a clean startup is zero evidence that signing happens. I found and fixed two genuine defects on the strength of that assumption, and neither produced a signature.

The first was the config itself. /etc/opendkim.conf had been truncated to four lines, with no Mode, no KeyTable, no SigningTable. The fingerprints of a package upgrade were right there: /etc/opendkim/ carried an mtime of Oct 11 2024 while the files inside it dated from 2017. I restored the missing directives and restarted. Clean start, no errors, no signature.

The second was key ownership. The private key belonged to _apt:uuidd with mode 0600, and the daemon runs as opendkim. Checking as the service user rather than as root is the only way to see this:

su -s /bin/sh opendkim -c "head -c 20 /etc/opendkim/default/mail.private"
head: cannot open '/etc/opendkim/default/mail.private' for reading: Permission denied

A real defect, and chown opendkim:opendkim is mandatory. The key became readable. Still no signature. Two genuine bugs repaired, both necessary, neither sufficient, and my supply of hypotheses was gone.

The actual root cause was one word, and it was in the man page

The tables were attached as refile: while their contents were in plain two-column format, and under refile: the lookup key is treated as a regex matched against the entire From: address. I stopped guessing and opened man opendkim.conf on the server itself, same 2.10.3 as the running binary, which is more authoritative than whatever version the web is serving. The answer sits in one paragraph about dataset types:

“If this table specifies a regular expression file (“refile”), then the keys are wildcard patterns that are matched against the address found in the From: header field. For all other database types, the full user@host is checked first, then simply host, then user@.domain (with all superdomains checked in sequence…)”

The tables held lines like this:

example.org  mail._domainkey.example.org

Under refile:, the pattern example.org is matched against noreply@example.org and does not match, so no key is found and the message leaves unsigned without a single log line. Under file:, the lookup falls through to the second step, “then simply host”, where example.org matches exactly. The fix was one word in two lines:

1
2
3
4
-KeyTable                refile:/etc/opendkim/KeyTable
-SigningTable            refile:/etc/opendkim/SigningTable
+KeyTable                file:/etc/opendkim/KeyTable
+SigningTable            file:/etc/opendkim/SigningTable

The other direction works too: rewrite every table entry to *@example.org and keep refile:. I rejected it because the tables covered five domains, and rewriting five lines of data is a wider edit than changing the dataset type to the one that already matches the format on disk.

The second half of the same bug: InternalHosts had 7 entries, mynetworks had 23

OpenDKIM only signs mail arriving from hosts in InternalHosts; everything else it verifies instead, so any app server missing from that list was sending bare mail by design. The man page states the whole behaviour in one line, describing the option as identifying “a set internal hosts whose mail should be signed rather than verified” (typo included, as printed in 2.10.3).

InternalHosts pointed at TrustedHosts, which listed 7 addresses. Postfix mynetworks listed 23. I brought the file up to 21 entries, adding 14 hosts that had been relaying through a server which was, on paper, configured to sign their mail. The two files are not required to match exactly, since they answer different questions, but a host that can relay and cannot be signed is almost always an oversight rather than a decision.

This is also why my manual test had lied to me earlier. Mail sent with sendmail from the relay itself originates from 127.0.0.1, which was in the list, so a signature appeared and I briefly thought the job was done. The traffic that actually mattered came from twenty other hosts, none of them in the file. Test along the path production traffic takes, not the path that is convenient from an SSH session.

The four defects and the check that actually proves each one

BrokenSymptom you seeCheck that settles it
No v=spf1 record, SenderID onlySPF … = did not passdig +short TXT example.org — the string must begin v=spf1
PTR forward mismatch550-5.7.25 … forward DNS entry does not matchdig -x <ip>, then resolve that name’s A record back — it must return <ip>
refile: on plain-format tablesNothing. No log line at all.Read the DKIM-Signature header of a delivered message
InternalHosts narrower than mynetworksSigns locally, unsigned from appsSend from a real application host, not from the relay

The third row is the one worth internalising. Three of these four failures announce themselves somewhere. The dataset-type bug produces no error, no warning, and a healthy-looking service, and the only way to observe it is to inspect the output rather than the process.

Step by step

  1. Build the verification path before touching anything: send to a local mailbox and read the delivered headers. Every later change then gives an immediate yes or no.
  2. Recheck every fact you were handed, including notes you wrote yourself yesterday.
  3. Read the whole SMTP rejection. 5.7.25 and 5.7.26 name different failures, and in both the specificity lives in the second clause of the sentence.
  4. Resolve the sending IP with dig -x, then resolve the answer forward. Both directions, or it doesn’t count.
  5. Confirm the TXT record begins with v=spf1 rather than merely mentioning SPF.
  6. For DKIM: confirm the dataset type matches the table format, confirm the service user can read the key with su -s /bin/sh opendkim -c "…", and confirm InternalHosts covers every host in mynetworks.
  7. Send the final test from a host that is not the mail server.

The end state was DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=mail in the delivered headers, spf=pass, dmarc=pass, and 250 2.0.0 OK from Gmail. Total time was about an hour and a half, most of it spent on the silent one.

What authentication didn’t fix: a 61% bounce rate

Passing SPF and DKIM gets mail accepted, not trusted, and this domain’s reputation problem survived every fix above untouched. The counters showed 687 rejections against 436 deliveries, a 61% bounce rate, and among them 104 messages addressed to @iclaud.com, a typo of @icloud.com that the application has been faithfully retrying for years. Bounce rate is not the metric Google publishes a threshold for; that one is spam rate, which bulk senders are asked to keep under 0.30% in Postmaster Tools. They are different numbers, but they feed the same reputation ledger, and 0.30% is a useful sense of the scale a receiver considers acceptable. Mail with flawless authentication still lands in the spam folder if the domain spends years hammering addresses that don’t exist.

The reason nobody knew is that the feedback loop had been cut at both ends. The server delivered non-delivery reports to the sender address, whose domain it considered local via mydestination, and no local user by that name existed, so the reports were discarded on arrival. That was 348 destroyed reports a day. The application mails a dead address, the report about it is deleted by the same server, nobody finds out, and the application keeps mailing.

What I deliberately did not do was tighten DMARC past p=none. Moving to quarantine before the statistics are clean would route legitimate mail to spam by my own hand, and there is no rush. Still outstanding: the envelope sender equals the From: address, where a dedicated bounce address with VERP belongs; certbot 0.12.0 is dead on this box, still calling the retired acme-v01 endpoint, with a certificate expired since Sep 13 2021. And every change here was made by hand on a container that lives outside Ansible, so none of it exists in any repository. The next person to open this server will find no trace of why it works.

Bottom line

When a daemon reports active and does not do its job while logging nothing, stop repairing hypotheses and go read the man page for the semantics of the specific option you are relying on. Silence is not the absence of a fault. It is its own class of behaviour, and it is always described somewhere in the documentation, usually in a sentence about how lookups are performed.

The process mistake was ordering. I fixed three genuine defects (truncated config, key ownership, then the host list) before finding the one that actually mattered, and each time I expected the next test to pass. A reliable check built at minute one, reading the headers of a delivered message, would have turned each of those into a one-minute negative result instead of an hour of accumulating false hope. Build the instrument before you start turning things.

#postfix #opendkim #dkim #spf #dns

<< Previous Post

|

Next Post >>