MailFlatDocs
Documentation/AI agents & assistants/LangChain

Using MailFlat with LangChain

MailFlatToolkit turns the Python SDK into LangChain tools, so an agent can open inboxes and read verification codes as part of its normal tool loop.

How it fits together

The toolkit is a thin shell over the same client you would call directly: every tool wraps one SDK method and returns a plain dict, and errors come back as {"error": "..."} instead of raising. That matters for an agent: a raised exception ends the run, while a returned error is something the model can read and work around.

The tools

The same twelve tools the MCP server and the Vercel AI SDK suite expose, including delete_message, which this toolkit used to withhold. Withholding it protected nothing: delete_inbox was always on the list, and it removes every message plus the address.
ToolArgumentsWhat it does
create_inboxprefix? · label? · retention_hours?Opens a real inbox and returns its address.
list_inboxesnoneEvery inbox this API key can see.
read_messagesaddress · direction?Messages in an inbox, newest first. Received mail unless you ask for 'out' or 'all'.
wait_for_otpaddress · timeout?Polls until a one-time code arrives, then returns it.
wait_for_messageaddress · timeout?Polls until a new message ARRIVES. Ignores mail the agent itself sent.
send_emailaddress · to · subject? · body? · html? · cc? · bcc?Sends a DKIM-signed email from the inbox. Accepted for delivery (202); the queue does the sending.
replyaddress · message_id · body? · html? · cc? · bcc?Answers a message in the SAME conversation. Recipient, Re: subject and threading headers are filled in.
wait_until_sentaddress · message_id · timeout?Answers 'did that mail actually go out?'. delivered:true once it did; a timeout means STILL QUEUED, not lost, so don't resend.
mark_readaddress · message_idMarks one message read, so the next poll can skip it.
burn_inboxaddressDeletes every message but KEEPS the address.
delete_inboxaddressDeletes the inbox and every message in it.
delete_messageaddress · message_idDeletes one message; the inbox itself stays.

Install

langchain-core is an optional extra, so the plain package stays dependency-free for people who only want the client.
Shell
pip install "mailflat[langchain]"

Build the tools

get_tools() returns the twelve tools as LangChain BaseTool objects, ready for any agent that accepts a tool list.
Python
import os
from mailflat.langchain import MailFlatToolkit
 
toolkit = MailFlatToolkit(api_key=os.environ["MAILFLAT_API_KEY"])
tools = toolkit.get_tools()
 
print([t.name for t in tools])
# ['create_inbox', 'list_inboxes', 'read_messages', 'wait_for_otp',
# 'wait_for_message', 'send_email', 'reply', 'wait_until_sent',
# 'mark_read', 'burn_inbox', 'delete_inbox', 'delete_message']

The async toolkit, when the agent runs many inboxes at once

AsyncMailFlatToolkit builds the same twelve tools with coroutine= bound, so ainvoke awaits instead of parking a thread. The names and schemas are identical. Swapping the toolkit changes how the tools wait, not what the model sees.
Python
import asyncio
from mailflat.aio import AsyncMailFlat
from mailflat.langchain import AsyncMailFlatToolkit
 
async def main():
async with AsyncMailFlat() as mf:
toolkit = AsyncMailFlatToolkit(client=mf)
tools = toolkit.get_tools()
 
# Twenty inboxes waiting for their code in ONE event loop.
inboxes = await asyncio.gather(*(mf.create(label=f"run-{i}") for i in range(20)))
codes = await asyncio.gather(*(i.wait_for_otp(timeout=60) for i in inboxes))
print(codes)
 
asyncio.run(main())
The sync toolkit still works under ainvoke, because LangChain just runs each call in a thread. That is fine for one inbox and expensive for twenty.

Hand them to an agent

Nothing about MailFlat is special here: the tools go in the same list as the rest, and the model decides when an inbox is needed.
Python
from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
 
llm = init_chat_model("claude-opus-5", model_provider="anthropic")
agent = create_react_agent(llm, tools)
 
result = agent.invoke({
"messages": [(
"user",
"Open an inbox labelled langchain-demo, tell me its address, "
"then wait up to 60 seconds for a one-time code and report it.",
)],
})
print(result["messages"][-1].content)

Give it fewer tools than you have

An agent that only reads mail has no reason to hold delete_inbox. Filtering the list is the whole mechanism. There is no separate permission model.
Python
READ_ONLY = {"create_inbox", "read_messages", "wait_for_otp"}
tools = [t for t in toolkit.get_tools() if t.name in READ_ONLY]
Pair this with a short retention_hours so inboxes still disappear even though the agent cannot delete them.

Share one client between the toolkit and your own code

Pass a client in and the toolkit stops building its own. Useful when your code and the agent must talk to the same base URL, or when a test injects a fake.
Python
from mailflat import MailFlat
from mailflat.langchain import MailFlatToolkit
 
mf = MailFlat(base_url="https://mail.example.com")
toolkit = MailFlatToolkit(client=mf)
 
inbox = mf.create(label="shared", retention_hours=2) # your code
agent_tools = toolkit.get_tools() # the agent's

Worth knowing

Tools return errors as data, not exceptions, so a model that asks for a code that never arrives sees {"error": "..."} and can decide to wait again. Log those returns; a silent retry loop is expensive.
wait_for_otp blocks the whole agent while it polls. Keep the timeout to what the mail actually needs (30-60 seconds) instead of the largest number that feels safe. Reach for AsyncMailFlatToolkit when several inboxes wait at once, so the waiting happens on the event loop rather than in one thread per inbox.
An encrypted inbox returns an error rather than a code, by design: the server cannot decrypt it. Create agent inboxes without encryption.