April 13, 2026 · 5 min read

Send real email from a Python AI agent in 20 lines

Most AI agents today live behind a chat UI. That's fine for demos, but the moment you want your agent to participate in real work — coordinating with customers, scheduling meetings, responding to vendor outreach — you need the inbox everyone else uses. Email.

This post walks through the shortest path to a Python agent that actually sends and receives email, with verified sender identity, conversation threading, and real-time delivery. No SMTP server, no public webhook URL (if you don't want one), no DNS.

Get an email address for your agent

Sign up at e2a.dev, pick a slug like research-assistant, and you'll get research-assistant@agents.e2a.dev. Auto-verified, ready to receive mail immediately. If you prefer your own domain, you can register it instead (MX + TXT record), but the shared domain is faster for experiments.

Install the Python SDK:

pip install e2a

Send an email

The SDK is async-only. A few lines:

from e2a.v1 import E2AClient

async with E2AClient(api_key="e2a_...") as client:
    await client.messages.send(
        "research-assistant@agents.e2a.dev",
        {
            "to": ["alice@example.com"],
            "subject": "Quick question",
            "body": "Hi Alice — I'm your scheduling assistant. Got a second for a quick sync?",
        },
    )

That's it. The message goes out signed with your domain's DKIM, shows up in Alice's inbox looking like a normal email, and her reply will route back to your agent via e2a.

Receive replies

Two modes, depending on whether your agent has a public URL:

Webhook delivery — subscribe a webhook (POST /v1/webhooks), and when Alice replies, e2a POSTs the signed event to your server. Verify the signature with construct_event (it raises on a bad signature or a replay):

from e2a.v1 import E2AClient, construct_event

@app.post("/webhook")
async def inbox(request):
    raw = await request.body()
    event = construct_event(raw, request.headers["X-E2A-Signature"], WEBHOOK_SECRET)
    msg = event.data  # metadata only (ids, sender, subject) — fetch the body via messages.get if you need it
    print(f"{msg['from']}: {msg['subject']}")
    async with E2AClient(api_key="e2a_...") as client:
        await client.messages.reply(
            msg["recipient"], msg["message_id"], {"body": "Got it. Does Thursday 2pm work?"}
        )
    return {"ok": True}

WebSocket delivery — no public URL needed. Great for running agents on your laptop or behind a firewall:

from e2a.v1 import E2AClient

async with E2AClient(api_key="e2a_...") as client:
    async for notif in client.listen("research-assistant@agents.e2a.dev"):
        msg = await client.messages.get(notif.recipient, notif.message_id)
        print(f"{notif.from_}: {notif.subject}")
        await client.messages.reply(notif.recipient, notif.message_id, {"body": "Got it, on it."})

listen() holds a WebSocket and yields a lightweight WSNotification when mail arrives (message_id, from_, subject, conversation_id). Fetch the full body with client.messages.get(...) and respond with client.messages.reply(...).

Thread conversations across turns

Multi-turn threading is the difference between an agent that sends isolated emails and one that maintains context. Pass conversation_id on each outbound and you'll see it back on subsequent inbounds in the same thread:

async for notif in client.listen("research-assistant@agents.e2a.dev"):
    convo_id = notif.conversation_id or new_id()

    msg = await client.messages.get(notif.recipient, notif.message_id)
    draft = await compose_reply(msg, history=load_history(convo_id))
    await client.messages.reply(
        notif.recipient, notif.message_id, {"body": draft, "conversation_id": convo_id}
    )

Works across humans replying from Gmail and other e2a agents replying via the platform. First contact from a human arrives with conversation_id=None — assign one yourself, and every reply in that thread will carry it.

Verify the sender

Inbound messages carry SPF/DKIM results so you can decide how much to trust them, and webhook deliveries are signed end-to-end. Always verify the signature on a webhook before acting on the payload — construct_event does it for you and raises on a bad signature or a replay older than five minutes:

from e2a.v1 import E2AClient, construct_event
from e2a.v1.errors import E2AWebhookSignatureError

try:
    event = construct_event(raw_body, signature_header, WEBHOOK_SECRET)
except E2AWebhookSignatureError:
    # Could be a spoof — reject it
    return Response(status_code=401)

handle(event)

The signature is HMAC-SHA256 over {timestamp}.{raw_body}, keyed by your webhook's signing secret (whsec_...).

What you can build from here

The three primitives — messages.send, listen/webhook, and conversation_id threading — cover most agent-in-your-inbox use cases. A few ideas:

Anything that can receive email and take action can now have an agent attached to it. The Python SDK README and API reference cover attachments, CC/BCC, reply-all, and the auth header details if you want to go deeper.