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](/blog/sign-in-without-passwords), 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:dmarc@yourdomain.com
```

`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:dmarc@yourdomain.com
```

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 `hello@somethingelse.com`, 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](/blog/background-work-without-extra-services). 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](/blog/what-your-agent-should-not-build), and it is the clearest case of all four: no amount
of better code fixes it, because the code was never the problem.
