Networking & DNS Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · May 29, 2025 · 9 min read · 5 Views
Banner: password reset emails going to spam fixed with SPF, DKIM and DMARC alignment

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 kept quarantine.
  • 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.com isolates 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 rua address) 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.com and 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-Results through the Gmail API; anything other than dmarc=pass pages 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

Add a label

#email #dmarc #dkim #spf #amazon-ses #deliverability
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.