İçeriğe geç
wedevit

September 22, 2026 · 8 min read · software

İlhan Buğra Aslan

Password reset emails never arrive: why transactional mail lands in spam


A user clicks "forgot password", the screen says "email sent", and nothing shows up. A temperamental spam filter is almost never the explanation. It is one of three things: your application never actually sent the message, the receiving server rejected it because your sending identity could not be verified, or your sender reputation is poor enough that the message went to the junk folder. Telling those three apart starts with recording the outcome of every send inside your product, because for most teams "sent" currently means "the SMTP call did not throw", and those are not the same statement.

The rules here got written down over the past two years. On February 1, 2024, Google and Yahoo started requiring senders of 5,000 or more messages a day to personal accounts to have SPF, DKIM and an aligned DMARC record, to connect over TLS, to publish valid forward and reverse DNS (PTR) records, to format messages per RFC 5322, and to keep the spam rate shown in Postmaster Tools below 0.30%. Microsoft adopted the same threshold and turned its rules on for Outlook.com, Hotmail and Live on May 5, 2025; non-compliant senders now get bounced with 550 5.7.515 Access denied, sending domain [yourdomain] does not meet the required authentication level. Google escalated too: from November 2025 the temporary 421 deferrals for non-compliant traffic started turning into permanent 550 rejections. You may well be nowhere near 5,000 messages a day, but the signals a receiving server weighs are the same signals.

What "sent" does and does not mean

An email passes four distinct checkpoints, and teams routinely collapse all four into a single word. First, your application composes the message and puts it on a queue. Second, your email provider accepts it and hands back a message ID. Third, the receiving server accepts it; that is exactly what "delivered" means in your provider's dashboard, and it says only that the other side did not refuse the handoff. Fourth, the message actually appears in the inbox.

You cannot observe that fourth step directly. Postmaster Tools data and seed accounts are how you approximate it. What matters more is that if you cannot observe the first three either, a "the email never arrived" ticket has no path to resolution. Unless support has one screen to look at, that conversation ends in guesswork every single time.

Do not send mail from your own server

Standing up your own mail server on a cloud VM looks cheap until it meets the network. AWS, Azure and Google Cloud all block outbound port 25 by default; AWS will lift it if you file a request, Google Cloud does not lift it for VMs at all. Even without the block, a brand-new IP address has no reputation, and no reputation means suspicion by default.

The practical answer is to send transactional mail through an email API or SMTP relay: Amazon SES, Postmark, SendGrid, Mailgun, Brevo and Resend all serve this. One launch-day surprise is worth knowing about in advance. Amazon SES opens new accounts in sandbox mode, where you can only send to verified addresses, with a ceiling of 200 messages per 24 hours and 1 message per second. Moving to production requires an approved request, so it belongs on the plan weeks before go-live rather than on the morning of it.

The part of authentication everyone skips: alignment

How to set up SPF, DKIM and DMARC records, and how to climb from p=none to p=reject without breaking legitimate senders, is covered step by step in email spoofing and domain protection. The piece specific to application email, and the one that gets skipped, is alignment.

Every message carries two sender addresses: the From address the recipient sees, and the envelope MAIL FROM (the Return-Path, where bounces go). By default your provider puts its own domain in MAIL FROM, something like bounces+...@amazonses.com on SES. SPF then passes, but it passes for amazonses.com, which shares no organizational domain with your From address, so the SPF leg of DMARC fails alignment. That leaves DKIM as the only support, and if DKIM is not signed with your own domain either, DMARC fails outright.

Two steps fix it: have DKIM signed with your domain, and configure the custom MAIL FROM subdomain your provider offers. On SES that means adding an MX and an SPF record on that subdomain; in return, bounces come back to your own domain and the SPF leg aligns naturally. Microsoft states its floor plainly: a DMARC record at minimum p=none, with at least one of SPF or DKIM passing in alignment.

Split transactional mail away from marketing

If campaign blasts and order confirmations leave from the same domain, one bad campaign drags your password resets down with it. The standard fix is to split the streams onto separate subdomains, each with its own SPF, DKIM and DMARC setup, so the two reputations accumulate independently.

One misconception is worth clearing up: splitting onto a subdomain does not put you back under the 5,000 threshold. Google's own sender guidelines FAQ says the threshold is evaluated at the primary domain level with subdomains included, and that once you have been classified as a bulk sender, the classification does not expire. Subdomains buy you reputation isolation, not an exemption.

The second split is about content. Google requires one-click unsubscribe headers (RFC 8058) only for marketing and subscribed messages; receipts, order confirmations and password resets sit outside that requirement. Drop a promotional block into the footer of an invoice, though, and the message becomes a marketing message with every rule attached. Many jurisdictions draw the same line for consent: a delivery notification is a service message, the discount code you attach to it is not.

Wire the feedback back into the product

Your provider emits an event for every message: accepted, delivered, bounced, complained, deferred. If you are not collecting those on a webhook endpoint, your product has no idea what happened to the mail it sent.

Three behaviours need to exist. Hard bounces go onto a suppression list permanently, because continuing to mail an address that does not exist is a direct hit to your reputation. Complaints suppress immediately, since the complaint rate is the most expensive number you own. Soft bounces (mailbox full, server busy) get a bounded number of retries and then stop. Keep that suppression list in your own database as well as at the provider, so you can show on screen why a given user is not receiving mail.

Consuming webhooks brings its own set of traps: signature verification, the same event arriving twice, events arriving out of order. You have met all of them on the payments side and the answers are identical, which we worked through in payment webhooks and reconciliation. If you want to be able to explain six months later why an address stopped receiving mail, writing these events alongside your audit trail is a habit that pays for itself.

Get sending off the request path

Sending email is a network call that can fail. If you send synchronously inside the signup handler, your signup form slows down whenever your provider does, and when the provider returns an error you end up with an account created and a welcome email that never existed. Queue the message, send it in the background, and retry only transient failures. If you retry, attach an idempotency key to every message, or a user receives the same invoice four times. The full mechanism is in background jobs and queues.

Time-sensitive mail adds one more wrinkle. Password resets and one-time codes are the least tolerant of delay, and greylisting on the receiving side can defer a first attempt by minutes on purpose. Set link expiry with that in mind, and consider a second channel for the flows that really cannot wait. Handing login itself to a managed identity provider also removes a chunk of this surface, a trade-off we weighed in build or buy authentication.

Treat the complaint rate as a budget

Google requires the spam rate reported in Postmaster Tools to stay under 0.30% and recommends aiming below 0.10%. Since June 2024, bulk senders above 0.30% are not eligible for mitigation when things go wrong. The number is smaller than it sounds: thirty complaints out of ten thousand messages is enough to cross the line.

On transactional mail, complaints almost always trace back to one thing. The recipient was not expecting the message. Somebody mistyped another person's address at signup, the sender name means nothing to them, or an unrelated notification turns up months later. Verifying the address at signup, keeping the sender name consistent with your brand, and giving users real notification preferences are the three cheapest ways to push that rate down.

Unglamorous rules that work

  • Include a plain text alternative. HTML-only bodies start at a disadvantage with filters. Send multipart/alternative with a text part.
  • No link shorteners. In a transactional message a shortened link reads as a spam signal. If you track clicks, use a tracking subdomain on your own domain rather than the provider's shared one.
  • Retire noreply@. Send from a monitored mailbox. You keep the users who reply, and you see the "it never arrived" reports first-hand.
  • Keep the sender name stable. If every module mails from a different name, recipients do not recognise you, and people report senders they do not recognise.
  • Keep seed accounts. One on Gmail, one on Outlook, one on a corporate domain. After any significant change, look at where your mail actually landed.

You cannot fix what you do not measure

Watch three sources together. Google Postmaster Tools shows your domain reputation, spam rate and authentication pass rate; it is free and a DNS record is all it takes to enrol. DMARC aggregate reports (rua) reveal who is really sending as your domain, and most teams discover a forgotten third-party tool in their first batch. Your provider's dashboard gives you bounce and complaint rates.

Putting those three on a dashboard is not enough on its own. They need thresholds and alerts. When the bounce rate doubles overnight, when a new integration starts writing to a bad address list, or when the authentication pass rate slips, the news should reach you from your monitoring rather than from a customer. The general approach to setting those thresholds is in observability, SLOs and error budgets.

The order to work in when someone says the mail never came

  1. Check your application record. Was the message composed, queued, attempted? No record means the problem is in your code, not in email.
  2. Look the message ID up at your provider. Accepted, bounced or deferred?
  3. If it bounced, read the code. 5.7.515 is an authentication problem, 5.1.1 means the address does not exist, anything starting 4.x.x is temporary and should have been retried.
  4. Accepted but not visible to the recipient points to filtering on their side: junk folder, corporate quarantine, domain-level block.
  5. Check whether the address is suppressed. It may have been silently muted by a bounce from months ago.

Run those five steps against your current system today. If you stall at step one, meaning you cannot answer "was the message even created" from a single screen, that gap comes before any deliverability work. At Wedevit we map your product's email flow end to end, set up the sending infrastructure and authentication alignment, and wire bounce and complaint events back into the application. In the same engagement we review your sending endpoints alongside the checks covered in API security. All of it is delivered remotely.


Need help with this topic?

get in touch →← all posts