Engineering reliability
Idempotency for email-triggered AI agent workflows
Prevent duplicate replies and repeated side effects by assigning a stable operation key at every boundary in an email agent workflow.
An email-triggered AI agent should assign one stable key to each logical operation, claim that key atomically, and save the result for every retry to replay. Do not generate a new UUID inside the retry loop. That produces a new operation, not a safe retry.
This rule prevents the most expensive kind of agent reliability bug: a workflow that reads one message but sends two replies, opens two tickets, or charges a customer twice because a network response went missing.
One email crosses several idempotency boundaries#
Consider a support agent that receives a message, creates a ticket, drafts a response, and sends it. Duplicate work can enter at every handoff:
- The mail provider reports the same inbound message twice.
- A webhook is retried because your endpoint's response was lost.
- A queue worker restarts after creating the ticket but before acknowledging the job.
- The send request reaches the email provider, but your application receives a timeout.
There is no single global key that cleanly represents all four events. Each boundary identifies a different operation.
| Boundary | Stable identity | What it prevents |
|---|---|---|
| Inbound ingestion | Provider message ID | Fetching and processing the same provider message twice |
| Webhook delivery | Postfleet event_id |
Enqueuing the same notification twice |
| Business action | Your operation key, such as ticket:418:reply:v1 |
Repeating the application's intended effect |
| Outbound send | Postfleet client_id |
Delivering the same resolved email twice |
Keep those values separate. A webhook event might be redelivered, but it still refers to the same inbound message. One inbound message may also lead to more than one legitimate send. Reusing one ID for every layer makes later changes hard to distinguish from accidental repeats.
Idempotency is about effects, not identical HTTP calls#
RFC 9110 defines an idempotent request method in terms of its intended effect: several identical requests should have the same intended effect as one. POST is not inherently idempotent, so an API has to add an application-level key and rules around it.
For an agent workflow, the logical effect is the useful unit. "Send the approved resolution for ticket 418, revision 1" is a logical effect. "Run attempt 7" is not.
A good operation key is:
- Stable across network retries, process restarts, and queue redelivery.
- Scoped to the resource that owns the action.
- Derived from a durable business identifier.
- Versioned only when the intended action changes.
For example:
ticket:418:customer-reply:v1
If a reviewer edits the recipient or body and approves a genuinely new revision, use v2. If the worker merely times out and runs again, keep v1.
A random UUID can still work if it is generated once and stored on the business record before any attempt. The mistake is generating it in the function that retries, where every run invents a fresh identity.
Claim the business action before doing it#
The application should own an operation table with a unique constraint. Claim the key and record the payload you intend to execute before calling an external service.
create table agent_operations (
operation_key text primary key,
request_hash text not null,
status text not null check (status in ('processing', 'complete', 'failed')),
result jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
The first worker inserts processing. A concurrent worker that loses the unique-key race reads the existing row instead of acting. If the request hash differs, stop and report a conflict. Silently reusing a key for different content hides a programming error.
Hash the resolved action, not a half-finished template. For a reply, that usually means the actual mailbox, recipient, subject, body, and thread reference. If two attempts use the same key but resolve to different recipients, the system should refuse the second request rather than guess which one is correct.
Once the external result is known, save the terminal result on the operation. Later attempts return that record. That is what turns a duplicate request into a replay rather than a second side effect.
Use client_id for idempotent Postfleet sends#
Postfleet's REST send endpoint accepts an optional client_id from 1 to 256 characters. Idempotency is scoped to the mailbox and client ID together. The API hashes the resolved recipient, subject, text, and reply reference, then applies these rules:
| Situation | Response | Caller action |
|---|---|---|
| First successful send | 201 |
Store the result |
| Same key and same resolved request after completion | 200 |
Treat it as the original result, not a second send |
| Approval is required | 202 |
Store the pending draft ID; do not retry as an error |
| Same key with different content | 409 idempotency_conflict |
Stop and fix the key or request construction |
| Same key while the first request is unresolved | 409 idempotency_in_progress |
Wait and retry the same key |
| Provider outcome is unknown | 502 delivery_outcome_unknown |
Keep the same key and reconcile; do not send with a fresh key |
Without client_id, every call is a new send.
Here is a compact TypeScript wrapper. The operation key comes from the caller's durable record, not from this function:
type SendInput = {
mailboxId: string;
operationKey: string;
to: string;
subject: string;
text: string;
inReplyTo?: string;
};
export async function sendOnce(input: SendInput) {
const response = await fetch("https://api.postfleet.ai/api/v1/send", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.POSTFLEET_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
mailbox_id: input.mailboxId,
client_id: input.operationKey,
to: input.to,
subject: input.subject,
text: input.text,
in_reply_to: input.inReplyTo,
}),
});
const result = await response.json();
if ([200, 201, 202].includes(response.status)) {
return result;
}
if (
response.status === 409 &&
result.code === "idempotency_in_progress"
) {
throw new Error("RETRY_SAME_OPERATION_KEY");
}
if (
response.status === 502 &&
result.code === "delivery_outcome_unknown"
) {
throw new Error("RECONCILE_WITHOUT_A_NEW_SEND");
}
throw new Error(
`${response.status}: ${result.code ?? result.error ?? "send_failed"}`,
);
}
The exact response fields and terminal failure behavior are documented in Sending and idempotency. One detail deserves special attention: delivery_outcome_unknown means the provider may have accepted the message. Creating a new key and sending again can produce a duplicate. Postfleet keeps the original operation in progress while it reconciles that ambiguous outcome.
A known terminal failure is different. Postfleet stores terminal results against the key, including rejected and failed outcomes. Reusing the key replays that result. If the documentation marks a terminal failure as retryable and you intentionally want a new delivery attempt, create a new operation version after recording why. Do not automate that decision for an unknown outcome.
Keep webhook acceptance and agent work separate#
Postfleet webhooks use at-least-once delivery and preserve event_id on retries and manual redrive. Your endpoint should claim that event ID and enqueue the business operation in one database transaction, then return 2xx.
The worker later claims its own operation key. This second claim is not redundant. It protects the business action if a job is copied, manually replayed, or produced through another route.
For example, one transaction can insert both records:
begin;
with claimed_event as (
insert into processed_webhook_events (event_id)
values ('evt_123')
on conflict do nothing
returning event_id
)
insert into agent_jobs (operation_key, message_id)
select 'message:msg_456:auto-reply:v1', 'msg_456'
from claimed_event
on conflict (operation_key) do nothing;
commit;
If the event ID was already present, claimed_event is empty and no job is inserted. In application code, also inspect the returned row counts so a duplicate can be logged and acknowledged explicitly.
The webhook documentation covers signature verification, retries, and stable event IDs. The core invariant is simple: a repeated notification may trigger another handler invocation, but it must not create another logical action.
Approval changes the workflow, not the key#
When a mailbox requires approval, a send request can return 202 with a pending draft. That is a successful creation of the approval request. Retrying with a new key would create another draft for the same proposed reply.
Store the draft ID as the operation result and let the dashboard move it through approval or rejection. The agent's data-plane key cannot approve its own work. Drafts and human approval describes the available states and their limits.
Draft creation itself accepts a client_id, but it does not currently provide the same deduplication contract as POST /api/v1/send. Also, Postfleet's direct MCP send tools do not expose caller-controlled client_id. If a workflow needs a caller-defined idempotency key for direct sending, use the REST endpoint. For a higher-risk action, prefer the draft and approval path and make draft creation idempotent in your own operation table.
Test the ugly timing windows#
Unit tests that call the function twice in sequence miss the failures that matter. Add tests for these cases:
- Start two sends concurrently with the same key and body. Exactly one provider delivery should occur.
- Reuse the same key with a different recipient or body. The request should fail with a conflict.
- Drop the HTTP response after the provider accepts a send. The retry should use the same key.
- Return a pending approval result. The worker should store it and stop retrying.
- Crash after enqueueing a job but before acknowledging the webhook. Redelivery should not create a second job.
- Redrive a dead webhook whose first attempt committed. The business action should remain single.
- Deliver two distinct messages with identical bodies. They should remain distinct because their message IDs differ.
Resend's idempotency guidance recommends a comparable business-entity key pattern and rejects a reused key when the payload changes. Provider retention windows and response semantics vary, so use Postfleet's documented contract for Postfleet calls rather than assuming every email API behaves the same way.
What idempotency cannot decide#
Idempotency prevents accidental repetition of an identified action. It does not decide whether the action is authorized, whether the model chose the right recipient, or whether a later edit deserves a new revision.
Those decisions belong to policy and approval. The operation key should encode the decision after your application makes it, not replace the decision. For an agent that reads hostile email, keep send permission narrow and put a person before consequential delivery. The secure MCP inbox guide shows how those boundaries fit around the agent.
It also does not create literal exactly-once execution across independent systems. There will always be a period when one system has committed and the other result is uncertain. Stable keys, atomic claims, and stored replays make that uncertainty recoverable without turning every timeout into another email.