Reset email never arrived? Check the suppression list before anything else

A send can succeed and deliver nothing. How to read suppressed_recipients, diagnose why an address was blocked, and decide whether removing it is safe.

A user can’t log in, asks for a password reset, and nothing arrives. Your logs show HTTP 200 and a message_id. Both facts are true at once, and the reconciliation is almost always the suppression list: the address hard-bounced or complained at some point, the provider recorded it, and every subsequent send to it is dropped before it reaches a mail server. On Infrai the send response tells you this directly, in a field most integrations never read.

That field is suppressed_recipients, and reading it usually turns a two-day support thread into an answer you can give in 60 seconds.

A successful send that delivered nothing

curl -s -X POST https://api.infrai.cc/v1/email/send \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "from": "accounts@example.com",
    "subject": "Set a new password",
    "html": "<p>Choose a new password using the link below.</p>"
  }'
{
  "ok": true,
  "data": {
    "message_id": "msg_Ln5Rk8dTv2WqYc3ZhBpX7uJf",
    "from_used": "accounts@example.com",
    "mode": "live",
    "accepted_recipients": [],
    "suppressed_recipients": ["user@example.com"]
  }
}

ok: true with an empty accepted_recipients is the shape to alarm on. If your integration only checks the HTTP status, this looks identical to a delivered message — so assert on the arrays, not on the status line, and log a distinct event when suppressed_recipients is non-empty.

Ask the list directly

The check route takes the address in the path and needs no request body. It’s free, and it’s the first thing a support engineer should run.

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

An address that was never suppressed comes back as a much shorter object — just the address and suppressed: false — so branch on that boolean rather than on the presence of reason.

attempt_count_blocked is the quietly useful one. A high number means your application has been cheerfully sending into a wall for weeks, which is both a wasted spend and a signal that nothing in your code is reading the response.

Why the address is on the list

The reason decides whether removal is defensible. These are not interchangeable, and treating them as one bucket is how a domain’s reputation gets wrecked.

reasonWhat happenedSafe to remove?
hard_bounceReceiving server said the mailbox doesn’t existOnly after the user proves the address works
complainedRecipient hit “report spam”No — removing it invites a repeat complaint
unsubscribedRecipient opted outNot for marketing; transactional needs a policy call
manualSomeone added it deliberately, often during testingYes, if you know who added it and why

Complaint rate is the metric that gets a sender throttled or suspended, and mailbox providers count a complaint far more heavily than a bounce (Gmail’s published bulk-sender threshold has sat at 0.3% since the rules took effect, and it hasn’t loosened in 2026). An account whose complaint rate keeps climbing eventually meets EMAIL_REPUTATION_SUSPENDED, which is a much worse day than one blocked reset email.

To see the whole picture at once — including entries added by a teammate during a test run — read the list:

curl -s https://api.infrai.cc/v1/email/suppression/list \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
  "ok": true,
  "data": {
    "items": [
      { "email": "user@example.com", "reason": "manual", "added_at": "2026-07-04T17:02:22.803322Z", "scope": "account", "attempt_count_blocked": 0 },
      { "email": "unsub-probe@example.com", "reason": "unsubscribed", "added_at": "2026-06-29T09:54:38.768897Z", "scope": "account", "attempt_count_blocked": 0 }
    ],
    "count": 2,
    "next_cursor": null
  }
}

Removing an entry, deliberately

Removal is one call, and it’s free like the rest of the management surface:

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

Do it for a typo the user has since corrected, for a mailbox that was full and has been emptied, or for a test address you suppressed yourself. Don’t do it in a loop, don’t do it as a nightly cleanup job, and don’t do it for a complained entry — Mailgun’s help centre documents the same reasoning for its “previously bounced address” behaviour, and every serious provider guards the list for the same reason. Re-suppressing an address is POST /v1/email/suppression/add, which is worth knowing when a support agent removes one by mistake.

One script your support team can run

The triage below answers “why didn’t this person get their reset mail” in a single command. It checks suppression first, then falls back to the message archive and the event timeline, because a non-suppressed address that still never arrived is a different problem entirely.

// diagnose-recipient.mjs — Node 22, no dependencies
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const address = process.argv[2];
if (!address) throw new Error("usage: node diagnose-recipient.mjs <email>");
const headers = { authorization: `Bearer ${KEY}` };

async function get(path) {
  const res = await fetch(`${API}${path}`, { headers });
  const payload = await res.json().catch(() => ({}));
  if (res.status === 404) return null;
  if (!res.ok || payload.ok === false) {
    throw new Error(payload.error?.code ?? `HTTP_${res.status}`);
  }
  return payload.data;
}

const blocked = await get(`/v1/email/suppression/check/${encodeURIComponent(address)}`);
if (blocked?.suppressed) {
  console.log(`SUPPRESSED reason=${blocked.reason} since=${blocked.added_at} blocked=${blocked.attempt_count_blocked}`);
  console.log(blocked.reason === "complained"
    ? "do not remove; contact the user on another channel"
    : "removal may be appropriate once the address is confirmed good");
  process.exit(0);
}

const archive = await get("/v1/email/list?limit=100");
const mine = (archive?.items ?? []).filter((m) => m.to === address);
if (!mine.length) {
  console.log("NOT SUPPRESSED and no message was ever sent to this address — check your own send path");
  process.exit(0);
}

const latest = mine[0];
const events = await get(`/v1/email/event/list?message_id=${encodeURIComponent(latest.message_id)}`);
const timeline = (events?.items ?? []).map((e) => `${e.at} ${e.type}`).join("\n  ");
console.log(`last message ${latest.message_id} state=${latest.state} vendor=${latest.vendor}`);
console.log(`  ${timeline || "no events recorded yet"}`);

Run it with INFRAI_API_KEY=your_infrai_api_key node diagnose-recipient.mjs user@example.com. If the timeline ends at sent with nothing after it, the message left the building and the receiving side filtered it — that’s a DMARC alignment conversation, not a suppression one.

What the user should see

Don’t tell the browser any of this. A reset endpoint that says “that address is blocked” tells an attacker the account exists, and one that says “sent!” when nothing was sent leaves a real user stuck in a loop.

The workable middle is a neutral confirmation on screen, an internal alert when suppressed_recipients is non-empty, and a support path that reaches the user another way. If a locked-out user’s only recovery channel is an address that hard-bounced, no email vendor can rescue you — that’s an account-recovery design limitation, and it’s worth a secondary factor or a support-verified path.

Where other tools do this better

If suppression management is a daily workflow for your team rather than an occasional support task, a specialist dashboard beats an API. Postmark and SendGrid both offer suppression views a non-engineer can operate, with bounce reason strings surfaced in the UI, and if that’s what your support team needs you’d be better off there. Infrai’s suppression surface is API-first and free, which suits teams that want the check wired into their own admin tool — the same key already reaches the queue behind the reset job, the error tracker that catches its failures and the usage view that prices it, so the diagnosis lives next to everything else rather than in a fourth vendor’s console.

References

Browse more email developer guides