Integrations · September 21, 2026
LangGraph Agent Email Inbox: The Parts Outside the Graph
Build a LangGraph agent email inbox: resume a run from an inbound event, keep the approval gate outside the graph, and survive two emails at once.
Most LangGraph email tutorials hand the agent an IMAP client, a loop that checks for
mail every minute, and an interrupt() call before sending. That design has two
holes. The pause lives inside the graph, so it is only as durable as the checkpointer
somebody remembered to configure. The approval is a Python branch, so any other code
path holding the same credential walks straight past it.
This covers the parts of a LangGraph agent email inbox that live outside the graph: resuming a run from an inbound email event instead of polling in a node, keying graph threads to email threads without double-replying when two messages land at once, and moving the approval gate into the inbox policy so a crashed worker can neither lose a pending send nor emit one. If you have not picked a transport yet, the integrations overview lists what an agent can connect through.
Agent Inbox and an email inbox are not the same thing
This query returns two unrelated products. Which one you meant changes what you build.
Agent Inbox is LangChain’s open-source UI for human-in-the-loop review. You point
it at a LangGraph deployment URL and an assistant or graph ID, and it renders the
pending interrupt() payloads on your threads as a Gmail-shaped list that a person
can approve, edit, respond to or ignore. It is an inbox in the sense that a ticket
queue is an inbox. It has no address. Nothing outside your deployment can put an item
in it, and the items in it are graph interrupts, not mail.
An email inbox is a mailbox with an address that strangers can send to. Mail arrives from people who have never heard of your graph, gets parsed and threaded, and has to produce a run. The work is inbound routing and outbound policy, not review UX.
They compose well. Agent Inbox is a reasonable reviewer surface for interrupts you genuinely want inside the graph, such as “which of these three vendors did you mean”. It is the wrong container for “may this agent email a customer”, because that decision has to hold when the graph is not running, and the rest of this piece is about why.
What a LangGraph agent email inbox needs beyond a send tool
Four things, and only the first is a tool call:
- An outbound path that is safe to retry, because a node re-runs from the top when the graph resumes.
- An inbound path that starts or advances a run without a poller.
- A stable identifier shared by the email conversation and the graph thread.
- An approval gate that outlives the process holding the graph.
A toolkit that wraps a mail API as LangChain tools solves the first and nothing else.
The remaining three are integration work in your own service, and they are where the
design decisions are. Everything below runs against
https://api.emailforagents.ai/v1, API version 2026-09-19, with the credential in
Authorization: Bearer.
Where LangGraph’s interrupt pattern puts the pause, and what that costs
interrupt() suspends a node, persists graph state through the configured
checkpointer, and surfaces a payload to whoever invoked the graph. The run is later
advanced with graph.invoke(Command(resume=value), config) against the same
thread_id. It is a clean pattern and it is the right way to ask a human a question
mid-run.
It is the wrong place to put an email approval, for three reasons that are all documented LangGraph behaviour rather than opinions about it.
Durability is a configuration choice, not a property. The pause survives exactly
as long as the checkpointer does. InMemorySaver is in-process, so a redeploy, an
OOM kill, or a scale-to-zero on your worker discards every pending send silently. A
Postgres checkpointer fixes that and hands you a second production datastore to back
up, migrate and reason about, where a lost row is a reply that never goes out.
The node restarts from the beginning when it resumes. Code before the
interrupt() call runs a second time, so a node that builds a draft, records it
somewhere, then interrupts, records it twice. LangGraph’s own guidance is that side
effects placed before an interrupt must be idempotent. Email is the canonical
non-idempotent side effect.
The gate is advisory. if decision == "accept": send(...) is enforced by the
graph being the only thing that can send. It stops being true the moment a retry
script, a second graph, a notebook, or a tool call the model reached by another route
holds the same credential.
from langgraph.types import interrupt
def approve_reply(state):
decision = interrupt({"subject": state["subject"], "body": state["draft"]})
if decision == "accept":
transport.send(state) # your process, your credential, your promise
return state
That last line is the whole problem. The promise that nothing sends without review is kept by a branch in a file.
Moving the approval gate outside the graph
Put the gate in the inbox policy and the send call itself becomes the pause. The node posts the reply, reads the response, and the run ends. Nothing is held open, nothing is checkpointed against a pending human, and there is no in-flight state to lose.
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/messages" \
-H "Authorization: Bearer $EFA_API_KEY" \
-H "Idempotency-Key: thd_9b04-reply-v1" \
-H "Content-Type: application/json" \
-d '{
"to": [{"email":"ap@example.com"}],
"subject": "Re: Invoice 4471",
"reply_to_message_id": "msg_7f21c...",
"text": "Received, and the receipt is on its way."
}'
Two HTTP 202 bodies are possible, and the node branches on result rather than on the
status code:
{
"result": "approval_required",
"draft_id": "drf_...",
"draft_version": 1,
"approval_id": "apr_...",
"reason_codes": ["draft_only_principal"]
}
{ "result": "queued", "message_id": "msg_...", "thread_id": "thd_...", "state": "queued" }
On approval_required no message row exists, no thread placement happens, and nothing
has been handed to a mail provider. The node stores approval_id in graph state and
returns. Compare the two placements directly:
| Property | interrupt() inside the graph | Inbox policy gate |
|---|---|---|
| Survives a worker crash | Only with a durable checkpointer | Yes, the hold is server-side |
| Binds to the exact reviewed content | Whatever state holds at resume | SHA-256 over the canonical payload |
| Applies to other callers | No | Yes, every caller on that inbox |
| Re-runs code on resume | Yes, from the top of the node | No, there is nothing to resume |
| Reviewer sees a sanitized projection | You build it | Returned on the approval record |
| Cost of a queued draft | A live checkpoint | Nothing but attention |
The version binding is the part that is hard to rebuild in a graph. The canonical payload is hashed at hold time and the hash is re-checked when the approval is consumed, so approving content that changed underneath is refused rather than sent. The approval also records which principal requested it, and a decision that does not match that origin is rejected, which closes the trick of one agent queueing a draft for another to spend. Reason codes, recipient rules and daily caps are set out in human approval before an AI agent sends email, and the policy surface itself is described on sending controls.
Two limits worth planning around. Approvals expire seven days after they are
requested, after which the record can no longer be converted into a send, so a graph
thread parked on one needs a deadline of its own. And this build has no draft-edit
endpoint, so a reviewer cannot amend the agent’s wording. A revision arrives as a new
approval with a new draft_id while the old one stays pending. Reject the stale one,
or two people approve the same intent twice.
The tool surface an agent cannot be argued past
If the agent reaches mail through remote MCP rather than your own HTTP client, the permission check moves earlier than a LangChain toolkit can put it. The grant decides which tools exist in the session, not which calls get refused.
There are exactly six tools: list_inboxes, list_threads, list_messages,
get_message, create_draft, send_message. Each declares the scopes it needs, and
tools/list returns only the tools whose scopes the connection holds. The three
consent presets produce three different tool lists:
| OAuth scope | Preset | Tools the model can see |
|---|---|---|
email.read | read | list_inboxes, list_threads, list_messages, get_message |
email.draft | read_draft | the four above, plus create_draft |
email.send | read_draft_send | the five above, plus send_message |
On a read_draft connection the model never sees a transmit capability in its tool
list, so there is no argument, injected instruction or jailbreak that reaches one.
Calling the absent name anyway is refused with permission_denied and the message
“This connection cannot use that tool.” That is a different control from an allowlist
consulted after the model has decided to call something, and it is the thing to weigh
against a toolkit that binds every operation on one object into the graph at once.
AgentMail’s langchain-agentmail package, checked against its own README during
writing, exposes fourteen tools including send and reply from a single pip install.
That is a better developer experience and a larger surface for an injected
instruction to aim at, and the trade is real in both directions.
create_draft reinforces the boundary from the other side. The tool strips
messages:send from the calling principal before dispatching, so create_draft
cannot transmit mail under any configuration, including on a connection that was
granted send. A draft here is not a message waiting to be sent. It is a record that
structurally cannot become one without a separate human decision.
Two further facts shape how you configure the client. An API key is rejected at
https://mcp.emailforagents.ai/mcp by design, so only an OAuth connection grant can
call these tools, and policies:write is on the list of scopes refused on connection
grants outright, so an agent cannot widen the policy holding it. The consent screen
and grant mechanics are in how the MCP email server works.
What none of this defends against is content inside a draft that a human approves
without reading, which is the failure mode discussed in
prompt injection through an agent’s email.
Resuming a graph from an inbound email event instead of polling in a node
Delete the polling node. Inbound mail lands on the project event stream, and the event carries the identifiers needed to route it to a graph thread. Events are pollable and can also be delivered to a signed webhook. Use both: the webhook is the latency path, the cursor is the recovery path for the window your endpoint was down.
curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/events?limit=50&cursor=184" \
-H "Authorization: Bearer $EFA_API_KEY"
{
"data": [
{
"id": "evt_...",
"type": "message.received",
"sequence": 185,
"occurred_at": "2026-09-21T09:14:02.118Z",
"data": { "message_id": "msg_...", "thread_id": "thd_...", "inbox_id": "ibx_..." },
"environment": "live",
"api_version": "2026-09-19"
}
],
"next_cursor": "185",
"has_more": false
}
Write next_cursor only once the batch has been processed and committed. Callbacks
carry webhook-id, webhook-timestamp and webhook-signature, verified against raw
bytes before parsing, and delivery is retried, so treat a repeated callback as normal
and deduplicate on the event ID. Header format and verification code are on
events and webhooks.
The dispatcher is small, and it has one branch worth getting right. A graph thread
currently suspended at an interrupt() must be advanced with Command(resume=...); a
thread that has finished its previous run takes a normal input. Checking the state
snapshot rather than guessing is the difference between a resumed run and a Command
payload that goes nowhere:
from langgraph.types import Command
def advance(email_thread_id: str, payload: dict) -> None:
config = {"configurable": {"thread_id": email_thread_id}}
snapshot = graph.get_state(config)
if snapshot.interrupts:
graph.invoke(Command(resume=payload), config)
else:
graph.invoke(payload, config)
def on_event(event: dict) -> None:
if not seen.add_if_absent(event["id"]):
return
body = event["data"]
if event["type"] == "message.received":
advance(body["thread_id"], {"inbound_message_id": body["message_id"]})
elif event["type"] == "approval.rejected":
advance_by_approval(body["approval_id"], {"status": "rejected"})
elif event["type"] == "message.queued":
advance_by_message(body["message_id"], {"status": "sent"})
Because the checkpointer is keyed on the email thread, a second message in the same conversation lands in a run that already has the history. There is no reconstruction step and no vector store needed to answer “what did we already say to this person”.
Be careful with the event catalogue here, because it is larger than the set that
fires. approval.requested and approval.rejected are emitted by this build.
approval.approved and approval.invalidated are declared in the catalogue and are
not produced by the current build: approving submits the draft, which produces
message.queued. Subscribing to a declared but unproduced type is accepted and simply
never fires, which is a long afternoon spent debugging a handler that was never going
to run. So the working state machine for a held reply is three events, not four. The
send returns approval_required, approval.requested confirms the hold, and then
either approval.rejected or message.queued resolves it. Check the status column on
events and webhooks before you write the branch.
Mapping email threads onto graph thread IDs, and the double-texting hazard
Use the thd_ identifier from the threads endpoint as the graph thread_id, and
store nothing else as the join key. Subject lines change, and Message-ID headers are
per message rather than per conversation.
GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads?q=invoice&limit=25
GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads/{thread_id}/messages?limit=25
The listing returns the fields a router needs and nothing that changes underneath you:
{
"data": [
{
"id": "thd_9b04...",
"inbox_id": "ibx_...",
"subject": "Invoice 4471",
"preview": "Attached is the invoice for September.",
"message_count": 3,
"last_message_at": "2026-09-21T09:14:02.118Z"
}
],
"next_cursor": "...",
"has_more": false
}
q searches stored subjects, senders and previews. It does not reach full message
bodies or attachment contents, so treat it as conversation lookup rather than
retrieval over corpus text.
Now the failure this mapping creates, which is the one nobody warns about. Sharing an
identifier between a mail thread and a graph thread means a person who sends two
emails thirty seconds apart produces two concurrent runs on the same thread_id. This
is LangGraph’s double-texting problem arriving through a channel where the sender has
no idea a run is in progress and no UI telling them to wait. On LangGraph Platform the
run creation call takes a multitask_strategy, defaulting to enqueue, so the second
run waits. Calling graph.invoke yourself has no such coordination: two dispatcher
workers can load the same checkpoint, and the second run reasons from a state that
does not yet contain the first run’s reply. The visible result is two replies to one
question, or a reply that ignores the message that just arrived.
Idempotency keys are what stop that from reaching a recipient, and the exact behaviour
matters because it splits three ways. Derive the key from the thread and a revision
counter rather than from the attempt, thd_9b04-reply-v1 rather than a fresh UUID.
Then two runs racing on one thread resolve like this:
| What the two runs produce | What the second send returns | What arrives |
|---|---|---|
| Identical body, second arrives after the first completes | the first response, replayed | one email |
| Identical body, second arrives while the first is still in flight | 503 temporarily_unavailable, “A matching send is already in progress.” | one email |
| Different body, same key | 409 idempotency_conflict | one email |
Two emails is not on that list, which is the point. The key converts a graph
concurrency bug into an HTTP error you can see, and the 409 is the useful one: it is
proof that two runs independently decided to reply and disagreed about what to say.
Treat it as a bug in your dispatcher rather than something to retry around. The replay
window is 30 days, after which the same key returns idempotency_expired rather than
sending again. The four outcomes of reusing a key and the reason an unknown outcome
must never be retried are covered in
idempotency keys: stop an agent sending duplicate emails.
Two smaller notes. Replies thread by passing the inbound message’s ID as
reply_to_message_id, which reuses the parent’s thread instead of opening a new one,
and the parent must live in the same inbox. Approval events carry approval_id and
draft_id but not the thread, so keep your own approval_id to thd_ map for the
window a send is held. The full request and error surface is in
the agent email API loop.
Testing the inbound loop before anything is routable
Every mail sandbox a LangGraph developer is likely to have used captures outbound traffic. It intercepts what you try to send and shows it to you. That verifies half of an agent inbox and structurally cannot verify the half that matters here, because nothing in the harness can deliver a message into your agent and make the graph run.
Test inboxes here are the other direction. They use a non-routable .mail.invalid
address and a simulated transport, and mail is injected into them:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_TEST_PROJECT_ID/test/inbound" \
-H "Authorization: Bearer $EFA_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{
"inbox_id": "ibx_...",
"from": {"email":"ap@example.com","name":"Accounts Payable"},
"to": [{"email":"invoices@a1b2c3.mail.invalid"}],
"subject": "Invoice 4471",
"text": "Please confirm receipt."
}'
That single call exercises the whole chain the graph depends on. The message is
received, parsed, placed in a thread, emitted as message.received, routed to a graph
thread ID, drafted against, held for approval, and replied into the same thd_. Fire
it twice in quick succession against one thread and you have a deterministic
regression test for the concurrency case above, which is otherwise only reproducible
by asking a colleague to email you twice. A CI job can assert every step, and a test
inbox cannot reach an internet recipient even if the assertion is wrong.
The sandbox limits are enforced in code rather than advertised: 3 active test inboxes
per workspace, the last 100 messages retained, up to 10 MB, content expiring after 7
days. Test mail is never charged as an email unit. The efa_test_ key prefix resolves
only to test projects and test inboxes, with no flag that lets it reach live, so a
misconfigured deploy fails closed instead of emailing a customer. Test projects also
reject local_part with HTTP 422 test_mode_unsupported and generate the
.mail.invalid address themselves. The rest of the checklist is in
testing an agent’s email without sending real mail.
What this does not give you
There is no LangGraph package to install here. The four integration points above are plain HTTP against a documented surface, or six MCP tools behind an OAuth grant, and you write the node functions yourself. AgentMail hands a LangChain agent an inbox in one line. If that is what you want today, take it, and accept that the tool list it binds into your graph is the same list whatever the agent is trusted to do.
On assurances, plainly: we hold no certifications and have commissioned no independent security review, there is no published uptime SLA, and provider acceptance is not proof of delivery. What exists instead is a set of shipped technical controls you can verify yourself in an afternoon. A grant that produces a session with no send tool in it. A draft that cannot transmit. An approval bound by content hash to one exact version and to the principal that requested it. An idempotency key bound to a canonical payload rather than to an endpoint. An audit entry with a before-and-after diff on every policy write. Those are different in kind from an audit certificate, and for the specific question “can this agent email a customer without a human seeing it first”, they are the ones that answer it.
Two cost notes before you build. The free allowance is one complimentary live inbox and 100 email units a month per verified owner identity, claimed by that identity’s first eligible workspace, and creating another workspace does not renew it. Custom domains require a paid plan, and paid checkout is currently disabled, so run the first LangGraph deployment on the platform domain.
Start with a test project, an email.draft grant, and the event dispatcher above. Add
messages:send only after you have watched the held-approval path work end to end,
and after you have fired two inbound messages at one thread and seen exactly one reply
leave.
Keep reading
Human Approval Before an AI Agent Sends Email
When to require review, why approval binds to one exact draft version, how recipient rules and daily caps work, and what editing a draft does to its approval.
MCP Email Server: Give Your Agent Client a Real Inbox
How a remote MCP email server works, what the consent screen must show, how to scope a grant to one inbox, and how to verify it before allowing sends.
Agent Email API: Send, Receive and Reply in One Loop
Build the full two-way loop over REST: scoped keys, idempotent sends, the states a message moves through, inbound events, and replies that thread correctly.