README
¶
e2a — Email for AI agents
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 modes — 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, or CLI
- CLI + SDKs — TypeScript and Python SDKs, plus a
e2aCLI 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.devdomain for instant slug-based onboarding (no DNS setup), a dashboard, and managed deliverability. - Self-host — see Quickstart and 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_domaininconfig.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/api/v1/agents \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"slug":"my-bot","agent_mode":"local"}'
curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/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.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: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
Agent modes
Agents operate in one of two modes, set via agent_mode at registration:
| Mode | Delivery | Public URL needed? |
|---|---|---|
cloud (default) |
HTTPS webhook POST to webhook_url |
Yes |
local |
WebSocket notification + REST fetch | No |
Local-mode agents accumulate "unread" messages while disconnected; on reconnect, the server drains them as WebSocket notifications. Both modes can also poll messages via the REST API.
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
The X-E2A-Auth-Verified field is the server's claim — anyone who can reach your webhook URL can set it. To make a security decision, verify the signature with the shared HMAC secret:
from e2a.v1 import E2AClient
client = E2AClient()
email = client.parse(request_body)
if not email.verify_signature(my_hmac_secret):
return 401 # untrusted, reject
# now safe to act on email.sender, email.is_verified, etc.
import { E2AClient } from "@e2a/sdk";
const email = await client.parse(req.body);
if (!email.verifySignature(myHmacSecret)) {
return res.status(401).end();
}
Both SDKs check, in order: body_hash matches the raw message bytes, HMAC matches the canonical, and timestamp is within a 5-minute replay window. Returns true only if all three hold. Treat false as untrusted regardless of the is_verified claim.
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:
X-E2A-Conversation-Idheader — authoritative for e2a-to-e2a traffic. Only honored when the SMTP envelopeMAIL FROMoriginates from this relay, so external senders cannot forge it.In-Reply-To/Referenceslookup — 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 has HITL enabled, outbound send and reply calls do not dispatch immediately. The message is stored with status pending_approval and the API returns HTTP 202 Accepted. A reviewer must approve it before delivery; otherwise, after a configurable TTL, the message expires into expired_approved (auto-sent) or expired_rejected (discarded), depending on the agent's hitl_expiration_action.
Reviewers can approve or reject via:
- Dashboard / API —
POST /api/v1/messages/{id}/approveor/reject - Magic-link email — sent automatically when HITL fires; one-click
GET /api/v1/approve?token=…and/reject?token=…URLs (requiresE2A_PUBLIC_URLand outbound SMTP configured) - CLI —
e2a pendinglists held messages
Enable HITL on an agent via PUT /api/v1/agents/{email} with hitl_enabled: true and an optional hitl_expiration_action and TTL.
API
All endpoints are under /api/v1 unless noted. Auth is Authorization: Bearer <api_key> except where called out. Path parameters containing @ (agent emails) must be URL-encoded.
Domains
| Method | Path | Description |
|---|---|---|
POST |
/domains |
Register a custom domain. Returns required MX and TXT records. |
GET |
/domains |
List domains owned by the authenticated user |
POST |
/domains/{domain}/verify |
Verify ownership via TXT record |
DELETE |
/domains/{domain} |
Delete (must delete all agents on the domain first) |
Agents
| Method | Path | Description |
|---|---|---|
POST |
/agents |
Register an agent. Use email for a custom domain (must be verified) or slug for a shared-domain registration (only when the deployment has shared_domain configured) |
GET |
/agents |
List agents owned by the authenticated user |
GET |
/agents/{email} |
Get agent details |
PUT |
/agents/{email} |
Update agent (webhook URL, mode, HITL settings) |
DELETE |
/agents/{email} |
Delete an agent |
POST |
/agents/{email}/test |
Send a test email through the agent |
Messages — inbound (per-agent)
| Method | Path | Description |
|---|---|---|
GET |
/agents/{email}/messages |
List inbound messages for the agent |
GET |
/agents/{email}/messages/{id} |
Fetch a single inbound message (transitions unread → read for local-mode agents) |
POST |
/agents/{email}/messages/{id}/reply |
Reply to an inbound message |
Messages — outbound / HITL
| Method | Path | Description |
|---|---|---|
POST |
/send |
Send an email (held with 202 Accepted if HITL enabled on the agent) |
GET |
/messages |
List outbound messages owned by the user (filterable by status) |
GET |
/messages/{id} |
Get a single outbound message |
POST |
/messages/{id}/approve |
Approve a pending_approval message |
POST |
/messages/{id}/reject |
Reject a pending_approval message |
User (data rights)
| Method | Path | Description |
|---|---|---|
GET |
/users/me/export |
Returns a JSON dump of the authenticated user's profile, agents, domains, API key metadata, messages, and usage events. Right-of-access export (GDPR Art. 15 / CCPA equivalent). |
DELETE |
/users/me?confirm=DELETE |
Permanently deletes the authenticated user and all associated data in one Postgres transaction. Right-of-deletion (GDPR Art. 17 / CCPA "Do Not Sell or Share"). Requires confirm=DELETE query parameter as a guardrail; returns per-table row counts so the caller can audit the cascade. |
Both endpoints require a valid API key or session. The export omits internal identifiers (Google subject, API key hashes, session tokens) — see SECURITY.md for the full data model.
HITL magic links
These endpoints accept a signed token query parameter (from notification emails) instead of an API key, so reviewers can approve from any mail client without auth.
| Method | Path | Description |
|---|---|---|
GET/POST |
/approve?token=… |
Approve a pending message via signed token |
GET/POST |
/reject?token=… |
Reject a pending message via signed token |
Real-time delivery
| Method | Path | Description |
|---|---|---|
GET |
/agents/{email}/ws?token={api_key} |
WebSocket for local-mode agents. Auth via query param (WebSocket clients can't set headers during upgrade). |
The server pushes lightweight JSON notifications (metadata only):
{
"message_id": "msg_abc123",
"conversation_id": "conv_xyz",
"from": "alice@example.com",
"to": "bot@your-domain.com",
"subject": "Meeting tomorrow",
"received_at": "2026-04-24T10:00:00Z"
}
Fetch full content via GET /agents/{email}/messages/{id}. On connect, all unread messages are drained as notifications automatically.
Other
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/health |
none | Health check |
GET |
/api/v1/info |
none | Deployment discovery — returns shared_domain, slug_registration_enabled, and public_url. CLIs/SDKs hit this to self-configure from a single base URL. |
POST |
/api/feedback |
none | Submit feedback (rate-limited per-IP) |
CLI
npm install -g @e2a/cli
e2a login
| Command | Description |
|---|---|
e2a agents register <slug> |
Register <slug>@<shared-domain>. The deployment's shared domain is auto-discovered after e2a login and cached in ~/.e2a/config.json. |
e2a agents list |
List your agents |
e2a agents update <email> |
Update an agent (webhook URL, mode, HITL) |
e2a agents delete <email> |
Delete an agent |
e2a listen |
Listen for emails over WebSocket (real-time) |
e2a listen --json |
Output one full message JSON per line |
e2a listen --forward <url> |
Forward each message as HTTP POST to a local URL |
e2a inbox |
List recent messages |
e2a read <id> |
Read a message |
e2a reply <id> --body … |
Reply to a message |
e2a send --to … --subject … --body … |
Send an email |
e2a pending |
List HITL messages awaiting approval |
e2a config |
View or update CLI 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.
SDKs
Python
pip install e2a # webhook mode
pip install 'e2a[ws]' # adds WebSocket support
from e2a.v1 import E2AClient
client = E2AClient() # reads E2A_API_KEY
email = client.parse(request_body) # validate + decode webhook payload
print(email.sender, email.subject)
email.reply("Got it!", conversation_id="conv_123")
WebSocket (local agents):
from e2a.v1 import AsyncE2AClient
async with AsyncE2AClient(api_key="e2a_…") as client:
async for email in client.listen("bot@your-domain.com"):
print(email.sender, email.subject)
await email.reply("Got it!")
TypeScript
npm install @e2a/sdk
See sdks/typescript/README.md.
Deployment
There are three audiences who configure something — and confusing them is the main UX pothole of self-hosted projects. The split:
| 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 |
Server operator
Copy config.example.yaml to config.yaml and fill in values, or set the environment variables below (env wins over file). All secrets should be set via env, never the file.
| Variable | Required | Description |
|---|---|---|
E2A_DATABASE_URL |
yes | Postgres connection string |
E2A_HMAC_SECRET |
yes | HMAC signing secret for X-E2A-Auth-* headers |
E2A_PUBLIC_URL |
for HITL emails | Externally visible base URL (e.g. https://e2a.example.com); required to render absolute magic-link URLs |
E2A_SHARED_DOMAIN |
optional | Mail domain backing slug-based agent registration (e.g. agents.example.com). When set, users can register agents with just a slug; when empty, every agent must use a custom domain that the user verifies. The shared domain itself becomes reserved (cannot be claimed as a custom domain). |
E2A_GOOGLE_CLIENT_ID |
for OAuth login | Google OAuth client ID for dashboard sign-in |
E2A_GOOGLE_CLIENT_SECRET |
for OAuth login | Google OAuth client secret |
E2A_OUTBOUND_SMTP_HOST |
for outbound | Upstream SMTP host (e.g. email-smtp.us-east-1.amazonaws.com) |
E2A_OUTBOUND_SMTP_PORT |
for outbound | Upstream SMTP port (typically 587) |
E2A_OUTBOUND_SMTP_USERNAME |
for outbound | Upstream SMTP username |
E2A_OUTBOUND_SMTP_PASSWORD |
for outbound | Upstream SMTP password |
E2A_OUTBOUND_SMTP_FROM_DOMAIN |
for outbound | Domain used in From: of outbound mail |
E2A_USAGE_TRACKING |
no (default false) |
Set to true to write per-message rows into usage_events / usage_summaries. The hosted deployment uses these for billing reconciliation; self-hosters typically don't need them. |
env: production in config.example.yaml enforces TLS for SMTP and HTTPS for webhook URLs. Leave it as development for local work.
Shared-domain setup
If you set E2A_SHARED_DOMAIN (or shared_domain in config.yaml) so users can register agents with just a slug — alice@agents.yourcompany.com — there are two parts to it: DNS you set up once, and a database row the server takes care of for you.
You do (once, externally):
- Pick the subdomain (e.g.
agents.yourcompany.com). - Add an
MXrecord pointing it at the host running the e2a SMTP relay. - Add
A/AAAArecords for that host. - Open inbound port 25 (the SMTP listener defaults to
:2525— either changesmtp.listen_addrto:25or NAT 25→2525). - Provision a TLS cert for the SMTP domain and set
smtp.tls_cert/smtp.tls_key. - Add SPF/DKIM TXT records on the subdomain so outbound mail from your relay isn't rejected by recipient mail servers.
The server does (automatically, at startup):
The shared domain needs a row in the domains table — it's the FK target for every agent registered against it. The server seeds this row idempotently every time it boots: INSERT … ON CONFLICT DO NOTHING against the configured shared_domain, with user_id = NULL and verified = true (system-owned, pre-verified). You don't run a migration, you don't psql anything by hand. Change the configured domain later? Restart and the new row appears; the old one stays as a harmless orphan because the API layer reads cfg.SharedDomain to decide what's reserved, not the table.
If you leave shared_domain empty, slug registration is disabled and every agent must use a custom domain the user verifies — no DNS setup required from you.
CLI / SDK user
End-users only need to know the deployment URL — the rest is auto-discovered.
export E2A_URL=https://e2a.example.com # default: https://e2a.dev
e2a login # browser flow; saves api key + auto-discovers shared domain
The CLI hits GET /api/v1/info on login and caches shared_domain to ~/.e2a/config.json, so commands like e2a agents update my-bot resolve to the right address on any deployment without further config. Escape hatches if you need to override or skip the discovery step:
| Variable | Description |
|---|---|
E2A_URL |
API base URL (default https://e2a.dev) |
E2A_API_KEY |
Bypass e2a login — useful in CI |
E2A_SHARED_DOMAIN |
Force the shared domain instead of auto-discovering it |
The TypeScript and Python SDKs follow the same pattern: pass baseUrl (or base_url) once and call E2AApi.fetchInfo() if you need the deployment's shared domain in your own code.
Web dashboard deployer
The Next.js dashboard ships as a static export, so its config is inlined at build time via NEXT_PUBLIC_* env vars. Copy web/.env.example to web/.env.local and adjust:
| Variable | Description |
|---|---|
NEXT_PUBLIC_SITE_URL |
Externally visible base URL of the dashboard. Used for SEO metadata, sitemap, and canonical URLs. Default: http://localhost:3000. |
NEXT_PUBLIC_SITE_NAME |
Display name in titles, OpenGraph, and structured data. Default: e2a. |
NEXT_PUBLIC_AGENTS_DOMAIN |
Shared mail domain shown in landing-page code samples (e.g. agents.example.com). When empty, samples fall back to your-domain.com. |
NEXT_PUBLIC_FEEDBACK_EMAIL |
Address shown on the feedback form. Empty hides the "or email us at …" line. |
NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION |
Google Search Console token. Only emitted into <head> when set, so forks don't inherit upstream's property. |
Scaling and limitations
Most state is already DB-coordinated. The HITL expiration worker, the webhook retry worker, and the periodic cleanup worker all use Postgres SELECT … FOR UPDATE SKIP LOCKED (or rely on DELETE idempotency for cleanup), so running multiple replicas concurrently is safe — only one worker claims a given pending message at a time, no duplicate sends. User sessions live in Postgres and the OAuth nonce travels in a cookie + the OAuth state parameter, so dashboard sign-in survives load-balancer rebalancing.
That leaves two real horizontal-scaling caveats:
- WebSocket fan-out is per-replica. The hub is an in-memory
map[agentID]*conn(internal/ws/hub.go). An agent connected to replica A won't receive real-time notifications for events that happen on replica B — an inbound mail arriving at B's SMTP relay, a HITL approval firing on B's API, etc. Messages aren't lost: they stayunreadin Postgres and the agent drains them on the next reconnect or REST fetch. They're just not pushed in real-time. Fix: a shared pub/sub (Redis, NATS) for cross-replica notification fan-out, or sticky sessions plus a per-replica routing layer. - Rate limits multiply with replica count. Limiters are in-process (per-IP, per-agent, per-user — see
ratelimit.New(...)calls in internal/agent/api.go). With two replicas the effective caps are 2× looser, not stricter. Operators who need exact global limits would move the limiters to a shared store (Redis, or a Postgres-backed token bucket).
Vertical scaling is fine. The API, the SMTP relay, and all three background workers run safely on multiple replicas today — the only paths that need attention before you do are the two above.
Dashboard auth is Google OAuth only. internal/auth/auth.go imports golang.org/x/oauth2/google directly and the config exposes google_client_id / google_client_secret. Teams running GitHub OAuth, Microsoft Entra, Okta, or generic OIDC need to add a provider in that package. The CLI and SDKs authenticate with API keys, which are provider-agnostic.
Otherwise infra-agnostic. The Go binary runs on any container host (Docker, Podman, k8s, ECS, Fly, Cloud Run, …). Storage is plain Postgres 14+ — managed (RDS, Cloud SQL, Neon, Supabase) or self-managed. Email goes out via standard SMTP, not a vendor SDK. Attachments live in Postgres rows, so there's no S3/GCS dependency. No queue, no Redis, no separate worker process. Secrets are read from env vars, so any secret manager that injects env at start time works.
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
stateparameter - Production mode (
E2A_ENV=production) enforces the above where development mode is more permissive
Report security issues privately — see SECURITY.md for the disclosure process and what's in scope. Do not file public GitHub issues for vulnerabilities.
Data handling
What e2a stores, how long it lives, and what users + operators can do with it.
What's stored
| Data | Where | Retention |
|---|---|---|
| Inbound + outbound message envelopes (sender, recipient, subject, conversation_id, timestamps) | Postgres messages |
Default 30 days; expires_at per row, hourly cleanup worker |
Inbound message bodies (raw RFC822 in raw_message) |
Postgres messages |
Same 30-day default |
Outbound message bodies (only while in pending_approval) |
Postgres messages.body_text / body_html / attachments_json |
Scrubbed on terminal HITL transition (approve/reject/expire) — only metadata persists after that |
| Attachments | Postgres rows (attachments_json, JSONB) |
Same lifetime as the parent message — no S3/GCS |
| Agent + domain ownership records | Postgres agent_identities, domains |
Until the user deletes the agent/domain or the account |
| API keys | Postgres api_keys, hash only (SHA over the plaintext) |
Until revoked or the user is deleted; plaintext exists only in the create response and is never persisted |
| OAuth sessions | Postgres user_sessions |
30 days; cleanup worker removes expired rows hourly |
Usage events / summaries (only when E2A_USAGE_TRACKING=true) |
Postgres usage_events, usage_summaries |
Indefinite by default — operator can purge or override |
| HMAC signing secret | Operator's env (E2A_HMAC_SECRET); never written to DB |
Lifetime of the deployment |
What's logged
- The SMTP relay logs envelope metadata on every inbound message: sender address, recipient list, byte count, the SPF/DKIM verdict. This is standard MTA practice (Postfix and other relays log the same), but it does mean PII (sender + recipient addresses) appears in application logs and inherits whatever retention your log pipeline has. Operators in privacy-strict environments should plan for redaction in their log forwarder.
- HITL state transitions log message IDs and agent IDs but not bodies.
- Webhook delivery attempts log the destination URL and status code.
Application logs do not include message bodies, attachment contents, raw API keys, or HMAC secrets.
User rights
The API exposes the two operations that GDPR Art. 15 / Art. 17 (and CCPA equivalents) require:
GET /api/v1/users/me/export— returns a JSON dump of everything the authenticated user owns. Profile, agents, domains, API key metadata, all messages with bodies, usage events. Internal identifiers (Google subject, key hashes, session tokens) are excluded.DELETE /api/v1/users/me?confirm=DELETE— wipes the user and every related row in a single Postgres transaction (cascade throughagent_identities → messages → webhook_deliveries, plus explicit deletion ofusage_eventswhich hasON DELETE SET NULLrather than CASCADE so it survives by default). Returns per-table row counts so the caller can audit what was removed.
Both are scoped to the authenticated user — there's no path to target someone else's data.
Operator responsibilities
Things e2a doesn't (and can't) handle for you:
- Database backups. Take them, encrypt them, set retention policy. e2a doesn't ship a backup story; use whatever your Postgres provider gives you.
- TLS termination for the API and SMTP. Production mode enforces HTTPS for webhook delivery; the operator's reverse proxy / ingress terminates TLS for inbound API traffic and the SMTP relay's
tls_cert/tls_keyconfig covers:2525. - At-rest encryption. Disk-level / volume-level encryption is the operator's responsibility (Postgres TDE, EBS encryption, GCP CMEK, …). e2a does not currently encrypt message bodies or attachments at the application layer; if your threat model includes a privileged DBA, you'll want to add column-level encryption.
- Log redaction. If your environment can't tolerate sender/recipient addresses in application logs, redact in your log forwarder or run with
--log-format=jsonand filter the relevant fields downstream. - Compliance attestations (SOC 2, HIPAA, ISO 27001) — those are deployment-level, not code-level.
Development
make build # go build -o bin/e2a ./cmd/e2a
make run # build + run (cp config.example.yaml config.yaml first)
make test # all Go tests (needs Postgres on :5433)
make test-unit # Go unit tests only (no DB)
make test-integration # integration tests (needs Postgres)
make test-e2e # e2e tests (needs Postgres)
make docker-up # start local Postgres via docker compose
make migrate # apply SQL migrations to local DB
See CLAUDE.md for the full developer guide (architecture, tests, code generation, conventions).
Contributing
By submitting a pull request, you certify the Developer Certificate of Origin for your contribution. Sign your commits with git commit -s.
License
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
e2a
command
|
|
|
e2a-contract-server
command
|
|
|
internal
|
|
|
approvaltoken
Package approvaltoken signs and verifies the short-lived HMAC tokens embedded in HITL notification-email magic links.
|
Package approvaltoken signs and verifies the short-lived HMAC tokens embedded in HITL notification-email magic links. |
|
hitlnotify
Package hitlnotify sends the approval notification email that fires whenever a new outbound message enters pending_approval.
|
Package hitlnotify sends the approval notification email that fires whenever a new outbound message enters pending_approval. |
|
hitlworker
Package hitlworker runs the periodic sweep that finalizes pending_approval messages whose TTL has elapsed.
|
Package hitlworker runs the periodic sweep that finalizes pending_approval messages whose TTL has elapsed. |