Two reset emails from one click: retry-safe sends after a timeout

A timeout on a password-reset send doesn't mean the mail never left. The token-as-ledger pattern, the reconciliation read, and what idempotency_key actually does today.

A reset send that times out has often already succeeded — you just didn’t get to see the response. Retry it blindly and the user gets two emails; worse, if your handler mints a fresh token per attempt, the link in the first one is dead before they click it. On Infrai the durable fix isn’t a provider flag. It’s making the reset token, not the HTTP call, the unit you deduplicate.

Three outcomes hide behind one ETIMEDOUT: the request never reached the API, it reached it and the mail went out, or it reached it and failed inside the vendor. The response you never received is the only thing that could have told them apart, so the design has to make all three converge on one user-visible result — exactly one live link, no matter how many times the button gets pressed.

No email API can settle that for you. Your database can.

Two identical emails are an annoyance. Two emails carrying different tokens, where issuing the second invalidated the first, is a support ticket: people click the older message because it’s the one at the top of their inbox on a phone, and the newer one is three notifications further down.

So invert the flow. Reuse the live token inside its own validity window and only mint a new one once the old one is spent or expired. The token row then doubles as the send ledger, and you don’t need a second dedupe table at all.

CREATE TABLE password_reset (
  user_id      bigint      NOT NULL,
  token_hash   text        PRIMARY KEY,
  issued_at    timestamptz NOT NULL DEFAULT now(),
  expires_at   timestamptz NOT NULL,
  consumed_at  timestamptz,
  send_state   text        NOT NULL DEFAULT 'claimed',
  message_id   text,
  attempts     int         NOT NULL DEFAULT 0
);

-- At most one live token per user. A second reset request inside the window
-- finds this row instead of creating a rival one.
CREATE UNIQUE INDEX password_reset_live
  ON password_reset (user_id)
  WHERE consumed_at IS NULL;

send_state starts at claimed, moves to sent when the API answers, and stays at claimed when the call times out. That third case is the whole point: an unresolved row is a question, not a failure.

What idempotency_key does — and doesn’t — do here

POST /v1/email/send accepts an idempotency_key, and it’s tempting to stop there. We tested it on 2026-07-26 by issuing the same body with the same key twice, two seconds apart:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "dana@example.com",
    "subject": "Reset your Kettle password",
    "html": "<p>Use this link within 30 minutes: <a href=\"https://kettle.example/r/6f1c9d2a\">choose a new password</a>.</p>",
    "idempotency_key": "reset:usr_4821:6f1c9d2a"
  }'

Both calls came back HTTP 200 with different message_id values and idempotent_replay: false in the envelope metadata, and both were metered:

{
  "ok": true,
  "data": {
    "message_id": "msg_h0Dw835DBSKZ4I1YaWBeNzCZ",
    "mode": "default_vendor",
    "from_used": "noreply+a1f9@send.infrai.cc",
    "accepted_recipients": ["dana@example.com"],
    "suppressed_recipients": []
  },
  "metadata": { "vendor": "resend", "idempotent_replay": false, "cost_funded_by": "paid_credit" }
}

Send the key anyway. It’s the right field, it makes your logs correlatable, and it costs nothing — but don’t build on it, because gateway-side collapsing of duplicate email sends isn’t live yet, and that’s a limitation you have to cover in your own code today. This is also the sort of thing worth re-testing before each release rather than trusting an article about it, including this one.

Note mode: "default_vendor" too. Omit from entirely and the message goes out as noreply+<tag>@send.infrai.cc with no DNS setup at all; supply a custom sender and a standard account gets HTTP 402 PRO_REQUIRED instead.

The sender: claim, call with a hard deadline, resolve later

Node 22’s fetch has no default timeout, so a stalled socket can hold a request handler open until the platform kills it. Twelve seconds is generous for an accept-and-queue API — in our testing the send route answered in roughly 700 ms, reads in under 100 ms.

// reset.mjs — Node 22 ESM. Requires: npm i pg
import { createHash, randomBytes } from "node:crypto";
import pg from "pg";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const sha = (s) => createHash("sha256").update(s).digest("hex");

/** Returns the live token for this user, or mints one. Never two at once. */
async function liveToken(userId) {
  const { rows } = await pool.query(
    `SELECT token_hash, send_state, message_id FROM password_reset
      WHERE user_id = $1 AND consumed_at IS NULL AND expires_at > now()`,
    [userId],
  );
  if (rows.length) return { ...rows[0], token: null, reused: true };

  const token = randomBytes(24).toString("base64url");
  await pool.query(
    `INSERT INTO password_reset (user_id, token_hash, expires_at)
     VALUES ($1, $2, now() + interval '30 minutes')`,
    [userId, sha(token)],
  );
  return { token_hash: sha(token), token, send_state: "claimed", reused: false };
}

export async function requestReset(userId, address) {
  const row = await liveToken(userId);
  // A reused token that already produced a message is a no-op: same link, no
  // second email, and the endpoint still answers 202 so it leaks nothing.
  if (row.reused && row.send_state === "sent") return { sent: false, reason: "cooldown" };

  const link = `https://kettle.example/r/${row.token ?? "resend"}`;
  let res;
  try {
    res = await fetch("https://api.infrai.cc/v1/email/send", {
      method: "POST",
      headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
      body: JSON.stringify({
        to: address,
        subject: "Reset your Kettle password",
        html: `<p>Use this link within 30 minutes: <a href="${link}">choose a new password</a>.</p>`,
        idempotency_key: `reset:${userId}:${row.token_hash.slice(0, 12)}`,
      }),
      signal: AbortSignal.timeout(12_000),
    });
  } catch (err) {
    await pool.query(
      `UPDATE password_reset SET attempts = attempts + 1 WHERE token_hash = $1`,
      [row.token_hash],
    );
    return { sent: false, reason: `unresolved:${err.name}`, tokenHash: row.token_hash };
  }

  const payload = await res.json().catch(() => ({}));
  if (!res.ok || payload.ok === false) {
    const e = payload.error ?? {};
    return { sent: false, reason: `${e.code ?? res.status}: ${e.message ?? "send failed"}` };
  }

  await pool.query(
    `UPDATE password_reset
        SET send_state = 'sent', message_id = $2, attempts = attempts + 1
      WHERE token_hash = $1`,
    [row.token_hash, payload.data.message_id],
  );
  return { sent: true, messageId: payload.data.message_id };
}

const outcome = await requestReset(4821, "dana@example.com");
console.log(outcome);
await pool.end();

The catch is that retryable can’t drive that decision for you. A syntactically broken recipient comes back as HTTP 503 VENDOR_DOWN with retryable: true, and retrying it is pure waste; a valid-looking address at a domain that doesn’t exist is accepted with HTTP 200 and fails asynchronously instead. A 5xx doesn’t prove the mail wasn’t sent, and a 200 doesn’t prove it will arrive.

Resolve the ambiguous rows against the archive

Every accepted message lands in the account archive, so a row still sitting at claimed a minute later is answerable rather than lost:

curl -sS "https://api.infrai.cc/v1/email/list?limit=10" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      { "message_id": "msg_h0Dw835DBSKZ4I1YaWBeNzCZ", "state": "sent", "channel": "email", "to": "dana@example.com", "vendor": "resend", "created_at": 1785042562.887 }
    ],
    "next_cursor": null,
    "count": 1
  }
}

Two limitations to plan around, both measured on 2026-07-26: the route ignores a to= query filter, so match on the recipient client-side, and next_cursor came back null even with a limit well below the number of messages held — pagination isn’t usable yet, so reconcile promptly rather than sweeping a week later. Once you have the id, the per-message timeline tells you what the vendor did with it:

curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_FjDRVM4y1dx7xcElLubSlJMF" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The reply is newest-first — {items:[{type, at, recipient, message_id, meta}], count, next_cursor} — and an id that was never accepted answers HTTP 404 EMAIL_NOT_FOUND, which is the clean signal that your timed-out call really did fail.

Four ways to answer a timeout

StrategyDuplicate emailsDead-link riskExtra callsVerdict
Retry the send immediatelylikelyhigh if you re-mint the token1 per retryThe default that causes this question
Never retry, show an errornonenone0Users just press the button again
Reconcile the archive, then retryrarenone1 free readGood default for a reset flow
Reuse the live token, resend on demandintentional onlynone0Best when support needs a resend button

The third and fourth rows compose: reconcile automatically, and let a deliberate resend reuse the same token so a duplicate is always a duplicate of one link.

Pick two of them, not four.

What a duplicate actually costs

Reads are free and rate-limited; only the send is metered, at $0.000115 per email, verified 2026-07-26 and flagged approximate because the vendor mix shifts. A new account starts with $2 of free credit. Read today’s figure rather than trusting this paragraph:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd'), c['billing']['unit']) for c in d['capabilities'] if c['id'] in ('email.send','email.batch.send')])"

Rates drift downward and discount campaigns run, so what you read is likely at or below the figure here. At reset volumes the money is irrelevant and the reputation isn’t: mailbox providers read repeated identical messages as a signal, and every duplicate is one more chance for a user to click a link you’ve already invalidated. Your real budget is the free read that prevents it.

When another provider is the better answer

If gateway-enforced idempotency is the feature you’re shopping for, check the current API references at Postmark and SendGrid before assuming any of them solve it — most transactional APIs treat a send as a fresh side effect too, and Resend’s own docs are worth reading on how it scopes request keys. If email is the only external service your product will ever call, a specialist’s dashboard and support desk are a fair reason to stick with one.

The reason to run this on Infrai is the rest of the loop. The token expiry sweeper is a cron job, the resend button is a queued task, the send failure lands in error tracking, and all of it sits behind the one key that made the send — one bill, one usage view, no second vendor onboarding to resolve a duplicate.

References

Browse more email developer guides