Groundmail

Blog

Password reset email not received: a debugging checklist

When users don't get password reset emails, check whether it was sent, bounced or suppressed, landed in spam, failed authentication, or had its link used by a scanner.

By Groundmail6 min read

On this page

When a user says they didn't get a password reset email, find out which of five things happened: your app never sent it, your email provider refused or suppressed it, the receiving server bounced it, it was delivered to spam or quarantine, or it arrived but the link no longer worked. Each leaves a different trace in your logs and your provider's events, so check them in that order instead of guessing.

The checklist

  1. Was a send attempted? Find the request in your app logs for that user and time.
  2. Did the provider accept it? Look for a message ID, or an error code, in the API response.
  3. Was the recipient suppressed? An earlier hard bounce or spam complaint blocks future sends to that address.
  4. Did it bounce? Read the bounce reason from the receiving server.
  5. Was it delivered? A delivered event means the receiving server accepted it. After that, it's spam folder, quarantine or filters.
  6. Does it pass SPF, DKIM and DMARC? Failing authentication sends resets to spam or gets them rejected.
  7. Did a link scanner use the token? "Link expired" on the first click usually means a scanner got there first.
  8. Is the template right? A broken link, a wrong host or a missing plain-text part all look like "never arrived" to a user.

1. Was it actually sent?

Start in your own code. Common culprits:

  • The address doesn't match an account. If your reset endpoint returns the same message whether or not the account exists (it should, to avoid revealing which emails are registered), a typo or a different address than the one the user signed up with silently sends nothing. Log the lookup result server-side.
  • The send is queued and the queue is stuck. If resets go through a background job, check the worker is running and the queue isn't backed up behind a bulk job.
  • The send threw and nobody noticed. Log the provider's error code and message on every failed send.
  • Rate limiting. Your own endpoint may throttle repeated reset requests, and the email provider has request-rate and daily limits. A 429 from the provider means nothing was sent.

2. Did the provider accept it, suppress it or bounce it?

A 200 response with a message ID means the provider accepted the message for delivery, not that it reached an inbox. Look up that ID to see what happened next.

Suppressed. Most providers stop sending to addresses that hard-bounced or marked a message as spam. If the user once mistyped their address, or their mailbox was full and later fixed, they can be stuck on the list. In Groundmail, a send to a suppressed address returns 422 with code: validation_error and the address in details.suppressed, and fires an email.suppressed webhook. Remove the entry once you've confirmed the address is good:

curl -X DELETE "https://api.groundmail.dev/suppressions?email=user@example.com" \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY"

Bounced. The bounce reason is the receiving server's own reply, and it usually says what's wrong:

Bounce text What it means
550 5.1.1 The email account that you tried to reach does not exist Wrong address. Ask the user to check it.
452 4.2.2 or 552 5.2.2 with an "over quota" or "out of storage" message Mailbox full. The user must free space.
550-5.7.26 This mail has been blocked because the sender is unauthenticated Gmail rejected it for failing SPF and DKIM. Fix your DNS.
550 5.7.515 Access denied, sending domain [example.com] does not meet the required authentication level Outlook.com rejected it for failing its authentication rules.
550 5.7.1 with a policy or spam message The receiver blocked the sender on reputation or policy.

The first two are about the address. The rest are about you, and they affect every user at that provider.

3. Delivered, but not in the inbox

If the receiving server accepted the message, it's in the spam folder, a quarantine, or filed by a rule.

  • Spam folder. Ask the user to check spam and, in Gmail, the Promotions and Updates tabs. If resets regularly land in spam, the cause is almost always authentication or reputation, not the wording. Our post on why emails go to spam covers the diagnosis.
  • Microsoft 365 and other corporate quarantines. Business mail systems can quarantine messages before they reach the mailbox. The user won't see it at all; their IT admin can find and release it in the quarantine, and allow-list your sending domain.
  • Inbox rules and filters. Users and admins sometimes have rules that move automated mail elsewhere.

To check what the receiver thought, have a teammate trigger a reset to a Gmail or Outlook address, open the message, view the original and paste the headers into the email header analyzer. Or trigger a reset to an address from the email spam test and get a full report on authentication, blocklists and content.

4. Authentication

Gmail, Yahoo and Outlook.com all expect SPF, DKIM and DMARC, with the From domain aligned. Check your sending domain with the deliverability checker. The usual problems:

This one looks like "never arrived" in support tickets but is really "arrived and didn't work". Corporate mail security tools often open links in incoming messages to check them for malware before the user sees the email. If opening your reset URL with a GET request consumes the token, the scanner uses it and the user gets "this link has expired".

The fix is in your app, not your email:

@app.get("/reset")
def show_reset_form(token: str):
    # Only check the token here. Do not mark it used.
    if not tokens.is_valid(token):
        return render("reset_expired.html")
    return render("reset_form.html", token=token)

@app.post("/reset")
def do_reset(token: str, new_password: str):
    # Consume the token only when the user submits the form.
    user = tokens.consume(token)
    if user is None:
        return render("reset_expired.html")
    user.set_password(new_password)
    return redirect("/login")

The same applies to magic links: open a page with a "Sign in" button and complete sign-in on the POST. Click tracking that rewrites links through a redirect domain can also trip scanners and filters, so keep it off for auth mail.

6. Template problems

  • The link points at localhost or a staging host because of a missing environment variable.
  • The token is URL-encoded twice, or truncated by a line break in the plain-text version.
  • There's no plain-text part, so some clients show an empty message.
  • The link uses a URL shortener, which filters treat with suspicion.

Send yourself a reset from production after every template change.

Make password resets reliable

  • Use a dedicated transactional stream. Send resets from a transactional subdomain like mail.example.com, separate from marketing and product mail, so a newsletter that draws complaints can't drag down your login emails.
  • Make sends idempotent. Retrying a timed-out request shouldn't send three resets. Pass an idempotency key derived from the token.
  • Monitor delivery. Subscribe to bounce, suppression and delivery events, and alert when the bounce rate for reset emails jumps.
  • Show support the timeline. Store the message ID next to the reset request so support can look up exactly what happened.

How Groundmail helps

Groundmail sends password resets on a transactional stream with its own IPs, separate from product email. Every send returns a msg_ ID you can look up (GET /emails/{id}) with its status and bounce reason, and webhooks for email.delivered, email.bounced and email.suppressed let you alert on problems. Sends accept an Idempotency-Key header, and click tracking is off unless you turn it on. The password reset email guide has send examples, and why password reset emails go to spam covers inbox placement in depth.

Frequently asked questions

Why do users not receive password reset emails?

The most common reasons are that the app never sent it (an error, a queue backlog or a typo in the address), the address is on the provider's suppression list after an earlier bounce, the message landed in spam because of authentication or reputation problems, or a corporate mail filter quarantined it.

Why does my reset link say expired or already used when the user clicks it?

Security scanners in corporate mail systems often open every link in an incoming message to check it. If your reset link is consumed on a GET request, the scanner uses it before the person does. Make the link open a page with a button that submits the reset, and only consume the token on that POST.

How long should a password reset email take to arrive?

Seconds. If your logs show the provider accepted the message and the user still saw nothing after a few minutes, check the delivery status and the receiving server's response rather than asking the user to wait longer.

Should password reset emails have an unsubscribe link?

No. Password resets are transactional mail the user asked for. Keep them free of marketing content and unsubscribe footers, and make sure an address that unsubscribed from marketing can still receive them.

Send from infrastructure we own.

Groundmail runs its own mail servers on its own IP space. Start free on the shared transactional pool, then move to dedicated IPs or a dedicated server without changing your code.