Authenticated email gateway for AI agents. Receive emails as webhooks or via WebSocket, send emails through an HTTP API, and verify the identity of every sender — humans and other agents alike.
X-E2A-Auth-* headers on every deliverye2a CLI for everyday agent opsYou can either use the hosted instance or self-host.
agents.e2a.dev domain for instant slug-based onboarding (no DNS setup), a dashboard, and managed deliverability.shared_domain in config.yaml.Human (Gmail/Outlook)
│
▼ SMTP
┌──────────────┐
│ e2a relay │ ← MX record for your agent domain points here
│ │
│ 1. Verify │ ← SPF/DKIM check on the inbound message
│ 2. Sign │ ← HMAC-signed X-E2A-Auth-* headers
│ 3. Deliver │
└──────────────┘
│
├──▶ Cloud-mode agent: HTTPS webhook POST
│
└──▶ Local-mode agent: store + WebSocket notification
│
▼
e2a listen (CLI) or client.listen() (SDK)
Inbound flow: SMTP → SPF/DKIM check → agent lookup → HMAC-sign auth headers → webhook or WebSocket delivery.
Outbound flow: API call → optional HITL hold → SMTP relay (agent-to-agent) or upstream SMTP (agent-to-human).
Requires Docker.
git clone https://github.com/Mnexa-AI/e2a.git
cd e2a
docker compose up -d
Postgres comes up first (migrations run automatically), then the API server, then the dashboard. Three host ports:
:8080 — HTTP API:2525 — SMTP relay:3000 — Dashboard (Caddy + Next.js, proxies /api/* to the API server)Health check:
curl http://localhost:8080/api/health
# {"status":"ok"}
Open http://localhost:3000 in a browser to view the dashboard. Sign-in requires Google OAuth credentials configured in config.yaml; for an API-only smoke test you can skip the dashboard and use the bootstrap flow below.
Create your first user and API key (no OAuth required):
docker compose exec e2a e2a -config /etc/e2a/config.yaml -bootstrap-email you@example.com
# User: you@example.com (id=...)
# API key: e2a_...
Save the key — it's only shown once. Register an agent and confirm it works:
KEY=e2a_...
curl -X POST http://localhost:8080/v1/agents \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"email":"my-bot@agents.e2a.dev"}' # an email on the deployment shared domain (or a domain you've verified)
curl -H "Authorization: Bearer $KEY" http://localhost:8080/v1/agents
To receive real inbound mail, point a domain's MX record at your relay host:
your-domain.com → server IPyour-domain.com → your-domain.com (priority 10)Then register and verify the domain through the API (see Domains). Without DNS, the API still works for testing — but external email won't reach your relay.
Upgrades and migrations. The compose file mounts
migrations/into Postgres' init directory, which only runs on first start (when the data volume is empty). When you upgrade e2a and pull a new schema migration, you must apply it manually:bash docker compose exec postgres sh -c \ 'for f in /docker-entrypoint-initdb.d/*.sql; do psql -U e2a -d e2a -f "$f" -v ON_ERROR_STOP=1; done'The migration files are idempotent (CREATE TABLE IF NOT EXISTS,ALTER TABLE … ADD COLUMN IF NOT EXISTS) so re-running them is safe.
Inbound mail reaches you two complementary ways — chosen per integration, not set on the agent:
| Channel | How | Public URL needed? |
|---|---|---|
| Webhooks | Account-level subscriptions (POST /v1/webhooks) — HTTPS POST per event, filterable by agent / conversation / event type |
Yes |
| WebSocket | Per-agent real-time notification stream (/v1/agents/{address}/ws) + REST fetch |
No |
A disconnected WebSocket client accumulates "unread" messages; on reconnect, the server drains them as notifications. Either channel can also poll messages via the REST API. Webhooks are their own resource (/v1/webhooks), chosen per integration rather than set on the agent.
Every email delivered through e2a (webhook or WebSocket-fetched) carries signed headers:
| Header | Description |
|---|---|
X-E2A-Auth-Verified |
true if domain-level auth (SPF or DKIM) passed |
X-E2A-Auth-Sender |
Verified sender email or agent domain |
X-E2A-Auth-Entity-Type |
human or agent |
X-E2A-Auth-Domain-Check |
SPF/DKIM result string (e.g. spf=pass; dkim=none) |
X-E2A-Auth-Delegation |
agent={id};human={id} if an active delegation binding exists |
X-E2A-Auth-Timestamp |
RFC3339 timestamp |
X-E2A-Auth-Message-Id |
Internal e2a message ID this delivery is for |
X-E2A-Auth-Body-Hash |
Hex SHA-256 of the raw message bytes |
X-E2A-Auth-Signature |
HMAC-SHA256 over a canonical string of the above |
The signature covers:
verified \n sender \n entity_type \n domain_check \n delegation \n timestamp \n message_id \n body_hash
The MAC binds to both message_id and a SHA-256 of the raw message body. Substituting either invalidates the signature, so an attacker who captures one delivery cannot replay the auth claim on a different message or under a modified body.
Any field in the payload — including X-E2A-Auth-Verified — is just the server's claim until you authenticate the delivery: anyone who can reach your webhook URL can POST a forged body. To make a security decision, verify the delivery's envelope signature — the X-E2A-Signature header — with your webhook's signing secret, a whsec_… value returned once when you create the subscription (POST /v1/webhooks); store it then. Rotate via POST /v1/webhooks/{id}/rotate-secret (24h grace window where the old secret still verifies). The envelope signature covers the whole payload, so once it verifies, the X-E2A-Auth-* claims inside are trustworthy too.
The inner
X-E2A-Auth-Signature(in the table above) is a separate mechanism, signed with the deployment's HMAC secret — not yourwhsec_— so a webhook subscriber neither needs nor can verify it. It exists for same-trust-domain consumers that receive these as relayed message headers (e.g. a self-hosted deployment holding the HMAC secret). Your verification path as a subscriber is the envelope signature.
The one-call shortcut parses and verifies a delivery, returning a typed event — use it instead of trusting any field on an unverified payload:
from e2a.v1 import construct_event, E2AWebhookSignatureError
# raw request body + the X-E2A-Signature header + your whsec_… secret
event = construct_event(request_body, signature_header, webhook_secret) # raises on bad signature
if event.type == "email.received":
# metadata-only notification — fetch the full message (body + attachments)
msg = await client.webhooks.fetch_message(event)
import { constructEvent, E2AWebhookSignatureError } from "@e2a/sdk/v1";
const event = constructEvent(req.body, req.header("X-E2A-Signature")!, webhookSecret); // throws on bad signature
if (event.type === "email.received") {
// metadata-only notification — fetch the full message (body + attachments)
const msg = await client.webhooks.fetchMessage(event);
}
construct_event / constructEvent checks that the HMAC matches the canonical signing string and the timestamp is within a 5-minute replay window. Pass an array of secrets to accept either during a rotation: constructEvent(body, header, [oldSecret, newSecret]).
Messages fetched over an authenticated channel — client.messages.get(address, id) or the client.listen(...) stream — are already trusted (the bearer token authenticated the call), so no verify step is needed there.
Both send and reply accept an opaque conversation_id. e2a propagates it to the recipient on delivery via payload.conversation_id, surfaced in this priority order:
X-E2A-Conversation-Id header — authoritative for e2a-to-e2a traffic. Only honored when the SMTP envelope MAIL FROM originates from this relay, so external senders cannot forge it.In-Reply-To / References lookup — standard RFC 5322 threading, scoped to the recipient agent's own messages. Covers humans replying from Gmail/Outlook.First contact from a human arrives with conversation_id: null — the agent should assign a new id before replying.
When an agent's protection config holds an outbound message for review, send and reply calls do not dispatch immediately. The message is stored with status pending_review and the API returns HTTP 202 Accepted. A reviewer must approve it before delivery; otherwise, after a configurable TTL, the protection config's holds.on_expiry decides the terminal: approve (the message just goes out, terminal status sent — for outbound, approving is sending) or reject (discard, review_expired_rejected). (Inbound messages can be held for review too — there, the auto-approve terminal is review_expired_approved, releasing the message to the inbox.)
Reviewers can approve or reject via:
POST /v1/reviews/{id}/approve or /reject (id-addressed, no inbox email needed; lists held items across all the account's inboxes via GET /v1/reviews). This is the primary path. The agent-path POST /v1/agents/{address}/messages/{id}/approve|reject is deprecated but still works identically for back-compat.GET /v1/approve?t=… and /v1/reject?t=… URLs (requires E2A_PUBLIC_URL and outbound SMTP configured)Enable review holds on an agent via PUT /v1/agents/{address}/protection: set the outbound gate action to review (or turn on the content scan), plus the hold TTL (holds.ttl_seconds) and its expiry behavior (holds.on_expiry = approve or reject). Posture lives entirely on the protection sub-resource.
All endpoints are under /v1 unless noted. Auth is Authorization: Bearer <api_key> except for /api/health, /v1/info, /api/feedback, and the HITL magic-link routes. Path parameters containing @ (agent emails) must be URL-encoded.
The surface covers domain registration + verification, agent CRUD, inbound/outbound messages, HITL approve/reject (API key or signed magic-link token), GDPR-style export and deletion, and a WebSocket channel for real-time inbound delivery.
See docs/api.md for the full endpoint reference, or api/openapi.yaml for the machine-readable spec.
npm install -g @e2a/cli
e2a login
The CLI is a thin developer convenience — it covers only what the other surfaces don't do ergonomically. Drive agents (read/send/reply/list/labels) over the MCP tools or the SDKs; manage domains/agents/webhooks/keys/HITL in the web dashboard.
| Command | Description |
|---|---|
e2a login |
Open a browser login and save your API key + default agent to ~/.e2a/config.json |
e2a listen --agent <email> |
Stream inbound email for an agent over WebSocket (real-time; --json for raw, --forward <url> to bridge to a local HTTP handler) |
e2a config [list\|get\|set] |
View or update the local config |
The listen --forward mode also supports OpenAI Responses API forwarding via --forward-token, which formats each inbound email as a Responses payload and auto-replies with the model's output:
e2a listen --forward http://localhost:18789/v1/responses --forward-token <token>
See cli/README.md for full reference.
pip install e2a # webhook mode
pip install 'e2a[ws]' # adds WebSocket support
```python from e2