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:

  1. An outbound path that is safe to retry, because a node re-runs from the top when the graph resumes.
  2. An inbound path that starts or advances a run without a poller.
  3. A stable identifier shared by the email conversation and the graph thread.
  4. 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:

Propertyinterrupt() inside the graphInbox policy gate
Survives a worker crashOnly with a durable checkpointerYes, the hold is server-side
Binds to the exact reviewed contentWhatever state holds at resumeSHA-256 over the canonical payload
Applies to other callersNoYes, every caller on that inbox
Re-runs code on resumeYes, from the top of the nodeNo, there is nothing to resume
Reviewer sees a sanitized projectionYou build itReturned on the approval record
Cost of a queued draftA live checkpointNothing 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 scopePresetTools the model can see
email.readreadlist_inboxes, list_threads, list_messages, get_message
email.draftread_draftthe four above, plus create_draft
email.sendread_draft_sendthe 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 produceWhat the second send returnsWhat arrives
Identical body, second arrives after the first completesthe first response, replayedone email
Identical body, second arrives while the first is still in flight503 temporarily_unavailable, “A matching send is already in progress.”one email
Different body, same key409 idempotency_conflictone 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

All guides · Documentation · Integrations