Password reset emails going to spam: fixing SPF, DKIM and DMARC alignment
After we tightened DMARC to quarantine, Gmail started filing our password resets and receipts as spam. Both SPF and DKIM passed, but for amazonses.com, not our domain. Here is how we read the headers, aligned both, and kept it that way.
Our product is a scheduling app for clinics. It sends three kinds of transactional email: password resets, appointment confirmations and payment receipts. In mid-May support tickets started arriving with the same line: "I never got the reset email." The emails were in the users' spam folders, mostly on Gmail, and a few Outlook.com users could not find them at all.
Our sending dashboard showed nothing unusual: delivery rate 99.6 percent, bounces normal. The mail was accepted by the receiving servers; it was being classified as spam after acceptance. Opening one in Gmail and choosing Show original gave us the authentication summary:
SPF: PASS with IP 198.51.100.23
DKIM: 'PASS' with domain amazonses.com
DMARC: 'FAIL'
And the full header:
Authentication-Results: mx.google.com;
dkim=pass header.i=@amazonses.com header.s=xplzuhjm7mmfhvdojhgc3xoveuvgrxh3;
spf=pass (google.com: domain of 010001970abc...@amazonses.com designates 198.51.100.23 as permitted sender) smtp.mailfrom=010001970abc...@amazonses.com;
dmarc=fail (p=QUARANTINE sp=QUARANTINE dis=QUARANTINE) header.from=example.com
Reproducing it
Any message from the app to a Gmail address reproduced it. We used a test account and a single command from a Rails console:
bin/rails runner 'UserMailer.password_reset(User.find_by!(email: "qa@example.net")).deliver_now'
Every copy showed the same pair: SPF and DKIM passing, DMARC failing.
What we ruled out
- Content. We sent a reset email with the body reduced to one sentence and no links. It still
went to spam with
dmarc=fail, so this was not a content filter. - IP reputation. The sending IPs belonged to our provider's shared pool and were not on public blocklists. Messages from the same IPs signed for other customers' domains were landing fine.
- Missing PTR or TLS. Both were handled by the provider and visible as valid in the headers.
Root cause
Two changes had collided. In March we had moved transactional email from an old self-hosted Postfix
to Amazon SES, and the engineer doing the migration verified only the address
no-reply@example.com as an SES identity, not the domain. SES therefore signed our mail with its
own DKIM key (d=amazonses.com) and used its default amazonses.com MAIL FROM domain. Both checks
passed, but for SES's domain, not ours. Then in early May our IT team changed our DMARC record from
p=none to p=quarantine as part of a security review. From that moment, receivers acted on the
alignment failure that had been there since March, and quarantined our transactional mail.
What is happening under the hood
DMARC does not ask "did SPF or DKIM pass?" It asks "did SPF or DKIM pass for a domain that matches the visible From: header?"
| Check | What it authenticates | Our value | Aligned with From: example.com? |
|---|---|---|---|
| SPF | Envelope sender (MAIL FROM / Return-Path) | amazonses.com subdomain |
No |
| DKIM | d= domain in the signature |
amazonses.com |
No |
| DMARC | From: domain, via aligned SPF or DKIM | example.com |
Fail, policy quarantine |
Relaxed alignment (the default) accepts subdomains of the same organisational domain, so
bounce.example.com aligns with example.com. amazonses.com never will.
The large mailbox providers also tightened their rules recently. Gmail's sender guidelines, enforced since February 2024, require senders of more than 5,000 messages a day to Gmail to have SPF, DKIM and DMARC with alignment; Yahoo published equivalent requirements; and Microsoft's similar requirements for Outlook.com consumer mailboxes took effect on 5 May 2025. Our domain crossed the 5,000-per-day threshold once marketing newsletters (sent through a separate platform) were counted, so the stricter tier applied to all mail with our From: domain, transactional included.
Environment
| Component | Version / value |
|---|---|
| App | Rails 8.0, Action Mailer over SES SMTP interface |
| Email provider | Amazon SES, eu-west-1 |
| SES identity | Email address no-reply@example.com only (no domain identity) |
| DMARC record | v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com |
| Root SPF | v=spf1 include:_spf.google.com include:_spf.newsletter-platform.example.net include:amazonses.com ~all |
| Volume | About 3,500 transactional plus 9,000 newsletter messages per day |
Minimal reproduction
Nothing in the application code was wrong; the bug lived in DNS and in the provider configuration. The quickest way to see it is to query the records receivers use:
dig +short TXT _dmarc.example.com
# "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
dig +short CNAME abc123._domainkey.example.com
# (empty: no DKIM key published for our own domain)
Who is affected
Anyone sending through an email service provider without domain-level DKIM and a custom
return-path, which is the default state of most providers until you finish setup. It is invisible
while DMARC is p=none and hits the moment the policy tightens, or when a mailbox provider starts
enforcing bulk-sender rules. Adding include:amazonses.com to the root SPF record, as someone had
done, does not help: SPF is evaluated against the MAIL FROM domain, which was SES's, not ours.
The fix
We fixed alignment for both mechanisms, so that one can fail (for example after forwarding) without DMARC failing.
1. Verify the domain in SES with Easy DKIM. SES generated three CNAME records, which we published:
tok1._domainkey.example.com. CNAME tok1.dkim.amazonses.com.
tok2._domainkey.example.com. CNAME tok2.dkim.amazonses.com.
tok3._domainkey.example.com. CNAME tok3.dkim.amazonses.com.
(The real token names are long random strings shown in the SES console.) Once verified, SES signs
with d=example.com, which aligns.
2. Set a custom MAIL FROM subdomain. We chose bounce.example.com, used for nothing else, and
published the two records SES asks for:
bounce.example.com. MX 10 feedback-smtp.eu-west-1.amazonses.com.
bounce.example.com. TXT "v=spf1 include:amazonses.com ~all"
We set Behavior on MX failure to Reject message so a broken DNS change fails loudly instead of silently falling back to the unaligned default.
3. Clean up the root SPF record. With SES using bounce.example.com, the root record no
longer needs the SES include, which also took us further from SPF's 10-DNS-lookup limit:
-v=spf1 include:_spf.google.com include:_spf.newsletter-platform.example.net include:amazonses.com ~all
+v=spf1 include:_spf.google.com include:_spf.newsletter-platform.example.net ~all
4. Separate transactional and marketing streams. We moved newsletters to news.example.com
with its own DKIM key on the marketing platform, and added RFC 8058 one-click unsubscribe headers
there. Gmail requires one-click unsubscribe for marketing and subscribed messages, not for password
resets, but splitting the streams means a bad newsletter week cannot drag down the reputation of the
domain that sends resets.
List-Unsubscribe: <https://news.example.com/u/7f3c1e>, <mailto:unsubscribe@news.example.com?subject=7f3c1e>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Alternatives and trade-offs
- Revert DMARC to
p=none. That makes mail deliverable again by removing protection against spoofing of our domain, and it does not satisfy bulk-sender requirements that expect alignment. We keptquarantine. - DKIM alignment only. DMARC passes with either mechanism, so DKIM alone is enough on paper. We did both because DKIM signatures can break when intermediaries modify messages, and SPF alignment is the fallback.
- Send resets from a dedicated subdomain.
From: no-reply@account.example.comisolates reputation even further. We may do it later; it needs its own DKIM and MAIL FROM setup.
How to test the fix
DNS first, then real messages:
dig +short TXT bounce.example.com # "v=spf1 include:amazonses.com ~all"
dig +short MX bounce.example.com # 10 feedback-smtp.eu-west-1.amazonses.com.
aws sesv2 get-email-identity --email-identity example.com \
--query '{dkim:DkimAttributes.Status, mailFrom:MailFromAttributes.MailFromDomainStatus}'
# {"dkim": "SUCCESS", "mailFrom": "SUCCESS"}
Then we sent a reset to Gmail, Outlook.com and Yahoo test accounts and checked headers in each. The Gmail result we wanted:
Authentication-Results: mx.google.com;
dkim=pass header.i=@example.com header.s=tok1;
spf=pass (google.com: domain of 01000197...@bounce.example.com designates 198.51.100.23 as permitted sender) smtp.mailfrom=01000197...@bounce.example.com;
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=example.com
Confirming in production
- DMARC aggregate reports (the
ruaaddress) for the following week showed SES-sent mail at 99.8 percent DMARC pass, up from 0 percent. The remaining failures were forwarded messages. - Google Postmaster Tools showed authentication at 100 percent for DKIM and SPF on
example.comand the user-reported spam rate for the domain under 0.05 percent. - "Did not receive reset email" tickets dropped from 37 in the bad week to 2 the week after, and the reset completion rate (link clicked within one hour) went from 58 to 91 percent.
Regression guard
- A daily job checks the SES identity status and the DMARC, DKIM and MAIL FROM records, and alerts if any differs from the expected value.
- A synthetic monitor sends a reset email to a seeded Gmail test inbox every hour and parses
Authentication-Resultsthrough the Gmail API; anything other thandmarc=passpages us. - DNS for mail lives in our infrastructure repository, so a record change goes through code review instead of a registrar web console.
Security implications
The fix strengthens security. With aligned DKIM and SPF we can eventually move DMARC to p=reject,
which tells receivers to refuse spoofed mail claiming to be from our domain, an important control for
a product that sends password reset links. Keep DKIM private keys with the provider (Easy DKIM
rotates them for you), never paste them into tickets, and make sure reset links remain single-use and
short-lived regardless of which folder the email lands in.
Prevention checklist
- Verify the whole domain, not single addresses, when onboarding any email provider.
- Configure a custom MAIL FROM or return-path on a dedicated subdomain.
- Read DMARC aggregate reports for at least two weeks before tightening the policy.
- Keep transactional and marketing mail on separate subdomains.
- Monitor authentication results on real messages, not just DNS records.
References
- Google Workspace Admin Help: Email sender guidelines — https://support.google.com/a/answer/81126
- Google Workspace Admin Help: Gmail SMTP errors and codes — https://support.google.com/a/answer/3726730
- Yahoo Sender Hub: Best practices — https://senders.yahooinc.com/best-practices/
- Amazon SES Developer Guide: Complying with DMARC — https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-dmarc.html
- Amazon SES Developer Guide: Using a custom MAIL FROM domain — https://docs.aws.amazon.com/ses/latest/dg/mail-from.html
- RFC 7489: DMARC — https://www.rfc-editor.org/rfc/rfc7489
- RFC 8058: One-click unsubscribe — https://www.rfc-editor.org/rfc/rfc8058
Add a label
Embed this post
<iframe src="https://hibug.me/transactional-email-spam-spf-dkim-dmarc" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.