An email that does not arrive is worse than a feature that does not work, because nobody reports it. The user assumes they typed the wrong address, tries again, gets nothing again, and leaves. Your logs show two successful sends.
This is the failure I find most often on projects I inherit, and it is almost never a code problem. The API call returned a 200 and the message was accepted for delivery. It was rejected or filed as junk somewhere between the provider and the inbox, for reasons that are entirely visible if you know where to look.
Email authentication is one of the eight things I set up on every build precisely because it is invisible when correct and silent when broken. Here is the whole of it, in the order it has to happen.
What actually decides whether mail is delivered?#
Three questions, asked by the receiving server in roughly this order, and your infrastructure only answers the first two.
| Question | Answered by | You control it? |
|---|---|---|
| Is this sender allowed to send as this domain? | SPF, DKIM, DMARC | Completely |
| Does this domain have a history of sending wanted mail? | Reputation | Over time |
| Does this specific recipient want it? | Engagement, complaints | Partly |
Authentication is a gate, not a boost. Passing all three checks does not get you into the inbox; failing any of them keeps you out of it. This is why "we set up SPF and it is still going to spam" is a coherent complaint and also a sign that the problem has moved to the second row.
Reputation is the row people underestimate. It attaches to the sending domain and the sending IP, it builds over weeks, and it is destroyed considerably faster than that. Everything in the second half of this post is about not destroying it.
How do SPF, DKIM and DMARC fit together?#
SPF authorizes servers. DKIM signs messages. DMARC ties either of them to the address the recipient sees, and tells the receiver what to do on failure. You need all three and they answer different questions.
SPF says which servers may send#
A TXT record listing the services allowed to send as your domain. It is checked against the envelope sender, not the visible From address, which is why SPF alone does not prevent somebody spoofing your brand.
; One SPF record per domain. Two records is a permanent fail, not a merge.
example.com. TXT "v=spf1 include:_spf.resend.com include:_spf.google.com ~all" The ten-lookup limit is the trap here. Every include costs a DNS lookup and the includes have their own includes; exceed ten and the whole record fails, taking previously working mail with it. A domain that accumulates a marketing tool, a CRM, a helpdesk and a transactional provider crosses it without anybody making a decision.
DKIM signs the message#
A cryptographic signature over the headers and body, verified against a public key in your DNS. Unlike SPF it survives forwarding, which matters more than it sounds — a message forwarded by a mailing list fails SPF at the second hop and passes DKIM.
Your provider generates the key pair and gives you records to publish. The only decision you make is the selector name, and the only mistake available is publishing the record on the wrong host — a resend._domainkey record placed at the apex instead of as a subdomain silently does nothing.
DMARC ties them to the visible address#
This is the one that makes the other two mean something. DMARC requires that a passing SPF or DKIM result belongs to the same domain the recipient sees in the From field — alignment — and publishes a policy for what to do when neither aligns.
; Start here. Monitor only, reports to an address you actually read.
_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com; pct=100"
; After a few weeks of clean reports, tighten in two steps.
_dmarc.example.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
_dmarc.example.com. TXT "v=DMARC1; p=reject; rua=mailto:dmarc@example.com" Going straight to p=reject is the mistake that takes a company's email down for a morning. There is always one service sending as your domain that nobody remembers — an invoicing tool, a scheduler, a form on an old site — and a reject policy finds it by discarding its mail. Monitor first, read the reports, then tighten.
Why does the subdomain matter so much?#
Because reputation is per sending domain, and you do not want the fate of your password resets tied to the performance of a newsletter.
If both go out from example.com, a marketing send with a poor list generates complaints, the domain's reputation drops, and the next password reset is filed as junk. The two kinds of mail have completely different engagement profiles and should not share a reputation.
| Subdomain | Carries | Why separate |
|---|---|---|
| mail.example.com | Password resets, receipts, invites | Must always arrive |
| news.example.com | Newsletters, product updates | Complaint-prone by nature |
| example.com (apex) | Nothing automated | Keep it clean; protect it with DMARC |
This costs one extra set of DNS records and it is close to impossible to retrofit cheaply, because moving your transactional mail to a new subdomain means starting its reputation from zero at exactly the moment you needed it to be good. Do it on day one.
The apex row is deliberate. Leaving the root domain with a strict DMARC policy and no legitimate senders means anybody spoofing your brand at the most obvious address gets rejected outright, and no real mail is at risk from that policy.
What breaks deliverability from inside the application?#
Four things, all of them code decisions rather than DNS ones, and all four are more common than a misconfigured record.
Sending from a no-reply address#
A no-reply@ sender cannot receive the replies that would otherwise be a positive engagement signal, and it trains people to ignore your mail. Use a real address that routes somewhere a human occasionally looks. Replies to transactional mail are rare, and the ones you get are usually a customer telling you something is broken.
Retrying a hard bounce#
A permanent bounce means the address does not exist. Retrying it — or worse, leaving it in a list that gets mailed weekly — is one of the strongest negative signals available. Process bounce webhooks, mark the address dead, and stop sending to it in the same transaction.
Not handling complaints#
A spam complaint arrives as a webhook. If you do not consume it, you keep sending to somebody who has explicitly told their provider your mail is unwanted, and the provider is watching whether you stop. This is the single fastest way to lose a sending reputation.
Sending on a schedule that looks automated#
Ten thousand identical messages in ninety seconds from a domain that sent forty yesterday is a volume pattern that looks like a compromised account. Ramp new domains gradually and spread bulk sends over hours rather than minutes.
How do you write the sending code?#
As a queued job with a template, an idempotency key and a stored record — not as an inline call in a request handler.
An email sent inline makes the request slow and couples it to a third party. When the provider has a bad minute, your signup endpoint has a bad minute, and a user who was successfully created sees an error and tries again.
// The request commits the work and enqueues the message. It does not send.
await db.$transaction(async (tx) => {
const user = await tx.user.create({ data });
await tx.outbox.create({
data: { kind: 'welcome', to: user.email, key: `welcome:${user.id}` },
});
}); The outbox table is the useful part. It gives the send the same durability as the record that triggered it, so a crash between the two is impossible, and the unique key on the row means a retried job cannot produce a second welcome email.
Templates belong in code, not in the provider#
A provider-hosted template is invisible to code review, untestable in CI, and changes in production with no deploy. Keep the markup in the repository, render it at send time, and use the provider purely as transport. This is the same argument as keeping content out of the pages themselves — the thing that can change silently is the thing that will.
Always send a plain-text alternative#
A multipart message with a text part scores better with filters and is the only version some clients show. Generating it from the same data as the HTML rather than writing it twice keeps them from drifting.
Log the provider message id#
When a customer says they never got it, the useful answer is a delivery status from the provider, and that requires having stored the id it returned. Without it you are relying on the customer to check a spam folder they have already checked.
How do you test email before it reaches a real inbox?#
With a catch-all in staging, an authentication check, and one real send to a seeded set of addresses before launch.
Never point staging at real addresses#
A staging environment with production data and a real email provider sends real mail to real customers, and it does it during a test run at 2am. Route everything in staging to a capture service, or to a single internal address, and make that the default rather than a setting somebody has to remember.
Check the headers, not the appearance#
Send to an address at each major provider and read the raw headers. Authentication-Results tells you exactly what SPF, DKIM and DMARC returned, and it is the only unambiguous answer available. A message that looks fine and shows dkim=fail is a message that will start being filtered as soon as volume rises.
Read the DMARC reports for a month#
The aggregate reports name every service sending as your domain, including the ones you forgot. Reading four weeks of them before tightening the policy is the difference between a clean transition and an outage nobody can immediately explain.
How do you warm a new domain?#
Gradually, and starting before you need it. A domain that has never sent mail has no reputation, and no reputation is treated closer to bad than to good.
The pattern that works is boring: send small volumes of genuinely wanted mail first, increase roughly by doubling every few days, and keep the early sends to addresses that will actually be opened. Your own team, your beta users, the people who signed up and are waiting — engagement from real recipients is what builds the record.
Start the domain before launch, not on launch day#
The worst possible schedule is publishing DNS records and immediately sending ten thousand invitations. Set the subdomain up two weeks early and let it carry the internal notifications, staging alerts and team invites in the meantime. By launch it has a small, clean history rather than none.
Do not import a list you did not collect#
A purchased or scraped list contains dead addresses and spam traps, and a spam trap hit on a new domain is close to unrecoverable. This applies to a list inherited from a previous system too — if nobody can say where the addresses came from, treat them as unverified and send a re-confirmation before anything else.
Watch the deferral rate, not just the bounce rate#
A deferral is the receiver saying "not right now", and a rising deferral rate is the earliest visible sign that you are ramping too fast. Bounces tell you about bad addresses; deferrals tell you about your reputation, and they show up days before anything lands in a junk folder.
Which provider should you use?#
Any of the main ones will deliver well. The decision is about the API and the operational surface, not about deliverability, because the domain reputation you build is yours rather than theirs.
| Factor | Why it matters |
|---|---|
| Webhook quality | Bounces and complaints must arrive reliably or the list rots |
| Shared vs dedicated IP | Dedicated needs volume to warm; shared is better below it |
| Suppression handling | A provider-side suppression list prevents a bad send from becoming a bad reputation |
| Log retention | Answering "did it arrive" three weeks later requires the log to still exist |
The shared-versus-dedicated question is the one people get backwards. A dedicated IP with low volume has no reputation and is treated with suspicion; a shared pool with other well-behaved senders is better below roughly a hundred thousand messages a month. Ask for dedicated when volume justifies it, not before.
I use Resend on most builds because the API is small enough to wrap in an afternoon and the webhooks are dependable, which are the two things that actually affect the code. That is a preference rather than a recommendation — the parts of this post that determine whether mail arrives are all on your side of the API.
What do you do when mail stops arriving?#
Work outward from the message, and resist the urge to change DNS first.
- Confirm the provider accepted it. The API response and the provider dashboard tell you whether the problem is before or after the handoff. Most reported failures never left your application.
- Read the delivery event. Delivered, bounced, deferred and complained are four different problems with four different fixes, and the provider knows which one happened.
- Check authentication on a real received message. Not on a checker tool — on headers from a message that actually arrived somewhere.
- Look at what changed. A new sending service, an added SPF include pushing past ten lookups, a marketing campaign last week, a DNS change during a domain migration.
There is one more thing worth checking before any of that, and it is embarrassing often enough to be worth the sentence: confirm the address the application actually sent to. A typo in a signup form, a trimmed string, or a user who genuinely mistyped their own address accounts for a meaningful share of reports, and it is the only cause that produces a delivery event you will never find because the message went somewhere else entirely.
That last step resolves more incidents than the other three combined. Deliverability rarely degrades on its own; it degrades because something was added, and the something is usually a marketing tool that somebody connected without mentioning it.
Deliverability is not a setting you get right once. It is a reputation, and reputations respond to behavior over weeks rather than to configuration in an afternoon.
What does this cost to set up properly?#
About two hours on a new project, and it is the cheapest insurance in the whole build.
That is: a sending subdomain, SPF and DKIM records from the provider, a DMARC record at p=none with reports going somewhere real, bounce and complaint webhooks wired to a suppression table, an outbox table, templates in the repository, and staging routed to a capture address. Two hours, once.
Retrofitting is harder in a specific way — not more work, but slower, because moving transactional mail to a fresh subdomain restarts its reputation and the ramp takes weeks. There is no way to buy your way through that period, which is the argument for the subdomain being a day-one decision rather than a later improvement.
Conclusion#
Send transactional mail from a dedicated subdomain, publish SPF and DKIM from your provider, and start DMARC at p=none with reports going to an address somebody reads. Tighten to quarantine and then reject only after the reports are clean for a few weeks.
Keep the sending subdomain separate from anything marketing touches, so a campaign with a poor list cannot take your password resets down with it. This is close to impossible to retrofit without a reputation gap, which is why it belongs in the first day of a build rather than the first incident.
In the application, queue the send through an outbox table with a unique key, keep templates in the repository rather than in the provider, generate a plain-text part from the same data, and store the provider message id so "did it arrive" has an answer. Consume bounce and complaint webhooks into a suppression list and treat that as non-optional.
Then verify by reading Authentication-Results on a message that actually arrived, not by trusting a checker. Two hours of work, and it removes the failure mode where a user cannot get into their account and never tells you. If you want the current setup checked before it matters, that is a quick piece of work with a very clear answer at the end.