groundmail

Guides

Why password reset emails go to spam (and how to fix it)

Why password reset emails land in spam or never arrive: authentication and alignment, new domains, shared IPs, link tracking, suppressions and corporate link scanners, with a fix checklist.

By GroundmailUpdated

Short answer

Password reset emails usually go to spam because the message isn't authenticated as your domain (SPF, DKIM and DMARC missing or not aligned with the From address), because the sending domain or IP has little or poor reputation, or because the links look risky: click-tracking redirects, URL shorteners or a domain that doesn't match the sender. Content is rarely the main cause.

Fix it in this order: publish SPF, DKIM and DMARC for the domain in your From address and confirm spf=pass dkim=pass dmarc=pass in a received message's headers; send from a domain your users recognise; link directly to your own domain with tracking off; and check that the address isn't on a suppression list. If corporate users report "expired" links, a security scanner is clicking them first.

On this page

First, find out what actually happened

"The reset email went to spam" covers three different failures, and they have different fixes. Before changing anything, find out which one you have.

What the user seesWhat happenedWhere to look
Email is in the spam or junk folderAccepted by the mailbox, then filteredAuthentication results, domain and IP reputation, links
Email never arrives anywhereNever sent, rejected, or quarantined by a corporate filterYour send logs, bounce and suppression events, the receiving admin's quarantine
Email arrives but the link says "expired"Something used the token before the user didLink scanners, prefetching, duplicate sends invalidating older tokens

A 200 from your email API means the message was accepted for delivery, not that it reached the inbox. Wire delivery, bounce and suppression webhooks into your logs so support can answer "did we send it, and what did the receiving server say?" without guessing. On Groundmail those are email.delivered, email.bounced, email.delivery_delayed and email.suppressed (webhooks docs).

Authentication and alignment

This is the most common cause and the first thing to check. Gmail, Outlook and Yahoo expect transactional mail to be authenticated, and Google and Yahoo now require SPF, DKIM and DMARC from bulk senders (sender requirements). Unauthenticated mail is filtered hard or rejected.

Passing SPF and DKIM isn't enough on its own. DMARC also needs them to align with the domain in the visible From address. Three different domains are involved in every message:

DomainWhere it livesChecked byAligns with From when
From domainThe From: header the user seesDMARC (the domain everything is compared to)-
Return-path (envelope sender)MAIL FROM in the SMTP session; becomes the Return-Path headerSPFIt is the From domain or a subdomain of it (relaxed alignment)
DKIM signing domainThe d= tag in the DKIM-Signature headerDKIMIt is the From domain or a subdomain of it (relaxed alignment)

The classic failure: the From address says no-reply@example.com, but the message is DKIM-signed with the email provider's own domain and the return path is the provider's bounce domain. SPF passes and DKIM passes, but neither aligns, so DMARC fails. Reading the headers is the only way to see this; the email itself looks fine.

The fix is to verify your own domain with your provider, so it signs with d=example.com and uses a return path under your domain. Full setup, with copy-paste records, is in SPF, DKIM and DMARC for transactional email.

Domain age, reputation and subdomains

Mailbox providers keep a reputation for your sending domain, built from the DKIM-signed mail that carries it. A domain registered last week has no history, and receivers treat unknown domains cautiously. If you launched on a new domain, expect some early filtering until real users open and act on your mail.

Sending transactional mail from a subdomain, such as mail.example.com or notify.example.com, gives it a reputation that is partly separate from the root domain. If your marketing team sends newsletters from example.com and someone's campaign draws complaints, your resets on mail.example.com are better insulated. Keep the subdomain for transactional mail only, and keep the From address recognisably yours: no-reply@mail.example.com is fine; an unrelated domain is not.

Shared IP reputation: who else is on the pool

Most providers send small senders' mail from shared IP pools. The IP reputation is the sum of everyone on the pool, so if the pool also carries cold outreach or bulk newsletters, someone else's complaint rate follows your resets. You can do everything right on your own domain and still inherit a bad neighbourhood.

Ask your provider what its shared IPs are allowed to carry. If the answer is "anything", that is worth knowing when resets land in spam with clean headers.

Content: plain beats clever

Content matters less than authentication and reputation, but a few habits help, and they also help the user act quickly.

  • A plain, specific subject. "Reset your Example password" beats "Action required!!" or "Urgent: account security". Avoid all caps, exclamation marks and urgency language.
  • Always send a text part alongside the HTML. HTML-only and image-only messages score worse, and some users read mail in clients that show text.
  • Show the URL as text as well as a button, so it works in clients that strip HTML.
  • A consistent From name and address. Use the same display name ("Example") and the same address every time. Changing them looks like a new, unknown sender.
  • Nothing else in the message. No marketing banners, promotions or newsletter footers. A reset email has one job.
  • Say what to do if they didn't ask, and how long the link lasts. It reduces support tickets and spam-button clicks from confused users.

Retries, duplicates and abuse of the reset form

How you send matters as much as what you send. Three patterns cause trouble:

  • Duplicate sends from retries. A job retries after a timeout and the user gets three identical emails, each with a different token, and only the last one works. Send with an idempotency key tied to the token, so a retry returns the original result instead of sending again.
  • Users hammering "resend". Throttle per address (for example, one reset email per minute) and reuse a still-valid token rather than issuing a new one each time. Bursts of identical messages to one address look automated.
  • Bots submitting the forgot-password form. Attackers use reset forms to probe for accounts or to send mail to addresses that don't exist. Rate-limit the endpoint, only send when an account actually exists (while showing the same response either way), and watch for bounce spikes. Bounces from junk addresses damage the reputation your real users' resets depend on.

Bounces and suppressions: the reset that was never sent

Email providers keep a suppression list of addresses that hard-bounced or complained, and refuse to send to them again. That protects your reputation, but it means a user whose address bounced once (a typo they later fixed at their mail host, a full mailbox misread as permanent, an old complaint) will never receive a reset, and nothing looks wrong in your app unless you check.

When a user says "I never got it", check the suppression list before anything else. On Groundmail, a send to a suppressed address returns 422 with the address in details.suppressed and emits an email.suppressed webhook, so you can log it and show support a clear reason. Look an address up with GET /suppressions?email=..., and once you've confirmed the address now works, remove it with DELETE /suppressions?email=....

Corporate filters and link scanners

B2B products hit a separate set of problems. Microsoft 365 with Defender, and other secure email gateways, can quarantine a message so it never reaches the junk folder at all: the user often sees nothing, and usually only their admin can release it. If one customer's whole domain reports missing resets, ask their IT team to check quarantine and, if needed, allowlist your sending domain. Fixed sending IPs make that conversation easier, which is one reason B2B senders move to dedicated IPs.

These gateways also scan links. Many open each URL in a message, at delivery or at click time, to check where it leads. If your reset link consumes the token on a GET, the scanner uses it up and the user gets "this link has expired".

Sending a reset with Groundmail

Groundmail is transactional email on our own mail servers and our own IP space, with a transactional-only shared pool on every plan and dedicated IPs or dedicated servers when you need isolation. Domains are verified with SPF, DKIM and a return-path CNAME under your domain, so mail is signed as you and aligns for DMARC. Open and click tracking are off by default on new domains; leave click tracking off on any domain that carries auth mail.

This sends a reset with a text part, a link straight to your own domain, and an idempotency key tied to the token so retries can't send duplicates:

Node

import { Groundmail, GroundmailError } from "groundmail";

const gm = new Groundmail(process.env.GROUNDMAIL_API_KEY);

export async function sendPasswordReset(email: string, tokenId: string, token: string) {
  const url = `https://example.com/reset?token=${token}`;
  try {
    return await gm.emails.send({
      from: { email: "no-reply@mail.example.com", name: "Example" },
      to: email,
      subject: "Reset your Example password",
      text: `Use this link to reset your password:\n\n${url}\n\nIt expires in 30 minutes. If you didn't ask for this, ignore this email.`,
      html: `<p>Use this link to reset your password:</p>
<p><a href="${url}">Reset password</a></p>
<p>Or paste this into your browser: ${url}</p>
<p>It expires in 30 minutes. If you didn't ask for this, ignore this email.</p>`,
      tags: [{ name: "type", value: "password_reset" }],
      idempotencyKey: `password-reset:${tokenId}`,
    });
  } catch (err) {
    if (err instanceof GroundmailError && err.status === 422) {
      // Recipient is suppressed: log it so support can see why.
      console.warn("reset not sent, suppressed", err.details);
      return null;
    }
    throw err;
  }
}

curl

curl -X POST https://api.groundmail.dev/emails \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: password-reset:tok_8f2c" \
  -d '{
    "from": { "email": "no-reply@mail.example.com", "name": "Example" },
    "to": "ada@example.org",
    "subject": "Reset your Example password",
    "text": "Use this link to reset your password: https://example.com/reset?token=...",
    "html": "<p><a href=\"https://example.com/reset?token=...\">Reset password</a></p>"
  }'

A retry with the same key and the same body returns the original response instead of sending a second email. If a domain had click tracking turned on, turn it off with PATCH /domains/{id} and {"click_tracking": false} (domains docs).

How to test inbox placement

  1. 1

    Send to real seed accounts

    Keep test inboxes at Gmail, Outlook.com and Yahoo, plus a Microsoft 365 mailbox if you sell to businesses. Trigger a real reset from your production flow, not a hand-built test message, so you test the exact headers and links users get.

  2. 2

    Read the Authentication-Results header

    In Gmail use "Show original"; in Outlook, view the message source. Look for spf=pass, dkim=pass and dmarc=pass, and check that header.from, the DKIM header.i or d= domain and smtp.mailfrom are all your domain or its subdomains. Microsoft also adds compauth=pass when its composite check passes.

  3. 3

    Check the links

    Hover every link in the received message. Each should point at your own domain, with no redirect hostname in front of it.

  4. 4

    Watch reputation over time

    Set up Google Postmaster Tools for your sending domain. Once you send enough to Gmail, it shows your spam rate and authentication results. Track bounces and complaints from your provider's webhooks alongside it.

What passing looks like (illustrative)

Authentication-Results: mx.google.com;
       dkim=pass header.i=@mail.example.com header.s=selector1;
       spf=pass smtp.mailfrom=rp.mail.example.com;
       dmarc=pass (p=NONE) header.from=mail.example.com

Diagnosis checklist

SymptomLikely causeFix
dmarc=fail with spf=pass and dkim=passSPF and DKIM pass for the provider's domain, not yoursVerify your own domain so DKIM signs d= your domain and the return path is under it
dkim=none or dkim=failDKIM record missing, truncated or on the wrong hostRe-check the selector TXT record; see SPF, DKIM and DMARC
spf=permerrorTwo SPF records, or more than 10 DNS lookupsMerge into one SPF record and trim includes
Spam at Gmail only, headers cleanNew domain or low domain reputationUse a transactional subdomain, keep sending steadily, check Postmaster Tools
Spam everywhere, headers cleanShared IP reputation or risky linksAsk what the pool carries; remove tracking and shorteners
Never arrives, no bounce, one company onlyCorporate quarantineAsk their admin to check quarantine and allowlist your domain or IPs
Never arrives for one user, API returned an errorAddress is suppressed after a bounce or complaintLook it up on the suppression list; remove once confirmed working
Link "expired" on first clickLink scanner consumed a single-use tokenConsume the token on POST from a confirm button, not on GET
User gets several resets, only the last worksRetries or repeated clicks issue new tokensIdempotency key per token; throttle and reuse live tokens
Arrives minutes lateQueueing in your app or provider, or receiver deferralsSend auth mail from a fast path, not a batch queue; check email.delivery_delayed events

Frequently asked questions

Why do my password reset emails go to spam?

Most often because the message isn't authenticated and aligned with the domain in the From address: SPF, DKIM and DMARC are missing, or they pass for your email provider's domain instead of yours. After that, the usual causes are a new or low-reputation sending domain, a shared IP pool that carries other kinds of mail, and links that go through tracking redirects or shorteners.

Why do password reset emails not arrive at all?

Check three things: whether your app actually sent it (logs and webhook events), whether the address is on your provider's suppression list after an earlier bounce or complaint, and, for business addresses, whether a corporate filter such as Microsoft 365 Defender quarantined it. Quarantined mail doesn't show in the junk folder, and usually only the recipient's admin can release it.

Why does my reset link say it has expired the first time the user clicks it?

A security scanner or link preview opened the URL before the user did, and your app consumed the token on that request. Make the link open a page with a button, and only use the token when the user submits it with a POST.

Should I use click tracking on password reset emails?

No. Click tracking rewrites the link through a redirect domain, which adds a domain filters have to judge on account-security mail, and it passes the token through another system. On Groundmail, click tracking is off by default on new domains.

Should password reset emails come from a subdomain?

It's a good idea. Sending transactional mail from a subdomain such as mail.example.com keeps its reputation partly separate from marketing mail on your root domain. Keep the subdomain under the brand users know, and authenticate it with SPF, DKIM and DMARC like any other sending domain.

How do I check whether my reset email passed SPF, DKIM and DMARC?

Send a real reset to a Gmail or Outlook account and open the raw message ("Show original" in Gmail, view source in Outlook). The Authentication-Results header should show spf=pass, dkim=pass and dmarc=pass, with your domain as header.from and in the DKIM and mailfrom domains.

Will a dedicated IP stop my reset emails going to spam?

Not on its own. A dedicated IP isolates your reputation from other senders, but it doesn't fix missing authentication, a new domain or risky links, and a low-volume dedicated IP can do worse than a well-run shared pool. Fix authentication first, then see shared vs dedicated IP.

Sources

Facts on this page about other companies' products, prices and policies come from these public pages. They change, so check them before you decide.

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.