Transactional Email: Why Your App's Mail Lands in Spam and How to Fix It
Password resets are the one feature nobody thinks about until they fail. A user requests a reset, the mail never arrives, and you get a support ticket with no error on your side to debug. Nine times out of ten the code was fine; the mail was rejected or silently filed as spam. We've debugged enough of these to have a checklist, and it applies to nearly every web app that sends mail.
The DNS records that do most of the work
Mail servers judge your mail almost entirely by three DNS records. Skip any of them and deliverability is a coin flip.
- SPF is a TXT record on your domain listing which servers may send mail as you. If your app sends directly from the web server, add its IP. If you send through a provider, add their include, for example
include:amazonses.comorinclude:_spf.google.com. One domain gets exactly one SPF record, and it has a 10-DNS-lookup limit, so don't stack every provider you've ever tried. - DKIM is a cryptographic signature the sending server adds to each message, published as a TXT record. Every reputable provider generates this for you during setup. Turn it on; unsigned mail from your own domain is treated as suspect by default.
- DMARC is a policy record telling receivers what to do when SPF and DKIM fail, and where to send reports. Start with
p=noneplus an address you actually read, watch the reports for a week or two, then move top=quarantineonce you're sure nothing legitimate is failing.
A minimal setup for a domain sending through a provider:
; SPF: your provider plus a hard fail for everyone else
example.com. IN TXT "v=spf1 include:amazonses.com -all"
; DKIM: the selector and key come from your provider
res._domainkey.example.com. IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCS..."
; DMARC: monitor first, enforce later
_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
Without these records, Gmail and Outlook make the decision for you, and their default leans toward the spam folder.
Separate transactional from marketing
Password resets, invoices, and two-factor codes should never share a sending pool with newsletters. Marketing mail gets higher bounce and spam-complaint rates by nature, and providers apply that reputation to everything from the same source. If a campaign goes badly, your reset emails shouldn't go down with it.
Use a different subdomain for marketing (mail.example.com for app mail, news.example.com for campaigns) or, at minimum, a separate provider account. The app's mail domain then builds a small, clean reputation it can defend.
Handle bounces, or stop sending to dead addresses
Every hard bounce hurts your sender reputation. When an address doesn't exist, take it off the list the first time it bounces. Soft bounces (full mailbox, temporary server trouble) deserve a few retries across a couple of days, then removal.
If your volume is small, the provider's webhook plus a subscribers.bounced_at column is enough. What matters is that the loop exists. An app that keeps mailing a dead address list for months will find its inbox placement getting worse even for valid addresses.
The message itself still matters
Receivers score content too, though less than people assume. The parts that actually matter:
- A plain-text alternative alongside the HTML part. Mail with only an HTML body looks bulkier.
- A real
Fromname and a reply address on the same domain you authenticated. Agmail.comsender address on mail sent from your own servers is an automatic SPF mismatch. - Working unsubscribe links in anything that could be called bulk, and no unsubscribe in genuinely transactional mail (a reset link is not a newsletter).
- No image-only bodies, no URL shorteners, no subject lines in all caps. These patterns are shared with spam for good reason.
A short debugging path when mail vanishes
When a user says the email didn't arrive, work down this list before touching code:
- Check the provider's log or dashboard for the message ID. Most providers show the exact rejection reason.
- Look at the DMARC reports for your domain. Failures there show which record is missing or misaligned.
- Send to a fresh Gmail or Outlook address you control and read the "Show original" view. It spells out SPF, DKIM, and DMARC results line by line.
- Only then look at your app: is the job queued, did the queue worker die, is the from-address configurable and wrong in production?
Most "email is broken" tickets end at step 1 with a rejection reason that names the exact fix.
Set it up once
SPF, DKIM, and DMARC take an hour to configure and don't need maintenance until you change providers. Bounce handling is a few hours of work that protects every future campaign and every password reset. Both are cheap compared to the support load of a broken signup flow.