# LangGraph Email Integration: Inbound Events and Replies

Published: August 24, 2026

Wire email into LangGraph: inbound webhooks as graph events, send and reply nodes, and human-in-the-loop interrupts resumed by an email reply.

A LangGraph email integration has three moving parts: an inbound webhook that turns received mail into a graph invocation, a node that sends or replies through an email API, and a checkpointer so a paused graph can be resumed days later when a human answers. The trick that makes it all work is mapping the email thread ID to the LangGraph `thread_id`, so every reply lands in the same durable graph state.

This post covers the LangGraph-specific patterns: events in, send/reply nodes, and `interrupt()` driven human-in-the-loop over email. If you are wiring email into a plain agent executor or a chain of tool calls, the [LangChain email integration guide](/blog/langchain-email-integration) is the closer fit.

## What makes LangGraph different from a tool-calling agent

In a tool-calling agent, email is just a tool the model may or may not call. In LangGraph, email is better modeled as two separate things:

| Concern | LangGraph mechanism |
| --- | --- |
| A message arrived | An external event that invokes or resumes a graph |
| The agent wants to send | A node (or a tool bound to a node) that calls the email API |
| Waiting on a human | `interrupt()` plus a checkpointer, resumed by `Command(resume=...)` |
| Conversation continuity | `thread_id` in the config, keyed to the email thread |

That last row is the one people get wrong. If you generate a fresh `thread_id` per webhook, the agent forgets everything it said in the previous message and starts negotiating with itself.

## Step 1: give the graph its own mailbox

The agent needs a real address that can receive, not just a sending API key. Create one with the CLI:

```bash
npx @robotomail/cli mailbox create langgraph-agent
```

Or over the API:

```bash
curl -X POST https://api.robotomail.com/v1/mailboxes \
  -H "Authorization: Bearer rm_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"address": "langgraph-agent"}'
```

One mailbox per graph deployment is a reasonable default. If you run a supervisor with several subgraphs that each talk to different counterparties, give each one its own address so replies route without a classifier. More on that pattern in [multi-agent workflows](/use-cases/multi-agent-workflows), and background on the general approach in [how to give your AI agent an email address](/how-to-give-your-ai-agent-email-address).

Then register a webhook pointing at your app so inbound mail is delivered as JSON. Payloads look like this:

```json
{
  "event": "message.received",
  "data": {
    "message_id": "...", "mailbox_id": "...", "mailbox_address": "langgraph-agent@robotomail.co",
    "from": "vendor@example.com", "subject": "Re: Order #A-4521",
    "body_text": "...", "thread_id": "...", "received_at": "2026-04-17T10:00:00.000Z"
  }
}
```

## Step 2: inbound mail as graph events

The webhook handler should do almost nothing: validate, dedupe, hand off to a background task, return 200. Graph runs involve model calls that can take tens of seconds, and a webhook delivery that times out gets retried, which means duplicate runs.

```python
from fastapi import BackgroundTasks, FastAPI, Request
from langgraph.types import Command

app = FastAPI()
seen = set()  # use Redis or a table in production

@app.post("/hooks/robotomail")
async def inbound(req: Request, tasks: BackgroundTasks):
    payload = await req.json()
    if payload.get("event") != "message.received":
        return {"ok": True}

    d = payload["data"]
    if d["message_id"] in seen:
        return {"ok": True, "duplicate": True}
    seen.add(d["message_id"])

    tasks.add_task(run_graph, d)
    return {"ok": True}


def run_graph(d: dict) -> None:
    config = {"configurable": {"thread_id": d["thread_id"]}}
    snapshot = graph.get_state(config)

    if snapshot.next:
        # Graph is paused on an interrupt: this email is the answer.
        graph.invoke(Command(resume=d["body_text"]), config=config)
    else:
        graph.invoke(
            {
                "inbound_message_id": d["message_id"],
                "reply_to": d["from"],
                "subject": d["subject"],
                "messages": [{"role": "user", "content": d["body_text"]}],
            },
            config=config,
        )
```

Two details worth calling out.

**Use the email `thread_id` as the LangGraph `thread_id`.** The email API already computes threading from `Message-ID`, `In-Reply-To` and `References`, so you inherit correct grouping for free instead of guessing from subject lines. See [threading concepts](/docs/concepts/threading) for how that is derived.

**Check `snapshot.next` before deciding what to invoke.** A non-empty `next` means the graph is parked mid-run, which is exactly the human-in-the-loop case in step 4. A paused graph invoked with fresh input rather than a `Command` will restart from the interrupt boundary and re-ask the question.

You also need a real checkpointer. `MemorySaver` loses every paused conversation on deploy, and email conversations routinely span days. Use the SQLite or Postgres saver.

## Step 3: send and reply nodes

Sending is a plain HTTP call, so it works as a node or as a tool bound to a ReAct-style node. Making it an explicit node is usually better in LangGraph: you get a deterministic place to enforce policy, log the outbound message, and short-circuit loops.

```python
import os, requests

API_KEY = os.environ["ROBOTOMAIL_API_KEY"]
MAILBOX = "mbx_shopping"


def reply_node(state: dict) -> dict:
    r = requests.post(
        f"https://api.robotomail.com/v1/mailboxes/{MAILBOX}/messages",
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "to": [state["reply_to"]],
            "subject": state["subject"],
            "bodyText": state["draft"],
            "inReplyTo": state["inbound_message_id"],
        },
        timeout=30,
    )
    r.raise_for_status()
    return {"turns": state.get("turns", 0) + 1, "draft": None}
```

`inReplyTo` carries the inbound message id and is what keeps the reply in the same visible thread in the recipient's client. Drop it and your reply shows up as a new conversation, which is how agents end up looking broken to the humans reading them.

For first-contact sends, omit `inReplyTo`:

```bash
curl -X POST https://api.robotomail.com/v1/mailboxes/mbx_shopping/messages \
  -H "Authorization: Bearer rm_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"to": ["vendor@example.com"], "subject": "Order #A-4521 delivery update?", "bodyText": "Hi, checking on the status of order A-4521."}'
```

### Guard the loop

Email agents can talk to each other, and to auto-responders, indefinitely. We run mail infrastructure, so we see these loops. Add a conditional edge before `reply_node`:

- Cap `turns` per thread (5 to 10 is plenty for most workflows) and route to a `handoff` node beyond that.
- Skip replies when the inbound `from` matches an automated pattern such as a bounce address, `no-reply@`, or a vacation autoresponder.
- Never reply to a message whose sender is the mailbox's own address.

## Step 4: human-in-the-loop interrupts over email

This is the pattern LangGraph is genuinely good at, and email is a natural approval channel because your reviewer already lives there. The graph pauses, emails a human, and resumes when the human replies. No dashboard, no Slack app.

```python
from langgraph.types import interrupt


def approval_node(state: dict) -> dict:
    if state["order_total"] < 100:
        return {"approved": True}

    requests.post(
        f"https://api.robotomail.com/v1/mailboxes/{MAILBOX}/messages",
        headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
        json={
            "to": ["ops@yourcompany.com"],
            "subject": f"Approve purchase: {state['vendor']} ${state['order_total']}",
            "bodyText": (
                f"{state['draft']}\n\n"
                "Reply APPROVE to send, or reply with edits."
            ),
        },
        timeout=30,
    ).raise_for_status()

    answer = interrupt({"awaiting": "approval", "total": state["order_total"]})

    if answer.strip().upper().startswith("APPROVE"):
        return {"approved": True}
    return {"approved": False, "draft": answer}
```

`interrupt()` raises out of the run and persists state under the current `thread_id`. Days later, the reviewer's reply hits your webhook, `snapshot.next` is non-empty, and `Command(resume=d["body_text"])` returns the email body as the value of `answer`. The node re-executes from the top on resume, so keep the send above the interrupt idempotent, or set a flag in state such as `approval_email_sent` and check it before sending.

Two practical notes:

- **Route approvals to a separate mailbox** if the same graph also converses with outsiders. A dedicated `approvals@` address means you never have to classify whether an inbound message is a human approval or a vendor reply. It also makes the `snapshot.next` branch unambiguous.
- **Strip quoted history** before feeding a reply into `resume`. Mail clients append the entire prior thread; the raw body will contain your own question. A simple heuristic that cuts at the first line starting with `>` or `On ... wrote:` handles most clients. The parsing details are in [how to parse an inbound email webhook](/blog/parse-inbound-email-webhook).

## Deployment notes

- **Timeouts.** Webhook delivery expects a fast 200. Always hand the graph run to a queue or background task.
- **Retries.** Deduplicate on `message_id`, not on the payload hash. Retried deliveries are byte-identical, but so is a legitimate resend from a persistent counterparty.
- **Attachments.** Fetch them in a node rather than inline in the webhook handler, and keep the bytes out of graph state. Store a reference. Serializing a 10 MB PDF into every checkpoint gets expensive fast.
- **Ordering.** Two replies can arrive seconds apart on the same thread. LangGraph will not serialize concurrent invocations of the same `thread_id` for you, so use a per-thread lock or a single-consumer queue keyed by `thread_id`.
- **Local testing.** Point the webhook at a tunnel and send mail to the mailbox by hand before wiring the model in. The [receive and reply guide](/docs/guides/receive-and-reply) has a working end-to-end loop you can copy.

## FAQ

### Do I need LangGraph Platform for email interrupts?

No. Interrupts and resumes work with self-hosted LangGraph as long as you configure a durable checkpointer such as the Postgres or SQLite saver and can look up state by `thread_id`. Platform gives you managed persistence and a task queue, which saves work but is not required.

### Should the email thread ID be my LangGraph thread ID?

Usually yes, it is the simplest correct mapping. Use a separate mapping table if one graph run spans several email threads, for example a recruiting agent talking to a candidate and a hiring manager in parallel, then key the graph by your own conversation ID and store the email thread IDs in state.

### How do I stop the agent replying to itself?

Cap turns per thread in state, filter senders that look automated, and drop any inbound message whose `from` equals the mailbox address. Enforce this in a conditional edge before the send node so no model decision can bypass it.

### Can several graphs share one mailbox?

They can, but you then need to classify inbound mail to pick the right graph. Provisioning one mailbox per agent by API is cheap and removes the classifier entirely. Details in [mailbox concepts](/docs/concepts/mailboxes).

Ready to wire it up? Create a mailbox, register your webhook, and point your graph at it: get started at [robotomail.com](https://robotomail.com).
