DEV Community

Posting Dude
Posting Dude

Posted on

Your Password Reset Email Is Probably in Spam: Deliverability for a Tiny SaaS

The usual story goes like this. Onboarding emails get low opens and almost no replies, so you rewrite the subject lines. Then rewrite them again. Then one user mentions in a support thread, "btw your welcome email was in my spam, and the password reset too."

The copy was never the problem. The mail wasn't arriving.

If you run a small SaaS, this is easy to miss because nothing breaks loudly. Signups still happen. Stripe still charges. Your app logs say "email sent". Nobody tells you the welcome email, the magic link or the reset link went to spam, they just quietly give up and you read it as churn.

This is the setup I'd do on day one now. It's maybe an hour of DNS work and it's the most boring hour you'll spend on the product, but it protects every email that actually matters.

Why this got stricter

Since February 2024 Gmail has had written rules for anyone sending to personal Gmail accounts, and Yahoo rolled out similar ones at the same time. The short version from Google's sender guidelines:

  • Every sender needs SPF or DKIM set up for the sending domain, valid DNS for the sending IP, and TLS.
  • Keep your spam rate (as shown in Google Postmaster Tools) under 0.3%. Google actually says aim for under 0.1%.
  • If you send 5,000+ messages a day to Gmail, you need SPF and DKIM, a DMARC record (p=none is ok), a From domain that aligns with one of them, and one-click unsubscribe on marketing mail.

Most of us are nowhere near 5,000 a day. Doesn't matter. Do the bulk-sender version anyway. It costs nothing extra and you never have to think about it again when you grow.

The one-hour setup

1. Send from your own domain, not a free address. hello@yourapp.com, not yourapp.team@gmail.com. I'd also split it: one subdomain for product mail (mail.yourapp.com or whatever your provider suggests) and keep newsletters/marketing on a different one or a different provider. If your launch-week newsletter annoys people and gets marked spam, it shouldn't drag your password resets down with it.

2. Use a real transactional provider. Postmark, Resend, SES, Mailgun, SendGrid, whichever. Don't send from your app server with raw SMTP on a VPS IP. Shared IPs from a decent provider have a reputation you can't build alone at 40 emails a day.

3. SPF. One TXT record on the sending domain that lists who's allowed to send. The trap people hit all the time: you can only have one SPF record per domain. If you add a second one for your new provider instead of merging it into the first, both break. Also SPF has a 10 DNS lookup limit, so don't pile every tool you've ever tried into it.

4. DKIM. Your provider gives you a key (usually CNAME or TXT records). Paste them in, wait, hit "verify" in the provider dashboard. This is the one that most often stays half-done because the dashboard shows a yellow "pending" and you go back to coding.

5. DMARC. Start with something like this on _dmarc.yourapp.com:

Enter fullscreen mode Exit fullscreen mode

p=none means "don't block anything yet, just send me reports". Leave it a few weeks, read the reports (they're ugly XML, a free parser helps), check that only your real senders show up, then move to p=quarantine. I wouldn't jump to p=reject until you're sure every tool that sends as your domain (support desk, billing, CRM) is passing.

6. Alignment. This one confused me the most. DMARC passes only if the domain in your visible From address matches the domain that passed SPF or DKIM. If your From says yourapp.com but DKIM is signed by provider-mail.net, you pass DKIM and still fail DMARC. Most providers fix this once you verify your own domain, but check a real email's headers, don't trust the green tick.

How I actually test it

Send yourself a reset email to a personal Gmail address. Open it, three-dot menu, "Show original". You want to see SPF: PASS, DKIM: PASS and DMARC: PASS at the top. That takes two minutes and tells you more than any dashboard.

Then do the same with an Outlook address and an iCloud one if you can. A lot of B2B users sit on Microsoft 365, and Microsoft has its own stricter rules for high-volume senders now too.

Last thing, sign up for Google Postmaster Tools for your domain. At low volume it often shows nothing (it needs a minimum amount of traffic), and that's fine. When it starts showing data, you'll be glad you set it up early.

Stuff that quietly hurts deliverability

  • Sending the welcome email and a "how's it going?" email 20 minutes apart. New users mark things as spam when they feel spammed. Fewer, triggered emails beat a long drip. I wrote about why calendar drips don't work for small products, and it applies here too: every email nobody wanted is a spam-report risk for the ones they did.
  • Noreply addresses. Use a reply-to that goes to a real inbox. Replies are a good signal and you learn stuff from them.
  • Link shorteners and tracking domains you don't own. A shared tracking domain that some spammer also used is not your friend. Many providers let you set a custom tracking domain, or just turn click tracking off on transactional mail.
  • Marketing content inside transactional emails. A password reset with three promo banners is not a transactional email anymore, at least not to a spam filter.
  • No unsubscribe on anything that isn't strictly transactional. If it's a newsletter, product update or "we launched X" email, give one-click unsubscribe and honour it fast (Google says within 48 hours for bulk senders).

If you want a starting point for what the onboarding sequence itself should look like once the mail actually lands, here's the onboarding email setup I'd copy. Short, triggered by what the user did, and skips itself once they're activated.

The emails I'd check first

If you only have time to test a few today, check these, in this order:

  1. Password reset / magic link (if this fails, the user is locked out, full stop)
  2. Email verification on signup
  3. Payment receipts and failed-payment emails (money is on the line here)
  4. Welcome / first onboarding email

Everything else can wait a week.

What I'd do this week

  • Monday: add SPF, DKIM and DMARC (p=none) for the sending domain. Merge SPF, don't duplicate it.
  • Same day: send a reset email to Gmail and Outlook, check "Show original" for three passes.
  • Next week: read the first DMARC reports. Anything sending as your domain that you don't recognise? Find out what it is.
  • In a month: if reports look clean, move DMARC to p=quarantine.

None of this is clever. But I'd rather lose an hour to DNS than lose users who never got the link to log in. If you've had a weird deliverability issue that none of this covers, I'd like to hear it in the comments, especially the Outlook ones, those still confuse me.

Top comments (5)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

Subdomain separation is the point I'd underline for anyone running SES on a tiny SaaS — resets from the root domain share fate with every newsletter blast, and one spam-flagged campaign quietly throttles the mail users actually need. The silent-churn failure mode you describe is worse than a bounce because nothing errors: SES returns 200, the send 'succeeds,' and the user just never comes back. One thing I'd add to the one-hour setup from running SES in production: check the account-level suppression list before assuming DNS — suppressed addresses fail silently exactly the same way, and no SPF record fixes that.

Collapse
 
posting-dude profile image
Posting Dude •

The suppression list is a really good add, I didn't cover it and it fails in exactly the same quiet way. Worth checking before touching DNS at all. Do you review it on a schedule or only when someone says an email never showed up?

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

Honest answer: it used to be the second one — I learned the account-level suppression list exists the hard way, when a 200 OK still meant nothing arrived. Now it's the first thing I check, before DNS, because it's the cheapest check with the highest hit rate: a suppression-list lookup takes a minute and explains the exact failure mode where nothing bounces, nothing errors, and the email just never leaves AWS. I don't put it on a calendar though — I read it through the bounce and complaint event stream, so a new suppression entry shows up in the same dashboard as the sends. Have you tried querying it via the SES API instead of the console? The console hides the pattern; the API lets you watch it move.

Thread Thread
 
posting-dude profile image
Posting Dude •

reading it off the bounce and complaint stream so a new suppression shows up next to the sends is way better than a calendar reminder nobody keeps. fair point that the console hides the pattern, a list you can query over time tells you if it's one dead address or a whole domain going bad. do you treat complaint entries differently from hard bounce ones, like ever removing a bounce entry once the user fixes their inbox?

Thread Thread
 
robertpaschal profile image
Odinaka Robert Nnamani •

Short answer: treat them differently, and never delete — expire.

A complaint means a human clicked "report spam." That's a signal about your content or targeting, and the fix is changing what you send, not resuming sends to that address — so complaint entries are effectively permanent. A hard bounce is a routing fact: dead mailbox, dead domain. Safe to re-test, but on a timer, never on a user's say-so.

The mistake I see is one shared list with no reason attached: a complaint and a dead mailbox look identical, so you either resume sending to someone who reported you, or you keep suppressing an address that came back to life. The approach I'm taking in rootmail is a reason field on every entry — hard bounces become eligible for re-validation after a cooling period, complaints never do.

Since you're hand-rolling this ledger anyway: rootmail's beta is open at rootmail.io/beta — it's built for exactly this, per-message delivery state, bounce/complaint handling, per-reason suppression, retry-safe sending. Fair warning: it's early and invite-only, and it runs in the SES sandbox, so sending is limited to verified addresses while in beta. Want in?