AgentStack
MCP unreviewed Apache-2.0 Self-run

e2a — email for AI agents

mcp-mnexa-ai-e2a · by Mnexa-AI

Authenticated email gateway for AI agents — per-agent inboxes, HITL approval, SPF/DKIM verified.

No reviews yet
0 installs
0 views
view→install

Install

$ agentstack add mcp-mnexa-ai-e2a

Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.

Are you the author of e2a — email for AI agents? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.

About

e2a — Email for AI agents

[](https://github.com/Mnexa-AI/e2a/actions/workflows/test.yml) [](https://github.com/Mnexa-AI/e2a/actions/workflows/build-image.yml) [](LICENSE) [](https://www.npmjs.com/package/@e2a/sdk) [](https://pypi.org/project/e2a/)

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.

  • Authenticated transport — SPF/DKIM verified on inbound; HMAC-signed X-E2A-Auth-* headers on every delivery
  • Two delivery channels — webhook (cloud agents) or WebSocket (local agents, no public URL needed)
  • Outbound API — agents send to other agents (SMTP relay) or humans (upstream SMTP, e.g. SES, Resend)
  • Human in the loop — opt-in approval gate that holds outbound mail until a reviewer approves via dashboard, magic-link email, the MCP tools, or the API
  • CLI + SDKs — TypeScript and Python SDKs, plus a e2a CLI for everyday agent ops

Use it

You can either use the hosted instance or self-host.

  • Hosted — sign up at e2a.dev. Includes the shared agents.e2a.dev domain for instant slug-based onboarding (no DNS setup), a dashboard, and managed deliverability.
  • Self-host — see [Quickstart](#quickstart) and [Deployment](#deployment). Every feature works the same; the shared-domain slug shortcut just needs you to point a mail domain at your relay and set shared_domain in config.yaml.

How it works

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).

Quickstart

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:

  • A: your-domain.com → server IP
  • MX: your-domain.comyour-domain.com (priority 10)

Then register and verify the domain through the API (see [Domains](#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.

Concepts

Delivery channels

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.

Auth headers

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.

Verifying the signature

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 your whsec_ — 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.

Conversation threading

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:

  1. 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.
  2. 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.

Human in the loop (HITL)

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:

  • Dashboard / API — the account-scoped review queue 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.
  • Magic-link email — sent automatically when a hold fires; one-click 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.

API

All endpoints are under /v1 unless noted. Auth is Authorization: Bearer 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](docs/api.md) for the full endpoint reference, or [api/openapi.yaml](api/openapi.yaml) for the machine-readable spec.

CLI

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 | Stream inbound email for an agent over WebSocket (real-time; --json for raw, --forward 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 

See [cli/README.md](cli/README.md) for full reference.

SDKs

Python

pip install e2a            # webhook mode
pip install 'e2a[ws]'      # adds WebSocket support
from e2a.v1 import E2AClient, construct_event

client = E2AClient()                                       # reads E2A_API_KEY
event = construct_event(request_body, signature_header, webhook_secret)  # parse + HMAC-verify
if event.type == "email.received":
    # event.data is metadata only — replying needs just the recipient + message_id
    # it carries (fetch the body with client.webhooks.fetch_message(event) if needed)
    meta = event.data
    await client.messages.reply(meta["recipient"], meta["message_id"],
                                {"body": "Got it!", "conversation_id": "conv_123"})

WebSocket (local agents):

from e2a.v1 import E2AClient

async with E2AClient(api_key="e2a_…") as client:
    async for notif in client.listen("bot@your-domain.com"):  # falls back to E2A_AGENT_EMAIL
        # notif is lightweight metadata — fetch the body when you want it
        email = await client.messages.get(notif.recipient, notif.message_id)
        await client.messages.reply(notif.recipient, notif.message_id, {"body": "Got it!"})

See [sdks/python/README.md](sdks/python/README.md).

TypeScript

npm install @e2a/sdk

See [sdks/typescript/README.md](sdks/typescript/README.md).

Deployment

Three audiences each configure a different surface:

| Audience | What they configure | Where | |---|---|---| | Server operator — runs the Go backend | DB, signing key, SMTP, OAuth, optional shared domain | config.yaml + E2A_* env | | CLI / SDK user — calls the API from their machine | Just the deployment URL (and login) | E2A_URL + e2a login | | Web dashboard deployer — hosts the Next.js dashboard | Public site URL + branding | NEXT_PUBLIC_* build-time env |

The Go binary runs on any container host; storage is plain Postgres 14+; outbound mail goes through standard SMTP. Most workers coordinate via SELECT … FOR UPDATE SKIP LOCKED, so multi-replica is safe — the two real horizontal-scaling caveats are in-memory WebSocket fan-out and per-process rate limits.

See [docs/deployment.md](docs/deployment.md) for the full env-var reference, shared-domain DNS setup, and scaling/limitation notes.

Security

  • Identity — agent registration requires DNS TXT verification of domain ownership (custom domains)
  • Domain auth — SPF and DKIM checked on every inbound message
  • Header signatures — HMAC-SHA256 over canonical auth-header string; reject if timestamp older than 5 minutes
  • SSRF protection — webhook URLs must be HTTPS (in production), resolve to public IPs, use domain names (no raw IPs, no private/loopback ranges)
  • OAuth CSRF — single-use, time-limited nonce in the state parameter
  • Production mode (E2A_ENV=production) enforces the above where development mode is more permissive

Report security issues privately — see [SECURITY.md](SECURITY.md) for the disclosure process and what's in scope. Do not file public GitHub issues for vulnerabilities.

Data handling

Message envelopes and inbound bodies live in Postgres for 10 days by def

Source & license

This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.3.2 Imported from the upstream source.