Your AI Agent Will Send That Email Twice. Here's How to Stop It.
Traditional apps send email when a user does something. A checkout completes, a receipt goes out. One event, one email, and if the send fails you retry it once and move on.
AI agents don't work that way. An agent that has email as a tool will call it in loops, re-plan after errors, and fan out across parallel tool calls. Add the usual network reality — TLS handshake timeout, worker cold start, upstream 502 — and retries stop being an edge case. They're the steady state.
Every one of those retries is a candidate for the worst kind of bug: the email that did go through, but whose response never made it back to the caller. The caller assumes failure and tries again. Your customer gets two OTP codes. Two invoices. Two "your account is ready" emails, five seconds apart, from a system that is supposed to be smarter than that.
What goes wrong without idempotency
Trace the failure:
- Agent calls the send endpoint. The API accepts the email, queues it to the provider, starts writing the response.
- The connection dies mid-response. Timeout on the agent's side.
- Agent's retry logic fires. Same payload, second send.
- API sees a new request. Queues it. Customer gets both.
The email is not the thing you duplicated by accident — it's the thing you duplicated on purpose, because your API had no way to tell "same request, second attempt" from "a genuinely new email".
Humans click send once. Agents retry on every transient signal they can see. If your email API's only dedup mechanism is your caller's discipline, you don't have dedup.
The fix is one header
An idempotency key is a client-generated string that names the intent, not the attempt. Same key on a retry means "this is the same email I already asked you to send."
curl -X POST https://api.mailanvil.com/v1/send \
-H "Authorization: Bearer re_your_api_key" \
-H "Idempotency-Key: order-8412-receipt" \
-H "Content-Type: application/json" \
-d '{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Your receipt for order #8412",
"html": "<p>Thanks! Your receipt is attached.</p>"
}'
First request: normal send. A retry with the same key returns the original email — same id, same subject, status: "queued" — plus one extra field:
{
"id": "em_01JABC...",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Your receipt for order #8412",
"status": "queued",
"created_at": "2026-09-22T08:14:03.512Z",
"idempotent_replay": true
}
That idempotent_replay: true is the whole contract: your agent can log it, count it, and treat the retry as a success without ever worrying that the customer saw two emails.
How it works under the hood (and why most implementations race)
The naive version is "check if key exists, if not, send." That check-then-act pattern is itself a race: two concurrent retries both see "no key", both send. Under agent-level parallelism that's not theoretical.
MailAnvil's implementation uses an atomic insert on the key — the database decides the winner, not application logic:
INSERT OR IGNORE INTO idempotency (customer_id, idem_key, email_id, created_at)
VALUES (?, ?, ?, ?)
Only one request's insert succeeds. The loser reads back the winner's email_id and returns it as a replay. Keys are scoped to your API account, so another customer's key can never collide with yours, and records are pruned with the 90-day email log window — long enough that no realistic retry loop, backoff schedule, or agent re-planning cycle will outlive it.
Generating good keys
A key names intent. Good patterns:
- Entity + purpose:
order-8412-receipt,user-3091-welcome,inv-2026-09-0031-reminder - Deterministic from your data: derive it from the primary key of the thing that triggered the email, and the same logical event always maps to the same key — including retries hours later
- Never timestamps or UUIDs:
uuid()per attempt generates a fresh key for every retry, which is the same as sending no key at all
The worst key is a random one generated inside the retry closure. It deduplicates nothing. Generate the key once, at the same place you build the email payload, and pass it down through every retry.
Agents, MCP, and the same guarantee
If your agent talks to MailAnvil over MCP (https://mcp.mailanvil.com/mcp), the send_email tool takes the same idea as a parameter:
{
"name": "send_email",
"inputSchema": {
"properties": {
"from": { "type": "string" },
"to": { "type": "array", "items": { "type": "string" } },
"subject": { "type": "string" },
"idempotency_key": { "type": "string", "description": "Idempotency key (max 255 chars)" }
}
}
}
Same atomic insert, same replay response, whether the call came from raw REST or an agent's tool call. That matters because an LLM agent is more likely to duplicate a send than your hand-rolled retry code — it may re-decide to send an email as part of re-planning, with no memory that attempt #1 already succeeded. Give the tool call a deterministic key like confirm-<booking_id> and re-planning becomes harmless: the second call returns the original email and a replay flag.
The checklist
If you're wiring email into an autonomous system:
- Send with an
Idempotency-Keyon every call — not just the ones you expect to fail - Generate keys deterministically from the triggering entity, not the clock
- Treat a replay response as success — it references the original email
- Watch
idempotent_replay: truein your logs — a high replay rate means your agent's retry loop is firing, which is worth knowing even when dedup is working - Pair with webhooks (
delivered,bounced,complained) so the agent observes outcomes instead of guessing from timeouts
Retries are not a bug in your agent. Duplicated customer emails are. One header, one atomic insert, and the difference between the two stops being your problem.