DEV Community

ConstantineHayes8524
ConstantineHayes8524

Posted on

Password Reset Email Not Delivered — Receipts Expose Spam Folder Causes

A password reset email not delivered to the inbox needs a control, not a reflexive provider swap or another spam-folder refresh. For a B2B SaaS product, use the order receipt sent after payment settles as that control: it travels through the same sender domain but has a different trigger and message shape. This narrows the fault domain quickly.

TL;DR: authenticate the sender domain, check SPF and DKIM alignment under DMARC, keep recovery copy strictly transactional, and correlate each application request with provider message events. If receipts arrive while resets do not, inspect suppression, content, and the reset-specific trigger. If both fail, start with domain authentication and sender hygiene.

Choice Integration surface Delivery evidence Best fit here Main trade-off
Plain multi-service API REST Poll message and event state A small service that wants no email SDK dependency No webhook event push or SMTP relay
Postmark REST API or SMTP Webhooks and message activity Teams that want a deliberately transactional product Another provider-specific integration
SendGrid REST API or SMTP Event Webhook Teams already using its broader email tooling More surface area to configure and govern
Amazon SES AWS API or SMTP Event publishing through AWS services Workloads already operated inside AWS IAM and event plumbing add setup
Mailgun REST API or SMTP Webhooks and event retrieval Teams that want API and SMTP options Provider-specific event semantics remain

My recommendation is boring on purpose. Keep the current provider while the evidence still points to authentication, suppression, or message design. Change transport only when its event model or operational fit removes a real constraint. Infrai is a credible small-service option when a plain REST call, one key, and one bill matter more than SMTP or push events; there is no email SDK to install or client-library version to babysit. The one credential can also cover other backend capabilities, which removes extra secret rotation and invoice reconciliation from a small service. Its polling model means the application must own correlation and monitoring.

Why is a password reset email delivered to the spam folder?

The two messages prove different things. A delivered receipt shows that the payment workflow reached an email send and that at least one message from the domain was accepted. It does not prove that the password-reset path ran, that the recipient was absent from a suppression list, or that reset copy received the same filtering treatment.

Start with four timestamps: reset requested, send accepted, provider state last checked, and receipt accepted for the same recipient or domain. Add one opaque correlation ID generated before the send. Do not put the reset token, email address, or full provider response into ordinary logs. Security links are secrets.

The split is useful:

  • Neither receipt nor reset is accepted: inspect the shared sender-domain setup and provider credentials.
  • Receipt is accepted but reset has no message ID: inspect the application branch, queue, and request validation.
  • Both are accepted but only reset is filtered: inspect suppression state, authentication alignment, and content differences.
  • Both are accepted and later fail: compare provider event states and recipient-domain patterns before changing code.

This is a diagnostic cohort, not a delivery guarantee. Receipts can differ in volume, recipient mix, and wording. Still, the control is much stronger than asking a user to refresh a spam folder.

Start there.

Authentication is the first shared dependency

SPF says which systems may send for a domain. DKIM signs the message. DMARC evaluates alignment and tells receivers what policy the domain publishes. A provider dashboard showing a green check is useful, but the received message headers are the evidence that matters for a specific delivery.

Verify the sender domain before tuning copy. If DKIM verification repeatedly fails or reset mail consistently lands in spam, rotate DKIM as part of a controlled key change, then verify again. Avoid changing DNS, sender identity, template, and provider on the same day; that destroys the comparison you need.

Short mail wins here. A reset message needs a recognizable sender, a direct explanation, one expiring action link, and a note for the person who did not request it. Marketing slogans, cross-sell blocks, tracking-heavy layouts, and several competing links make a security transaction look like a campaign. Remove them.

SPF, DKIM, and DMARC are necessary signals, but none forces inbox placement. Mailbox providers still apply their own filtering. That boundary matters because it prevents an authenticated message in spam from being misdiagnosed as an application outage.

Correlate the trigger before blaming transport

I benchmark this workflow by time-to-first-explanation, not time-to-first-API-call. One structured record at the trigger boundary usually beats a large logging stack with no shared identifier. The following TypeScript polls one accepted message by its provider ID. It's deliberately narrow: one real route, explicit authentication, bounded retries, and errors that retain the response body.

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const apiBaseUrl = process.env.INFRAI_BASE_URL;
if (!apiBaseUrl) throw new Error("INFRAI_BASE_URL is required");

const wait = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

export async function getEmailState(
  messageId: string,
  attempt = 0,
): Promise<unknown> {
  const response = await fetch(
    new URL(`/v1/email/get/${encodeURIComponent(messageId)}`, apiBaseUrl),
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await wait(delayMs);
    return getEmailState(messageId, attempt + 1);
  }

  const body: unknown = await response.json();
  if (!response.ok) {
    throw new Error(
      `Email status failed (${response.status}): ${JSON.stringify(body)}`,
    );
  }

  return body;
}
Enter fullscreen mode Exit fullscreen mode

Join the returned state to the correlation ID recorded when the reset was requested. A provider accepting a request isn't the same as the mailbox accepting the message, so alerting should distinguish “no send attempted,” “send rejected,” and “accepted but not delivered.” Four retries cap the sample's 429 path; a production worker should persist its attempt state instead of holding a process open.

No transport fixes a missing trigger.

With a polling-only API, troubleshooting should join message and event state to these application logs. Without a by-tag aggregated cost/reporting API, tags can't replace your own correlation ledger. There are no email event webhooks in this option either. Poll with bounded backoff and store the last observed state; a tight loop is config bloat wearing an observability badge.

Two criteria matter more than the feature count

The first is evidence latency. Postmark and SendGrid document webhook delivery events. Mailgun documents webhooks and event retrieval. Amazon SES publishes sending events through AWS destinations. Those push paths can shorten the gap between a mailbox event and an application alert. A polling-only integration can still work, but the poll interval becomes part of the recovery objective.

The second is integration weight. A plain REST API keeps an SDK out of the dependency graph. In the multi-service option above, one key spans 295 routes across 20 modules under one bill. That matters only if the service also consumes other backend capabilities: one credential policy and one invoice reduce the configuration attached to the recovery workflow. Its public, self-describing discovery surface requires no key and every documented capability has runnable examples in 10 languages. Those are concrete ways to reduce setup friction for a CLI, a test harness, or a second service; none improves inbox placement.

Postmark, SendGrid, Amazon SES, and Mailgun offer SMTP paths as well as APIs. SMTP is valuable when replacing an existing relay with minimal application change. It is irrelevant to a service already built around an HTTP transport adapter, and a wide platform is not automatically better than a focused email product.

Count the glue. I would evaluate a candidate with three numbers in a test environment: minutes from reset request to observable terminal state, application modules touched, and configuration values introduced. These are proposed acceptance measurements, not claims about vendor performance. Run the same recipient-domain set and message body for every candidate. Otherwise the benchmark is theater.

When is the runner-up better?

Pick Postmark or SendGrid ahead of a polling-only REST option when push delivery events are a hard operational requirement and your team can process, authenticate, deduplicate, and retain webhooks. Pick Amazon SES when AWS-native identity and event infrastructure are already normal operating equipment. Pick Mailgun when keeping both SMTP and API integration choices open is important.

The reverse is clear. A compact service with an existing HTTP abstraction may prefer the plain REST shape because there is no SDK release cycle to track. That advantage disappears if the team must build a near-real-time cross-channel orchestrator: without webhook pushes, polling limits responsiveness.

Do not treat this route as a China-compliance shortcut. The Tencent domestic email vendor is pending, so the supported conclusion is limited to US/EU reset mail. Plan the fallback honestly too: the email side has no managed OTP interface, so an email-code fallback must be built in the application. SMS introduces its own controls, including business-layer geographic fencing and country-based spend breakers.

A final boundary: scheduled email has no cancellation route. That is a poor match for reset links, which should be generated on demand and expire under application policy anyway.

Further reading

Top comments (0)