Integrations · September 23, 2026

A LangChain Email Tool That Can Receive

A langchain email tool that receives as well as sends: the current ToolRuntime interface, idempotency keys that survive a resume, and which events fire.

Almost every published langchain email tool is one function: take a recipient, a subject and a body, hand them to SMTP or the Gmail API, return the string "Email sent to alice@example.com". It works in a demo because the demo ends there. Nothing in that design tells the agent whether the message left the building, and nothing at all tells it that a reply came back four hours later. The agent has a mouth and no ears.

This piece builds both halves against a documented REST surface: the send tool and the header it cannot omit, the inbound path from event to body, and the reply that lands in the same thread. It also fixes the return value, because "Email sent" is a claim the system never made. If you want the same loop without a framework in the way, read the agent email API loop. Other client wiring is on the integrations hub.

What the ranking LangChain email examples actually give you

Three shapes dominate the results, and they fail in different places.

The Gmail toolkit is the official one, installed as langchain-google-community[gmail], and it exposes five tools: GmailCreateDraft, GmailSendMessage, GmailSearch, GmailGetMessage and GmailGetThread (checked on LangChain’s integrations docs, 23 September 2026). Reading works. Notification does not. There is no push in that toolkit, so an agent learns about a reply by calling GmailSearch again on a timer, and the mailbox it reads is a human’s.

The tutorial tool is the SMTP function above. It returns a string, so every distinction the transport made collapses before the model sees it.

The vendor package is the interesting one, and the honest note here is that it is not send-only. AgentMail’s langchain-agentmail, read on its own README on 23 September 2026, ships fourteen tools, from agentmail_list_inboxes through agentmail_reply_to_message and agentmail_get_attachment, plus a [webhooks] extra that installs a FastAPI router with svix-compatible signature verification that returns 401 on a bad signature. If you were told nobody in this space does inbound for LangChain, that was wrong when you heard it.

What none of the three settle is the part that bites in production: what a successful send actually proves, what to do when the same tool call runs twice, which events genuinely arrive, and how much of an inbound email you are holding when one does. Those are the sections that follow the setup.

Check LangChain’s tool interface before you copy a 2024 snippet

The interface moved, and most email snippets on the web predate the move. As of LangChain’s current Python docs, read today:

That last point is the one to design around. Credentials, project IDs and inbox IDs belong on the runtime side, where the model cannot see them and therefore cannot be argued into changing them. The model should see to, subject and body, and nothing that identifies an account.

Do not take the bullet list above on faith either. Read the tools page in LangChain’s docs before you write the file; this is a surface that has been renamed twice, and a snippet that imports from the wrong module fails at import time if you are lucky and silently binds the wrong object if you are not.

The send tool, and the header that is not optional

The REST base is https://api.emailforagents.ai/v1. Credentials go in Authorization: Bearer. The send path is

POST /v1/projects/{project_id}/inboxes/{inbox_id}/messages

and it requires an Idempotency-Key header. This is not a recommendation. The route reads the header before it parses the body, so a request without one fails with HTTP 400 invalid_request and the message “Idempotency-Key is required for send operations.” before your JSON is ever validated (packages/api/src/index.ts:237, status from packages/contracts/src/errors.ts). A tool that omits the header does not send fewer duplicates, it sends nothing, and the 400 it gets back says nothing about the body you were debugging.

import httpx
from langchain.tools import tool, ToolRuntime

API = "https://api.emailforagents.ai/v1"


@tool
def send_email(to: str, subject: str, body: str, runtime: ToolRuntime) -> dict:
    """Send an email from the agent's own inbox. Returns the accepted outcome,
    which is not a delivery confirmation."""
    ctx = runtime.context
    r = httpx.post(
        f"{API}/projects/{ctx.project_id}/inboxes/{ctx.inbox_id}/messages",
        headers={
            "Authorization": f"Bearer {ctx.api_key}",
            "Idempotency-Key": f"{ctx.task_id}:{runtime.tool_call_id}",
            "Content-Type": "application/json",
        },
        json={
            "to": [{"email": to}],
            "subject": subject,
            "text": body,
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

Two shape notes that cost people an afternoon. to is an array of objects with an email field, not a bare string, and the request schema is strict, so one unknown top-level key is a 400 rather than an ignored field.

The response is HTTP 202 in two different situations. result is the discriminator. "queued" carries message_id, thread_id and state. "approval_required" carries draft_id, approval_id and reason_codes, and means no message exists at all: a human has to act in the console before anything is transmitted. Returning a dict rather than a string keeps both shapes intact, which is the minimum the model needs to avoid announcing a send that did not happen.

Where the idempotency key should come from in an agent loop

Derive it from the thing you meant to send, not from the attempt. In an agent loop you have three candidates and they protect against different failures.

Key sourceSurvivesDoes not protect against
uuid4() inside the toolnothingevery retry, including the HTTP client’s own
runtime.tool_call_idHTTP retries, a crashed and resumed runthe model re-planning and emitting a second tool call
a business identifier you controlall of the abovenothing, if the identifier is genuinely unique per mail

runtime.tool_call_id is the cheap correct answer for the harness-level failures, and it is stable across a resume because it is part of the recorded tool call rather than something generated at execution time. It is not enough on its own. If the model decides its first attempt failed and calls send_email again with the same arguments, that is a new tool call with a new ID, and you get a second email. Prefix the key with an identifier for the unit of work, as the example above does with ctx.task_id, so both layers are covered.

The header is bound to the payload, not to the endpoint: the request is normalised and hashed, so reusing a key with a changed body is an error rather than a second message. Treat HTTP 409 idempotency_conflict as a bug in how you derive keys, not as something to retry around. The replay window, the tombstone period and the states that must never be auto-retried are set out in the idempotency rules for agent sends.

accepted is not delivered, and message.accepted fires from only one of two paths

A 202 with result: "queued" means the message is stored and accepted for processing. No provider has been contacted. Later the send intent may move to accepted, which means one hop succeeded: the upstream provider took the submission. Neither state says a recipient received anything, and a message to five recipients has five independent delivery outcomes that are allowed to disagree.

That much is in the state table. The part that is not, and that decides whether a LangChain agent resumes or hangs, is which of the two code paths into accepted emits an event.

The ordinary path is the send worker. It submits to the provider, writes the resulting state, and emits message.${state} where state is one of accepted, failed or submission_unknown (packages/domain/src/services.ts:4459). Because the type is built by interpolation rather than written out as a literal, grepping the source for the string "message.accepted" finds only the declaration in packages/contracts/src/events.ts and misses the emit entirely. It does fire.

The second path is reconciliation. When a worker dies mid-submission the next lease finds the intent in submitting, moves it to submission_unknown with last_error_code='interrupted_submission', and returns without emitting anything at all (services.ts:4359). Nothing moves it on afterwards. The callback handler has a branch that would flip submitting or submission_unknown to accepted (services.ts:4561), but it finds the 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 a later callback matches nothing and the branch never runs for it (why it stays unknown).

So the rule to build against: message.accepted is emitted only when the provider answered the submission in-band, and an unknown send never becomes accepted on its own. That unknown send is the case where an agent most wants to hear “it went out after all”, and no event will ever say so. An agent that blocks on message.accepted therefore hangs on exactly the ambiguous sends. Poll the message state instead, or treat message.accepted as an optimisation and the read as the source of truth.

submission_unknown deserves a branch of its own. The system deliberately stops rather than retrying, because the submission may already have been taken. Your tool must not round that down to “failed”, because that is how a human presses resend on a message that already went out. Surface the state and the last_error_code verbatim and let the run halt. What each message status actually proves is the diagnosis order.

Which events exist, and the three ways message.delivery_updated goes missing

packages/contracts/src/events.ts declares eighteen event types. Eleven have an emit site in this build:

inbox.created   domain.updated    message.received   message.queued
message.accepted  message.submission_unknown  message.failed
message.delivery_updated  approval.requested  approval.rejected  webhook.test

Seven are declared and never fire: inbox.updated, inbox.deleted, message.canceled, message.quarantined, approval.approved, approval.invalidated and project.sending_paused. A webhook subscription accepts all seven and they are simply never delivered, which is a miserable bug to chase, because the subscription succeeds and the handler type-checks. In particular, an agent that blocks until approval.approved arrives blocks permanently; watch for message.queued on the resulting message instead.

message.delivery_updated is the one that carries per-recipient outcomes, and it is conditional in three ways the catalogue does not show.

First, the provider’s own vocabulary is not yours. Upstream speaks email.sent, email.delivered, email.bounced, email.complained, email.suppressed, email.delivery_delayed and email.failed. Those strings never reach your project’s event stream. Six of them map to a status of delivered, bounced, complained, suppressed, delayed or failed and are re-emitted as a single message.delivery_updated carrying message_id, recipient and status (services.ts:4540). Type a LangChain tool against the email.* names and you are typing against an API you are not talking to.

Second, email.sent is the exception. The handler counts it as proof of acceptance, but it has no entry in the status map. A send whose only callback is email.sent therefore produces no event at all: not message.accepted, because the callback path does not emit it, and not message.delivery_updated, because there is no status to report.

Third, the handler resolves a recipient row only when the callback names exactly one address. A payload listing two or more recipients matches nothing, so the status is dropped and no event is emitted for any of them. The comment in source is explicit that an aggregate payload cannot establish which address bounced and must never suppress all recipients, which is the right call and also means your handler cannot rely on hearing about every recipient.

One more ordering trap. The write to message_recipients is guarded by a rank so a late delayed cannot overwrite a stored delivered. The emit is not inside that guard. An out-of-order callback therefore emits a message.delivery_updated whose status is behind what the database now holds. Treat the status on the event as a hint and the message read as authoritative, or a LangChain node that updates state from the event body will walk a thread backwards from delivered to delayed. Whether you consume these by webhook or by cursor, the events and webhooks reference is the list to check before writing a handler.

While reading the catalogue, note that the audit trail is a different namespace. inbox.policy.updated is an audit action recorded in packages/domain/src/customer-controls.ts, not an event type, so it never appears on a webhook however carefully you subscribe.

The receive half: an event tells you a message exists, not what it says

This is where the send-only examples end and the real work starts.

An inbound email produces message.received, and the payload is exactly five fields:

{
  "message_id": "msg_...",
  "thread_id": "thd_...",
  "inbox_id": "ibx_...",
  "environment": "live",
  "simulated": false
}

No sender. No subject. No body. That is deliberate, and it means the event is a trigger, not a delivery of content. Reading one inbound email is three calls: the event, then the message metadata from /projects/{project_id}/inboxes/{inbox_id}/threads/{thread_id}/messages, then the body from /projects/{project_id}/inboxes/{inbox_id}/messages/{message_id}/body.

Skipping the third call is the common shortcut, and it is wrong. The message listing carries a preview, which is a truncation, not the email. Build a summariser on preview and it will confidently describe the first line of a thread as the whole message.

The body endpoint has one response field that decides how your tool should behave:

{ "text": "...", "html": "...", "original_raw_available": false, "body_available": true }

When a message has no stored content manifest the endpoint still returns 200, with text set to the preview, html set to "", and body_available set to false (packages/domain/src/services.ts:3508). A tool that returns payload["text"] and nothing else hands the model a truncated preview that is indistinguishable from a short email. Return the flag and say what it means:

@tool
def read_email(message_id: str, runtime: ToolRuntime) -> dict:
    """Fetch the full text of one received email. If body_available is false,
    only a truncated preview exists and you must not treat it as the message."""
    ctx = runtime.context
    r = httpx.get(
        f"{API}/projects/{ctx.project_id}/inboxes/{ctx.inbox_id}"
        f"/messages/{message_id}/body",
        headers={"Authorization": f"Bearer {ctx.api_key}"},
        timeout=30,
    )
    r.raise_for_status()
    d = r.json()
    return {
        "text": d["text"],
        "complete": d["body_available"],
        "note": None if d["body_available"] else "truncated preview only",
    }

Two operational points. The credential needs messages:read and threads:read for this path, and events:read for the cursor. And whatever arrives here is untrusted input written by a stranger, so it is data for the model to read, never instruction for it to follow.

Replying into the same thread

Pass the inbound message’s ID as reply_to_message_id on an otherwise ordinary send. The parent’s thread is reused and the reply takes the next sequence number in it. Recipients and subject are still explicit; the field threads the message, it does not address it.

{
  "to": [{"email": "ap@example.com"}],
  "subject": "Re: Receipt for invoice 4471",
  "reply_to_message_id": "msg_7f21c...",
  "text": "Corrected receipt attached.",
}

The parent must live in the same inbox, and the credential needs messages:read on top of its send scope, because replying reads the thread as well as writing to it (packages/domain/src/services.ts:1541). reply_to_message_id is part of the canonical payload that the idempotency key is hashed over, so two replies to different parents cannot collide on one key even with identical bodies.

What this field does not control is what the recipient’s client shows. Threading is resolved into In-Reply-To and References at submission time, and Gmail or Outlook then group by their own rules. How an agent’s replies stay in the right thread covers the header mechanics and the ways grouping breaks anyway.

Rehearsing both directions before anything is routable

You can exercise the whole loop, including inbound, without a message crossing a transport. A test project’s inboxes get a non-routable .mail.invalid address and a simulated transport, and

POST /v1/projects/{project_id}/test/inbound

injects a synthetic message into one of them. The body takes inbox_id, from, to, subject and one of text or html. It requires both test:simulate and messages:read on the credential, and it returns HTTP 403 test_mode_required on a live project (packages/domain/src/services.ts:2123, status mapped in packages/contracts/src/errors.ts). The resulting message.received event is identical in shape to a live one except that environment is "test" and simulated is true, so your LangChain handler takes the same branch it will take in production.

Note what this is and is not. A test inbox cannot reach an internet recipient and nothing is transmitted into it, so what you are rehearsing is your own handler, not deliverability. That is a narrower thing than a routable sandbox mailbox receiving a real message from outside, and the two are not interchangeable. The sandbox checklist is in testing an agent’s email without sending real mail.

Environment cannot be crossed by configuration. An efa_test_ key resolves only to test projects and test inboxes, and no flag points it at live, so the failure mode of a bad deploy is a rejected request rather than an email to a customer.

What this does not give you

Four limits worth knowing before you wire this into a LangChain agent.

Live mail runs on agents.emailforagents.ai. Custom domains require a paid plan and those plans are not purchasable yet, so the agent’s address will carry that subdomain.

The free allowance is one live inbox and 100 email units a month per verified owner identity, where a unit is one message received or one recipient sent to. A reply-all to four people costs four. When the allowance runs out, sending stops and received mail stays readable.

There is no push into LangChain. You subscribe a webhook or poll the project event cursor, and either way something you own has to receive the callback and start or resume the agent. The framework has no inbound transport of its own.

And acceptance is never proof of receipt. The best the return value can honestly say is which hop succeeded, which is exactly why it should be a structured object rather than the word “sent”.


Keep reading

All guides · Documentation · Integrations