Reset email deliverability is a per-user question, not a percentage

Why password-reset mail goes missing: account-scoped suppression, accepted vs delivered, and dead addresses. A Python triage script over the Infrai email routes.

Pick the provider that can tell you, in one call, what happened to one specific message to one specific person. For password resets that’s the whole of deliverability — an aggregate rate of 99.2% is meaningless to the user who can’t get into their account, and it’s meaningless to the support engineer holding their ticket. Infrai’s email surface answers that question with two free reads, and this piece walks the three ways a reset quietly disappears.

Everything below assumes SPF, DKIM and DMARC are already published, because without them a reset email is both more likely to be filtered and trivially spoofable. What it adds is the layer above authentication: suppression, bounce classification, and the awkward case where the address itself has stopped existing.

Failure one: a list you forgot you had

The most common cause of “the reset email never arrives” isn’t spam filtering. It’s that the address is on your own suppression list, put there weeks ago by an unrelated message, and every send to it is dropped before it reaches a mail server.

Ask directly:

curl -s https://api.infrai.cc/v1/email/suppression/check/noreply@example.com \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
  "ok": true,
  "data": {
    "email": "noreply@example.com",
    "reason": "manual",
    "added_at": "2026-07-25T16:02:13.112374Z",
    "scope": "account",
    "attempt_count_blocked": 0,
    "suppressed": true
  }
}

Read scope carefully. It’s account, which means one list covers every message this key sends — product notifications, receipts and resets alike. A hard bounce from a newsletter blocks the reset that the same person requests three months later, and no amount of DKIM hygiene changes that.

Suppression modelInfraiAmazon SESPostmark
Default scopeaccount-wideaccount-level listper-server, split by stream
Separate transactional streamnovia configuration setsyes, built in
Read one addressGET /v1/email/suppression/check/{email}API callAPI call
Remove an entryDELETE /v1/email/suppression/delete/{email}API callAPI call

That first row is a genuine limitation of the Infrai surface. SES lets you scope suppression to a configuration set and Postmark separates transactional from broadcast streams outright, so a marketing bounce there can’t take your reset mail down with it. If your marketing volume is large and messy, that separation is worth buying.

The mitigation that works here is discipline rather than configuration: never send bulk mail on the key you use for account recovery, and screen before every reset send.

Failure two: accepted is not delivered

A send returning 202-shaped success means the platform took custody. It does not mean a mail server accepted the message, and it certainly doesn’t mean a human saw it.

Two routes answer two different questions. GET /v1/email/get/{id} gives you the current state of a message; GET /v1/email/event/list gives you the timeline behind that state, and it needs a message_id query parameter — call it bare and you get a 400 rather than an account feed.

curl -s "https://api.infrai.cc/v1/email/event/list?message_id=msg_Hd029dIYk7I6cdlWLbiRQal7" \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
  "ok": true,
  "data": {
    "items": [
      { "type": "sent", "at": "2026-07-26T01:11:52.181134Z", "recipient": "user@example.com", "message_id": "msg_Hd029dIYk7I6cdlWLbiRQal7", "meta": { "vendor_message_id": "91d93002-0128-4baf-ab2e-1ad52fddd91c" } },
      { "type": "queued", "at": "2026-07-26T01:11:52.170569Z", "recipient": "user@example.com", "message_id": "msg_Hd029dIYk7I6cdlWLbiRQal7", "meta": { "vendor": "resend" } }
    ],
    "next_cursor": null,
    "count": 2
  }
}

Newest first, one row per state change. A timeline that stops at sent a few minutes after the send is the normal picture; one that stops at queued for ten minutes points at the platform, not the recipient’s mail server.

Failure three: the address is gone

Hard bounces on a reset are different in kind from hard bounces on a campaign. A campaign bounce costs you one impression. A reset bounce means a paying customer has no route back into their account, because the only credential they can prove is an address that no longer accepts mail.

Decide the policy before it happens, not during the ticket.

The triage script

This is what a support engineer should be able to run with an email address and nothing else. Python 3.11 or newer, pip install requests:

import os
import sys
from urllib.parse import quote

import requests

BASE = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
    raise SystemExit("INFRAI_API_KEY is not set")
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})


def get(path, **params):
    response = SESSION.get(f"{BASE}{path}", params=params, timeout=10)
    payload = response.json()
    if not response.ok:
        error = payload.get("error", {})
        raise RuntimeError(f"{path} failed: {error.get('code')} {error.get('message')}")
    return payload["data"]


def triage(address, message_id=None):
    report = {"address": address}

    blocked = get(f"/v1/email/suppression/check/{quote(address)}")
    report["suppressed"] = blocked.get("suppressed", False)
    report["suppression_reason"] = blocked.get("reason")
    if report["suppressed"]:
        report["verdict"] = "blocked before send — clear the entry or use another channel"
        return report

    if message_id:
        message = get(f"/v1/email/get/{message_id}")
        events = get("/v1/email/event/list", message_id=message_id)
        report["state"] = message["state"]
        report["vendor"] = message["vendor"]
        report["timeline"] = [f"{e['type']} @ {e['at']}" for e in events["items"]]
        last = events["items"][0]["type"] if events["items"] else "none"
        report["verdict"] = {
            "bounced": "address rejected the message — treat as unreachable",
            "delivered": "accepted by the receiving server — check spam placement",
            "sent": "handed to the vendor, no receiving-server verdict yet",
            "queued": "not yet handed off — platform side",
        }.get(last, f"unrecognised terminal event: {last}")
    else:
        report["verdict"] = "no message id — search your own send ledger first"

    return report


if __name__ == "__main__":
    print(triage(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None))

Every call in it is free and rate-limited, so running it on every reset ticket costs nothing.

Where the bounce feeds back

A bounce should change your user row, not just a list somewhere. Map it: a hard bounce marks the address unverified and forces the user down the recovery path; a soft bounce is a retry; a complaint means stop sending anything non-essential. The suppression list carries the reason, so this is a lookup, not guesswork.

Removal is deliberate and one call:

curl -s -X DELETE https://api.infrai.cc/v1/email/suppression/delete/noreply@example.com \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"

Do that for a manual or unsubscribed entry when the user asks. Do it for a hard bounce only if you have a reason to believe the mailbox is back, because the next send will bounce again and hurt the reputation of the domain your other resets depend on.

Sender identity, briefly

Reset mail from a shared sending domain lands, but it reads like phishing to anyone paying attention. Your own domain is the fix, and on a standard Infrai account it’s gated: POST /v1/email/domain/verify answers 402 PRO_REQUIRED, and so does any send with a custom from, before any DNS lookup. Plan for the upgrade or plan to stay on the shared sender — those are the two honest options.

Postmark’s domain verification flow is the cleanest we’ve used if you want your own domain on day one with no tier conversation.

Cost, and what stays free

Reads dominate this workflow and reads are free. The suppression check, the message state, the event feed and the account listing are all billed at nothing and don’t touch the new-account trial. Only the send itself is metered: per email, at a catalogue rate of $0.000115 read on 2026-07-26, with $2 of credit on a new account. Rates fall over time — cuts and campaigns are normal — so get today’s number rather than trusting this line:

curl -s https://api.infrai.cc/v1/discovery \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  | jq '.capabilities[] | select(.id | startswith("email.")) | {id, free: .billing.free, unit: .billing.unit}'

And what you actually paid, per capability, is in your usage record:

curl -s https://api.infrai.cc/v1/account/usage \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  | jq '.data.breakdown[] | select(.key == "email.send")'

Divide cost by calls there and you have the rate that matters to your finance team, which is the metered one rather than the catalogue one.

The trade-off across this whole design: no webhooks means you never expose an inbound endpoint, and it also means you find out about a bounce when you next poll. For reset mail — where somebody is sitting on the page waiting — polling one message on demand is the right shape. For a 200,000-recipient campaign it isn’t, and you’d be better off with a provider that pushes events to you.

References

Browse more email developer guides