Python Email API: Send Mail and Get Answers Back in the Same Thread (2026)
Use a Python email API to give your agent its own inbox: send, read replies and the thread, get new mail over a WebSocket, and reply in the same thread.
- python
- sdk
- tutorial

TL;DR
Sending email from Python takes a few lines, but an agent also has to read the answer and reply inside the same conversation. This tutorial gives a Python program an inbox of its own and walks the whole loop, from the first send to a reply in the same thread. By the end you will have code that runs end to end and a list of the failures worth planning for.
Most Python email tutorials stop at the send. The script opens an SMTP connection or calls a send endpoint, the message leaves, and the program exits. Any reply goes to an address nobody reads. That's fine for a password reset. It fails for an agent, because an agent is supposed to read the answer and act on it.
The missing piece is an inbox the code owns. With one, the program that sent the message can list what came back and load the full conversation. Its answer then sits under the original message in the other person's mail client. No mail server, no IMAP polling, no MIME parsing.
The sections follow the order you build in: check the setup, create the inbox, send, read, listen for new mail, and reply. The last two cover what breaks in production and what we leave to your code.
How do you send and receive email from Python?
You send and receive email from Python by giving the program its own inbox through the AgentMail Python SDK and running the whole conversation through it. Install the agentmail package and create an inbox. Send from it with inboxes.messages.send. Replies land in that same inbox, so inboxes.messages.list returns them and inboxes.threads.get loads the conversation. To answer, inboxes.messages.reply sends into the existing thread.
That is five calls. For new mail without a polling loop, the SDK opens a WebSocket and pushes each message as an event. A webhook does the same job for a server with a public URL.
Pick by situation. The program only sends and nobody needs the answer: Python's built-in smtplib and any send API will do. The reply is the point, as with support, scheduling or approval agents: give the program an inbox. If you are still choosing between the SDK, an MCP server and raw HTTP, MCP, SDK or REST for agent email compares the three.
Prerequisites
You need Python 3.10 or newer, the agentmail package pinned to 2.0.25, and an API key. The SDK sets 3.10 as its minimum, so pip won't install it on older interpreters. Create a key in the AgentMail Console and export it as AGENTMAIL_API_KEY, because every snippet here reads it from the environment. Each line below checks one requirement.
Bash
# check the interpreter, install the pinned SDK, confirm the version and the keypython3 -c "import sys; print(sys.version_info >= (3, 10))"python3 -m pip install --quiet agentmail==2.0.25python3 -c "import importlib.metadata as m; print(m.version('agentmail'))"python3 -c "import os; print('AGENTMAIL_API_KEY' in os.environ)"# output: True# output: 2.0.25# output: TrueHow do I create an inbox in Python?
An inbox is an email address with its own stored mail that your code controls through the API. Create one per agent with inboxes.create. Pass a client_id so a retried request returns the inbox it already made instead of a second one.
Python
# create the agent's inbox; client_id makes a retry return the same inboximport osfrom agentmail import AgentMailfrom agentmail.inboxes import CreateInboxRequestclient = AgentMail(api_key=os.environ["AGENTMAIL_API_KEY"])inbox = client.inboxes.create( request=CreateInboxRequest(username="invoice-agent", client_id="invoice-agent-v1"))print(inbox.inbox_id)# output: the new inbox id, which is the address itself, ending in @agentmail.toThe inbox id is the email address. Without a domain, the address is on agentmail.to. Export the id as AGENTMAIL_INBOX_ID for the rest of the tutorial. Build the client_id from something stable, such as the agent's name or your user id. Leave out the @ sign. The field doesn't accept it.
How do I send an email in Python?
inboxes.messages.send takes the inbox id, a recipient, a subject and a body. It returns the id of the new message and the id of its thread. Keep the thread id. Every later step in the conversation hangs off it.
The example sends to the inbox itself. That lets you run the full loop alone, without waiting on a second person. In production, to is your customer.
Python
# send from the agent's inbox and print the ids the reply step needsimport osfrom agentmail import AgentMailclient = AgentMail(api_key=os.environ["AGENTMAIL_API_KEY"])inbox_id = os.environ["AGENTMAIL_INBOX_ID"]sent = client.inboxes.messages.send( inbox_id, to=inbox_id, subject="Invoice 4417 is ready", text="Invoice 4417 is in the billing portal. Reply here with any questions.",)print(sent.message_id)print(sent.thread_id)# output: <010001a1c7f589db-2390b2ca-2855-49c7-a58a-4cec7faebc23-000000@email.amazonses.com># output: e2dc63dd-74da-4063-b091-abfb53ad0508Add html next to text when you want a formatted body. The to, cc and bcc fields each take one address or a list.
How do I read incoming mail and the thread?
Every message that reaches the inbox is stored, so reading mail is a list call rather than a parsing job. inboxes.messages.list returns a response whose .messages attribute holds the newest messages first. Filtering on the received label returns only mail that arrived in the inbox. inboxes.threads.get returns the whole conversation, which is what an agent needs to see before it answers.
Python
# list received mail, then load the thread the newest message belongs toimport osfrom agentmail import AgentMailclient = AgentMail(api_key=os.environ["AGENTMAIL_API_KEY"])inbox_id = os.environ["AGENTMAIL_INBOX_ID"]received = client.inboxes.messages.list(inbox_id, labels=["received"], limit=10)latest = received.messages[0]print(latest.subject, latest.labels)thread = client.inboxes.threads.get(inbox_id, latest.thread_id)print("messages in thread:", thread.message_count)for message in thread.messages: body = message.extracted_text or message.text print(message.labels, body.splitlines()[0])# output: Invoice 4417 is ready ['sent', 'received', 'unread']# output: messages in thread: 1# output: ['sent', 'received', 'unread'] Invoice 4417 is in the billing portal. Reply here with any questions.Because the example mailed itself, its one message carries both the sent and received labels. Each message also carries text and extracted_text. The extracted version is the new content without the quoted history underneath. Feed that to the agent and old replies stay out of its context.
How do I get new mail without polling?
A WebSocket keeps one connection open and delivers each new message as an event. You don't need a public URL or a loop asking the API every few seconds. The Python SDK ships sync and async WebSocket clients; the sync one below subscribes to the inbox and stops at the first message.received event.
Python
# wait on a WebSocket for the next message instead of pollingimport osfrom agentmail import AgentMail, Subscribe, Subscribed, MessageReceivedEventclient = AgentMail(api_key=os.environ["AGENTMAIL_API_KEY"])inbox_id = os.environ["AGENTMAIL_INBOX_ID"]with client.websockets.connect() as socket: socket.send_subscribe(Subscribe(inbox_ids=[inbox_id], event_types=["message.received"])) for event in socket: if isinstance(event, Subscribed): print("subscribed to", len(event.inbox_ids), "inbox") elif isinstance(event, MessageReceivedEvent): print("new mail:", event.message.subject) break# output: subscribed to 1 inbox# output: new mail: Question about invoice 4417Start it, then send a message to the inbox from another terminal or any mail client. The loop prints the subject and exits. In an asyncio app, AsyncAgentMail gives the same client with async with and async for.
A server that already has a public URL can take a webhook instead, and we POST a message.received event to it. Webhooks have two rules that catch people out, both covered in the failure list further down. Before production, test inbound email webhooks with a throwaway inbox. The handler side is in how to trigger an agent when email arrives.
How do I reply in the same thread?
inboxes.messages.reply takes the inbox id and the id of the message you are answering, and the reply joins that message's thread. Pass to explicitly. The block also sends an Idempotency-Key header built from the message id. A retry after a timeout then returns the first reply instead of sending a second.
Python
# answer the newest received message inside its own thread, onceimport osimport uuidfrom agentmail import AgentMailclient = AgentMail(api_key=os.environ["AGENTMAIL_API_KEY"])inbox_id = os.environ["AGENTMAIL_INBOX_ID"]latest = client.inboxes.messages.list(inbox_id, labels=["received"], limit=1).messages[0]key = str(uuid.uuid5(uuid.NAMESPACE_URL, latest.message_id))reply = client.inboxes.messages.reply( inbox_id, latest.message_id, to=[latest.from_], text="Thanks for the note. Invoice 4417 is due in 30 days.", request_options={"additional_headers": {"Idempotency-Key": key}},)print(reply.thread_id == latest.thread_id)# output: TrueThe comparison prints True because the reply and the message it answers share one thread id. The same flow from a shell, with raw HTTP under each step, is in email from the command line for agents. More SDK recipes live on the build page.
When this breaks
Most failures in this loop come from retries and from webhooks, and each one shows up as a specific symptom. The four below are the ones developers hit first.
A webhook create call returns 400. The event_types field is required and has no all-events default, so list message.received by name. Spam, blocked and unauthenticated mail arrive only if you add those variants too.
The agent replies twice. Webhook delivery is at least once, so the same event can arrive again, and a handler that takes longer than 15 seconds to answer gets it redelivered. Deduplicate incoming events on event_id, and give every send or reply an Idempotency-Key. Reusing a key with different content returns a 409, and keys expire 24 hours after the send. The full pattern is in idempotent webhook handlers.
The idempotency key is rejected. Keys take 1 to 256 characters from letters, digits, hyphen, period, underscore and tilde. A raw message id carries angle brackets and an @, which is why the reply block hashes it into a UUID first.
The webhook payload has no body. When the stored body is over 64 KiB, the event drops text and html and carries a body_url that works for one hour. Fetch the message with inboxes.messages.get instead, which always returns the full content.
What AgentMail does not do
We don't decide what your agent says or whether it should answer; that logic stays in your code. Sender reputation attaches to the domain, not to each inbox. Inboxes on agentmail.to share one domain, so a dedicated custom domain is how you isolate it. We also don't deduplicate webhook events for you, which is why the event_id check above belongs in your handler.




