Overview
Agent mailboxes give AI agents their own email identity on inbound.euromail.dev (or your verified domain). Zero MIME parsing, zero OAuth, zero deliverability setup — the agent gets an address, long-polls for incoming messages, processes them, and acknowledges. If the agent crashes mid-processing, the message automatically returns to the queue.
This guide focuses on the lease / ack / nack delivery model. For the full feature set (threads, search, labels, auto-responder, reply, attachments, contacts, analytics), see the API reference.
Quick Start
1. Create a mailbox
curl -X POST https://api.euromail.dev/v1/agent-mailboxes \
-H "X-EuroMail-Api-Key: em_live_..." \
-H "Content-Type: application/json" \
-d '{ "display_name": "Support Agent" }'
Response:
{
"data": {
"id": "1f4e...",
"address": "[email protected]",
"display_name": "Support Agent",
"created_at": "2026-04-14T10:00:00Z"
}
}
The agent can now receive email at [email protected]. Pass a custom local_part in the create request if you want a specific address like [email protected].
2. Wait for the next message
GET /messages/next blocks until a message arrives or the timeout expires (max 60 s). On success, it leases the message — hides it from other pollers for 5 minutes — and returns a lease_token you'll need to ack or nack.
curl -X GET "https://api.euromail.dev/v1/agent-mailboxes/{id}/messages/next?timeout=30" \
-H "X-EuroMail-Api-Key: em_live_..."
Success response (200):
{
"data": {
"id": "msg_uuid",
"mail_from": "[email protected]",
"from_header": "Alice <[email protected]>",
"subject": "Help with my order",
"text_body": "Hi, I need to change...",
"html_body": "<p>Hi, I need to change...</p>",
"created_at": "2026-04-14T10:01:23Z"
},
"lease_token": "b9f2d1c0-...",
"lease_expires_at": "2026-04-14T10:06:23Z"
}
Timeout response (408): empty body — the long poll expired without a message. Re-issue the request.
3. Ack when done
After processing the message successfully, acknowledge it so it won't be redelivered:
curl -X POST https://api.euromail.dev/v1/agent-mailboxes/{id}/messages/{msg_id}/ack \
-H "X-EuroMail-Api-Key: em_live_..." \
-H "Content-Type: application/json" \
-d '{ "lease_token": "b9f2d1c0-..." }'
Response: 204 No Content. Ack is idempotent — re-acking an already-acked message returns 204 too.
4. Nack to retry
If processing fails (AI rate limit, transient error, bug), return the message to the queue so another attempt can pick it up:
curl -X POST https://api.euromail.dev/v1/agent-mailboxes/{id}/messages/{msg_id}/nack \
-H "X-EuroMail-Api-Key: em_live_..." \
-H "Content-Type: application/json" \
-d '{ "lease_token": "b9f2d1c0-..." }'
Response: 204 No Content. The message becomes immediately deliverable to the next GET /messages/next call.
The Lease Model
EuroMail agent mailboxes implement at-least-once delivery. Every message you process will be delivered at least once, and may be delivered more than once if your agent crashes before acking. Your handler should be idempotent.
Why not just auto-ack on delivery?
The obvious alternative — mark the message as read the moment GET /messages/next returns it — would lose messages if the agent crashed while processing. That's the classic "at-most-once" trap: a single network hiccup, OOM kill, or LLM API timeout silently drops the customer's email.
The lease model trades theoretical duplicates for zero silent loss. For agents that reply to humans, this is almost always the right trade.
Lease lifetime
| State | Condition | Visible to pollers? |
|---|---|---|
| Fresh | read_at IS NULL AND no lease | Yes |
| Leased | leased_until > now() | No |
| Lease expired | leased_until < now(), read_at IS NULL | Yes (returns to queue) |
| Acked | read_at IS NOT NULL | No (permanently) |
The default lease is 5 minutes. If your agent's typical processing time is significantly longer than that, ack/nack early and extend the lease with a heartbeat pattern (issue an ack once you've durably queued the work elsewhere, then process asynchronously).
Handling duplicates
Because leases can expire (e.g., long-running LLM call + slow recovery), the same message can be delivered twice. Defend against this with an idempotency key derived from the message ID:
processed_ids = set() # or Redis, or a DB table
def handle(msg, lease_token):
if msg["id"] in processed_ids:
ack(msg["id"], lease_token) # already did the work, just ack
return
reply = llm.generate_reply(msg["text_body"])
send_reply(reply)
processed_ids.add(msg["id"])
ack(msg["id"], lease_token)Multiple Workers
The lease query uses PostgreSQL SELECT FOR UPDATE SKIP LOCKED, so multiple worker processes can poll the same mailbox concurrently without stepping on each other. Each poller gets a different message (or times out if no more work is available).
This lets you horizontally scale by running N worker pods. Each pod runs the same long-poll loop and the database hands out one message at a time.
Listing and Browsing
GET /messages returns messages without acquiring a lease — useful for dashboards, history, and debugging:
# All messages (most recent first)
curl "https://api.euromail.dev/v1/agent-mailboxes/{id}/messages?status=all"
# Only unread (same ordering as the queue)
curl "https://api.euromail.dev/v1/agent-mailboxes/{id}/messages?status=unread"
# Only already-acked
curl "https://api.euromail.dev/v1/agent-mailboxes/{id}/messages?status=read"
status=unread includes currently-leased messages (they still have read_at = NULL), so don't use this endpoint to decide what to process next — always call GET /messages/next for that.
Error Responses
| Status | Meaning |
|---|---|
204 No Content | Ack or nack succeeded |
400 Bad Request | lease_token is missing, malformed, doesn't match the current lease, or the message is already acked |
404 Not Found | Mailbox doesn't exist or doesn't belong to your account |
408 Request Timeout | Long poll timed out with no message available |
500 Internal Server Error | Database or Redis failure; retry with exponential backoff |
A common bug is re-acking a message after the lease has expired and another agent has already re-leased it. In that case your lease_token no longer matches the current one — you'll get a 400. Treat this as "somebody else is processing it now, move on."
Required Scopes
| Endpoint | Scope |
|---|---|
GET /messages, GET /messages/next, POST /ack, POST /nack | mailbox:read |
POST /agent-mailboxes, DELETE /messages/{id} | mailbox:write |
DELETE /agent-mailboxes/{id} | mailbox:admin |
Create scoped API keys via the dashboard for agents that only need to read. Never give an agent mailbox:admin unless it explicitly needs to provision or delete mailboxes.
Common Patterns
Single-worker loop (Python)
import os, time, requests
API = "https://api.euromail.dev"
KEY = os.environ["EUROMAIL_API_KEY"]
MAILBOX_ID = os.environ["MAILBOX_ID"]
HEADERS = {"X-EuroMail-Api-Key": KEY, "Content-Type": "application/json"}
while True:
r = requests.get(f"{API}/v1/agent-mailboxes/{MAILBOX_ID}/messages/next?timeout=30",
headers=HEADERS, timeout=35)
if r.status_code == 408:
continue # no message, long-poll again
r.raise_for_status()
body = r.json()
msg = body["data"]
token = body["lease_token"]
try:
handle_message(msg)
except Exception as e:
# Return to queue for another attempt
requests.post(f"{API}/v1/agent-mailboxes/{MAILBOX_ID}/messages/{msg['id']}/nack",
headers=HEADERS, json={"lease_token": token})
raise
requests.post(f"{API}/v1/agent-mailboxes/{MAILBOX_ID}/messages/{msg['id']}/ack",
headers=HEADERS, json={"lease_token": token})Webhook-driven (no polling)
Register a webhook for the mailbox.message.received event type on the mailbox's account. Your webhook handler still needs to ack the message afterwards — the webhook fires in addition to the lease queue, not instead of it.
FAQ
Can I change the lease duration? Not yet. The default is 5 minutes and that's currently hardcoded. File an issue if you need longer-running lease windows.
What happens if I call /messages/next and never ack or nack?
The message becomes deliverable again once leased_until passes (5 minutes after the lease was acquired). The next poller will get it.
Can two agents process the same message at the same time?
No. Only one agent holds an active lease at a time, and SELECT FOR UPDATE SKIP LOCKED guarantees no race between concurrent pollers. Duplicates happen only when a lease expires and the message is redelivered.
Is the message ordering preserved?
Yes, GET /messages/next always returns the oldest deliverable message first (FIFO by created_at).
How do I delete a mailbox?
DELETE /v1/agent-mailboxes/{id} — cascades to all messages and attachments. Requires mailbox:admin.