← All writing

Why does your app's email go to spam, and how do you fix it?

Almost always missing DNS records, not your code. What SPF, DKIM, and DMARC do, the order to set them up, and the From-address mistake that undoes all three.

Why does your app's email go to spam, and how do you fix it?

Because you are missing DNS records, not because your code is wrong. Three records decide whether your mail is trusted: SPF says which servers may send for your domain, DKIM signs each message cryptographically, and DMARC tells receiving servers what to do when the first two fail. Publish all three, make sure your From address is on the domain you signed with, and the problem usually disappears within a day or two.

The code is two lines. This is the other 95% of the work.

Why this matters more than it used to

Two things changed.

Large mailbox providers tightened their requirements, and messages from domains with no authentication now get treated with more suspicion than they used to. And if your product uses passwordless sign-in, your email is not marketing, it is your front door. Mail in spam means nobody can log in, and there is no error anywhere to tell you.

The three records

SPF: which servers may send

A DNS record listing the servers allowed to send mail for your domain. A receiver checks the sending server against that list.

Your email provider tells you what to publish. It usually looks like:

v=spf1 include:_spf.yourprovider.com ~all

Two things people get wrong:

  • Only one SPF record per domain. Two records is not "extra coverage", it is a syntax error, and it fails both. If you already have one, merge the new include: into it.
  • There is a lookup limit. SPF permits ten DNS lookups, and each include: counts. Stack up enough providers and it silently starts failing.

DKIM: a signature on every message

Your provider signs outgoing mail with a private key. You publish the matching public key in DNS. The receiver verifies the signature and knows the message was not altered and really came from you.

Your provider generates this and gives you the record to publish. There is not much to get wrong beyond pasting it correctly, and it is the strongest of the three signals.

DMARC: what to do when the first two fail

This is the one people skip, because mail appears to work without it, and it is increasingly the one that decides whether you are trusted.

DMARC does two jobs: it tells receivers your policy for failures, and it makes them check alignment, which is the part that actually catches spoofing.

Start here, on day one:

v=DMARC1; p=none; rua=mailto:[email protected]

p=none means "do not change anything, just send me reports". It is safe to publish immediately and it starts giving you data about who is sending as you.

Once the reports look clean, tighten it:

v=DMARC1; p=quarantine; rua=mailto:[email protected]

Then eventually p=reject. Do not start at reject. If something legitimate is misconfigured, you will stop your own mail, and the first thing you will notice is customers unable to sign in.

The mistake that undoes all three

Here is the one that catches people who did everything else right.

You verify yourdomain.com with your email provider. DKIM signs as yourdomain.com. Then you set your From address to [email protected], perhaps because that is where you read replies.

Now DKIM signs one domain while the From header claims another. Alignment fails, DMARC fails, and your carefully configured setup is worse than useless, because you are now producing exactly the signature-mismatch pattern that spoofing filters exist to catch.

The rule: the From address must be on the domain you authenticated.

If you want replies somewhere else, forward the address at your provider. Do not solve it with a Reply-To on a different domain, and do not solve it by changing the From.

The order to do it in

  1. Pick a provider and verify your domain. They will give you the SPF and DKIM records.
  2. Publish SPF and DKIM. Wait for propagation, which is usually minutes but can be longer.
  3. Publish DMARC at p=none with a reporting address. Do this on day one; it costs nothing and starts collecting evidence.
  4. Send a test message and read the headers. Check that SPF, DKIM, and DMARC all show as passing. Do not skip this and assume; the whole point is that failure is silent.
  5. Watch the reports for a couple of weeks.
  6. Tighten to p=quarantine, then later to p=reject.

The code part, briefly

Almost trivial by comparison, but three things are worth getting right.

Send both HTML and plain text. A message with no plain text alternative looks worse to filters, and some clients genuinely prefer it.

Send outside the web request. A slow mail provider should never slow down a page. Queue the message and deliver it in a background job. This is also what makes retries possible when the provider has a bad minute.

Do not send real mail in development. Every developer has sent a test message to a real customer. Use a local preview that opens the message in a browser instead of delivering it, so nothing can escape from a laptop.

Transactional and marketing mail should be separate

Worth knowing before it hurts.

Your sign-in codes and receipts are transactional. Your newsletter is marketing. If they share a sending domain and enough people mark the newsletter as spam, the reputation damage takes your sign-in codes down with it.

The usual fix is a subdomain for marketing, keeping the reputation of your transactional mail separate from anything anyone might complain about.

The checklist

  • One SPF record, within the lookup limit.
  • DKIM published and verified.
  • DMARC published, starting at p=none, tightened once the reports are clean.
  • From address on the domain you signed with. No exceptions.
  • HTML and plain text in every message.
  • Delivery in a background job.
  • Local preview in development, so nothing real is ever sent by accident.
  • Marketing mail on a separate subdomain.

This is one of the four things worth not building yourself, and it is the clearest case of all four: no amount of better code fixes it, because the code was never the problem.

Keep reading