Gmail bounced everything the relay sent: three broken auth mechanisms and a silent OpenDKIM
**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 a couple of dozen application hosts: password resets, notifications, alerts. Inbound mail for the domain goes elsewhere, to Google Workspace as MX. Postfix on a legacy container with more than two years of uptime, OpenDKIM 2.10.3, no Ansible, edits by hand only. The daily log summary read a four-digit `bounced` against a single-digit `sent`: thousands of rejections, a handful of deliveries. 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
* **Root cause (DKIM):** `KeyTable`/`SigningTable` mounted as `refile:` while the entries were plain `example.org mail._domainkey.example.org` lines. Under `refile:` the key is a regex matched against the whole `From:` address, so `example.org` never matches `noreply@example.org`. Changing one word per line to `file:` fixed it.
* **Also broken:** `InternalHosts` listed fewer than ten addresses against a Postfix `mynetworks` two to three times longer. Mail from the app hosts was being verified instead of signed.
* **Also broken:** the only SPF-shaped TXT record was `spf2.0/mfrom,pra` SenderID. RFC 7208 discards anything that doesn't begin with `v=spf1`, so the domain effectively had no SPF record.
* **Also broken:** the PTR pointed at a hostname whose A record led to a different machine. Forward-confirmed reverse DNS failed.
* **Trap:** `grep -c DKIM-Signature /var/log/mail.log` returns `0` whether signing works or not. Postfix does not write message headers to the log.
* **Trap:** testing with `sendmail` from the mail server itself passes, because `127.0.0.1` was in `InternalHosts`. Real traffic from other hosts kept going out bare.
## 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 a bounce column four digits wide.
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/` had been touched recently while the files inside it were years older. 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:
-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 several domains, and rewriting every line 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 was a fraction of mynetworks
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 fewer than ten addresses. Postfix `mynetworks` listed two to three times as many. Syncing the short list against the long one tripled it, finally covering the application 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 the application 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
Broken | Symptom you see | Check that settles it
---|---|---
No `v=spf1` record, SenderID only | `SPF … = did not pass` | `dig +short TXT example.org` — the string must begin `v=spf1`
PTR forward mismatch | `550-5.7.25 … forward DNS entry does not match` | `dig -x <ip>`, then resolve that name's A record back — it must return `<ip>`
`refile:` on plain-format tables | Nothing. No log line at all. | Read the `DKIM-Signature` header of a delivered message
`InternalHosts` narrower than `mynetworks` | Signs locally, unsigned from apps | Send 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: six messages in ten still bounce
Passing SPF and DKIM gets mail accepted, not trusted, and this domain's reputation problem survived every fix above untouched. A full day of counters after the fix still showed more rejections than deliveries — roughly six messages in ten going nowhere — and among them close to a hundred a day 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 hundreds of 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 that expired years ago. 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.
_Originally published at bitpage.me — BitPage, a technical blog on backend, databases, infrastructure and incident post-mortems._