Developers · September 22, 2026

My Agent Says It Sent It. Nobody Received It

Your AI agent says it sent an email and nobody received it. Work the states in order, learn which ones emit no event at all, and never retry one.

The transcript reads “I’ve sent the receipt to ap@example.com.” The person at ap@example.com says nothing arrived. Both statements can be true at once, and the usual first move, telling the agent to try again, is the one move that can turn a non-delivery into a double send. When your AI agent says it sent an email but nobody received it, the sentence in the transcript is not evidence of anything. It is generated text about a tool call, written by a model that often cannot distinguish “the call returned” from “the call did what I wanted”.

This is the diagnosis order for that report, in the states and event names the deployed agent email API actually writes, including the four state changes that emit no event at all. If what you want is the conceptual difference between the statuses rather than a procedure, what an email status actually proves is the reference page. This one is the runbook you open at 16:00 with an angry customer on the other end.

Start by disbelieving the agent, not the mail server

Most of these reports never reach deliverability. They end at the first question: did a send actually happen? Four different non-events all produce a confident “sent” in a model’s own narration.

The tool the agent called cannot transmit mail. Over MCP, create_draft and send_message are separate tools, and create_draft is implemented by calling the same send path with messages:send stripped from the acting principal (packages/mcp/src/index.ts:327). A draft is not a send that has not happened yet. It is a send that cannot happen without a human.

The tool was never in the session. tools/list is filtered by grant: visibleTools() returns only the tools whose scopes the connection actually holds, and tools/call re-checks the same thing before dispatch. A connection granted read and draft access gets a session in which send_message does not exist. That is the point of making the tool surface the permission boundary, and it means a model asked to send mail will reach for what it can see, draft it, and then describe the outcome in the words it was prompted with.

The send returned 202 with "result": "approval_required". This is an HTTP success. The body carries draft_id, draft_version, approval_id, reason_codes and request_id, and no message_id, because no message exists. An agent that branches on the status code rather than on result will report success for a message sitting in a review queue. Check with one call:

curl -s "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/approvals" \
  -H "Authorization: Bearer $EFA_API_KEY"

That route is GET /v1/projects/{project_id}/approvals, verified in packages/api/src/index.ts:205, and it requires the approvals:read scope. Each row carries state, reason_codes, expires_at and the canonical payload that would have been sent. Two behaviours are worth knowing before you rely on it. A pending approval whose expires_at has passed is returned with state: "expired" rather than pending, computed at read time in listApprovals rather than stored, so nothing fires when an approval goes stale. approval.invalidated is declared in the event catalogue and is never emitted by this build, which means a stale approval is only ever found by looking. And the list returns at most 100 rows, newest first, with next_cursor always null, so a backlog deeper than 100 is not reachable by paging. If approvals are new to you, how the human approval gate works covers the flow.

No tool call happened at all. Models fabricate confirmations of mutating actions, particularly after a few failed attempts. The check is not subtle: if your agent runtime logs tool invocations, a “sent” with no corresponding invocation in the same turn is a hallucinated success, and no amount of DNS work will fix it.

Which events you actually receive, and which you are waiting for in vain

Six event types describe the life of an outbound message. Every name is verbatim from packages/contracts/src/events.ts and every one below is emitted by a code path in packages/domain/src/services.ts.

EventEmitted when
message.queuedThe request passed validation, policy and quota, and was stored.
message.acceptedThe provider returned a submission ID for this attempt.
message.failedThe provider refused outright, or the retry window closed.
message.submission_unknownA submission attempt returned without a usable answer.
message.delivery_updatedA provider callback named one recipient and a mapped status.
message.receivedInbound mail landed, including the reply you are waiting for.

Now the part that sends people looking for events that never arrive. email.delivered, email.bounced, email.suppressed, email.complained, email.delivery_delayed and email.failed turn up in provider tutorials, and they are real names, but they belong to the sending provider: they are its callbacks to this system, not events on your stream. Ask to subscribe to one and the webhook endpoint refuses outright with invalid_request, “Unknown event type in subscription.” That is the useful kind of failure, because it happens when you configure the webhook rather than the first time a bounce goes unseen. The provider’s outcomes are mapped to a per-recipient status and surfaced to you as message.delivery_updated, with a status of delivered, bounced, suppressed, complained, delayed or failed. Subscribe to message.delivery_updated and branch on the status field.

The quiet version of this trap is elsewhere. approval.approved and approval.invalidated are in the event catalogue, so the endpoint accepts a subscription to either, but they are declared and never emitted by this build. Nothing will ever arrive on them. Only approval.requested and approval.rejected fire.

email.sent is the sharp edge in that mapping. It is a real provider callback and it is the only one that means “we took it”, but it has no entry in the status map, so it produces no message.delivery_updated and no event of any kind. It still does work: it is one of the five callbacks treated as proof of acceptance. It just does that work silently. The events and webhooks reference has the subscription mechanics.

The state changes that emit nothing

A message state and an event are not the same system here, and they disagree in four places. Every one of them produces a message that moved while your event consumer saw nothing, which is exactly the shape of the report you are debugging.

What happensNew stateEvent
Send accepted, stored, job enqueuedqueuedmessage.queued
Provider throttles, attempt rescheduledstays queuednone
Provider returns a submission IDacceptedmessage.accepted
Provider refuses outrightfailedmessage.failed
Provider returns an unusable answersubmission_unknownmessage.submission_unknown
Worker dies mid-submission, next lease finds itsubmission_unknownnone
Later provider callback for an unknown sendstays submission_unknownnone
Provider idempotency window expires on an unsent attemptfailedmessage.failed

The two silent rows in the middle matter most. There are two routes into submission_unknown and only one of them tells you. When the provider call returns ambiguously, the outcome handler emits message.submission_unknown. When a worker dies after the submission left but before the result was recorded, the next lease finds an intent still marked submitting, moves it to submission_unknown with last_error_code: "interrupted_submission", holds the quota, and returns without emitting anything. The message sits in submission_unknown indefinitely and your webhook never hears about it.

There is no automatic exit. The callback handler has a branch that would promote an unknown send to accepted, but it looks the send up by the provider’s email ID, and that ID is only stored when the provider’s reply was classified as accepted. An unknown send never has one, so its callbacks match nothing and the state never changes on its own (the full walk-through). A webhook consumer cannot see the full lifecycle of an outbound message. Poll the message state, or poll the event feed and re-read the message on every hit.

One more divergence, in the opposite direction. message.delivery_updated carries the status from the callback that triggered it, but the recipient row is only rewritten when the callback is not older than the last one recorded and the new status ranks at or above the stored one. The ranking is pending 0, delayed 1, delivered 2, failed 3, bounced and suppressed 4, complained 5. The write is gated. The event is not. So a delayed callback that arrives after a delivered emits message.delivery_updated with status: "delayed" while the message resource correctly still reads delivered. Treat the event as a signal to re-read, not as the status.

The same handler resolves the affected recipient only when the callback names exactly one address, on the grounds that an aggregate payload cannot establish which address bounced and must never be allowed to suppress all of them. A multi-address callback writes nothing and emits nothing.

My AI agent says it sent an email but nobody received it: the order to work

Each step rules out a class of cause, and the order puts the cheap checks first. Do not skip to step five because the problem feels like deliverability. In practice it usually is not.

  1. Find the message. List the inbox and look for it by subject or recipient. If there is no message, go back to the previous section: you are looking at a draft, an approval, or a fabricated confirmation.
  2. Read environment and simulated on the message. This is step two for a reason. See the next section.
  3. Read state. queued means nothing has been submitted yet. failed means it will not be. submission_unknown means stop and investigate before doing anything. accepted means the question has moved to the recipients.
  4. If queued and staying queued, check whether sending is paused or the allowance is gone. Two errors on the original request are the giveaway: permission_denied with “Sending is paused or the resource is inactive.” (the send path’s authorization check in packages/domain/src/services.ts), and quota_exceeded, “This workspace has exhausted its delivery allowance” (raised in packages/domain/src/billing.ts). When the free allowance of 100 email units a month runs out, sending stops and received mail keeps working, which produces an agent that looks half broken rather than blocked. Sending controls covers the pause switch.
  5. If accepted, read recipients[], not the message state. Each entry is { email, type, delivery_status }. A five-recipient send is five outcomes. A bounce on one address does not move the message off accepted, and a roll-up view showing a green tick is how somebody concludes an invoice arrived.
  6. If the recipient entry is still pending, you may be missing the callback rather than the delivery. Replay the event feed from your stored cursor, then check webhook delivery attempts at GET /v1/projects/{project_id}/webhooks/{webhook_id}/deliveries before concluding anything about the recipient’s mail server.
  7. If the recipient is bounced, suppressed or complained, the message is not arriving, and the next send to that address will be refused at request time with 422 recipient_suppressed, “A recipient is on the suppression list.” This is the point to involve a human, not a retry.
  8. If the recipient is delivered, the receiving server accepted the message. From here the investigation is about filtering and placement on their side, and your API cannot see it.

The two fields that end most investigations: environment and simulated

A message resource carries environment and simulated, and between them they settle the most embarrassing version of this bug in one glance:

{
  "id": "msg_2f1c8a4b",
  "state": "accepted",
  "environment": "test",
  "simulated": true,
  "from": {
    "email": "0f3ac41e-8b25-4a77-9d0e-6c2b1f48aa31@8d21c5b0-4f7a-4c31-9b6e-2ad5c9f10e73.mail.invalid"
  }
}

That message reached accepted. It emitted message.queued and then message.accepted. Nothing crossed a transport. Test projects run a simulated provider rather than the live one, and a test inbox is issued a non-routable address that cannot reach an internet recipient by design. Every status in your dashboard is green and the mail never existed outside the database.

You can recognise it without reading a field. A test address is built as <inbox uuid>@<project uuid>.mail.invalid, so it is two bare UUIDs and a reserved TLD, with no chosen local part and no hex-prefixed public ID. If the from address in a support screenshot looks like that, stop diagnosing deliverability.

The usual route into this state is credentials. An efa_test_ key resolves only to test projects and test inboxes, and no flag lets it cross into live, so an agent handed the wrong key does not fail loudly. It succeeds quietly against the wrong environment. Two habits prevent it: assert environment: "live" in the send response path of anything that matters, and keep the test key out of the process that serves real customers. The boundary itself is deliberate, and testing an agent’s email without sending real mail is the case for it.

message.submission_unknown is not a failure, and must not be auto-retried

This is the state teams misread most often, because every other email API collapses it into “error” and the agent’s retry policy treats “error” as “try again”.

submission_unknown means the request left this side and no usable acknowledgement came back: a timeout, a 5xx, a connection error, a response the client could not interpret, or a worker that died mid-submission. Nobody here knows whether an email exists. Resending is correct roughly half the time and duplicates a real message the rest of the time, which for an invoice or a password reset is worse than silence.

The system records the state and stops. It does not reschedule the job, and the reserved sending quota moves to a held state rather than being released, on the grounds that the message may well have gone out and should be paid for. In this build only a person resolves it, and not by retrying:

Not a later provider callback. An email.sent, email.delivered, email.bounced, email.complained or email.suppressed callback is treated as proof of acceptance, and the handler has a branch to promote an unknown send. But callbacks are matched to a send by the provider’s email ID, which is only stored when the provider’s reply was classified as accepted. An unknown send never recorded one, so those callbacks match nothing and waiting for one is not a plan.

A human reading the provider’s log. Every outbound submission carries the header X-EFA-Correlation: efa_intent:<send intent id>, composed in packages/domain/src/services.ts:4424 and truncated at 256 characters in packages/mail/src/resend.ts:112, so the message can be located in the provider’s own records without guessing from subject lines and timestamps. Find it there and the answer is acceptance. If it is genuinely absent, composing a fresh send with a new idempotency key is a decision a person makes, once.

If your agent has a retry loop at all, read idempotency keys and safe retries before you let it near this state. The short version: Idempotency-Key is rejected as missing at the route itself, it binds to a hash of the canonical payload rather than to the endpoint, and deriving keys from business intent instead of from the attempt number is what makes the difference between a safe retry and a second email.

Correlating a complaint back to a request with x-request-id

Every API response carries an x-request-id header, and every 202 send response repeats it in the body as request_id. It is the one identifier tying a line in your agent’s log to a record on this side.

The useful detail is that it is not only generated, it is honoured. The request-id middleware reads an inbound x-request-id header if you send one and echoes that same value back; only when the header is absent does it mint a req_ identifier. So an agent that already has a task ID can push it through and get a single identifier spanning both systems.

curl -s -D - -o /dev/null \
  "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/events?limit=100" \
  -H "Authorization: Bearer $EFA_API_KEY" \
  -H "x-request-id: req-triage-4471"

Poll the same endpoint with cursor to walk the feed forward. The default page is 25 and the maximum is 100. Events come back with id, type, sequence, occurred_at, data, environment and api_version, and next_cursor is the sequence of the last row returned. Two failure modes to code for: a malformed cursor is rejected as invalid_cursor, and a cursor older than the retained window is rejected with cursor_expired and a 410 rather than silently restarting from the beginning. The second is the honest behaviour and it does mean your consumer needs a restart path that begins with no cursor at all.

Log three things per send and most future reports resolve in a minute: the x-request-id, the message_id from the 202, and the literal result field. The third is the one people leave out, and it is the one that distinguishes a queued send from an approval waiting on a human.

What acceptance and delivery still do not prove

Provider acceptance is not proof of delivery. It means one hop succeeded: the provider’s API returned a submission ID and the message became its problem instead of ours. Billing commits at that moment, because acceptance is the last thing anyone on this side can observe, and that is exactly why it is a weak claim about the recipient.

Delivery is stronger and still bounded. message.delivery_updated with status: "delivered" means a receiving mail server answered 250 to the envelope. What that server did next is invisible from here: inbox, spam folder, a rule that filed it away, a promotions tab nobody opens. Receiving systems do not report placement back to senders, so no email platform can tell you where a message landed, and nothing in this pipeline guarantees delivery or inbox placement. There is no published uptime SLA here either, and anybody offering you one for email is describing a system they do not control.

Nor does any status prove a person read the message. There is no open tracking and no read receipt in this API, deliberately, because image proxies and privacy filters make that signal both absent and wrong in different directions. The honest ceiling is a sequence: we stored it, a provider took it, a receiving server accepted it. If your workflow depends on someone having acted, the evidence is a reply arriving as message.received, not a delivery status.

Which gives the agent a better definition of done. “I called send_message” is a claim about a function. “The send returned "result": "queued" with a message_id, and the message later read accepted with every recipient delivered” is a claim about the world, and it is one an agent can check for itself before it tells a human the job is finished.


Keep reading

All guides · Documentation · Developers