The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Openagentemail listing page.
Self-hosted email for AI agents. The open-source alternative to AgentMail.
openagent.email · website: openagentemail/website

One docker compose up on your own VPS gives every agent you run unlimited real
mailboxes on your own domain — over REST and MCP — with OTP and verification-link
extraction built in. No per-inbox pricing, no third party ever seeing your mail.
A guided wizard: it checks what you already have, helps you pick a VPS and a domain if you're missing either, and connects your agent clients (Claude Code, Cursor, Kimi Code…) once the server is up.
The manual path needs a VPS with outbound/inbound port 25 open and a domain you control:
Then verify everything end to end:
doctor.sh checks .env permissions; MX, A, SPF, DKIM, and DMARC; PTR;
outbound port 25; DNS blocklists; TLS certificates on 465 and 993; and the
server-side ntfy verification endpoint. It does not log in over IMAP/SMTP or
send a round-trip test. Docker Mailserver can create docker-data/ as root,
so use sudo for these two scripts; an EACCES failure prints the same retry
instruction instead of pretending the DKIM key is missing.
If no SMTP relay is configured and outbound port 25 is blocked, API
queued:true only means the local mailserver accepted the message. It does
not mean the recipient received it: Postfix can retain the message in its
queue. Treat doctor's outbound-port-25 result as the delivery prerequisite, or
configure a relay before relying on direct delivery.
The default docker compose up -d path remains self-signed: it does not start
or pull Certbot and does not publish TCP 80. To use a publicly trusted mail
certificate, opt in only after mail.$DOMAIN has an A (and, if used, AAAA)
record pointing at this host and the firewall permits inbound TCP 80. HTTP-01
cannot create those DNS or firewall prerequisites for you.
In .env, set the following (use a reachable contact address outside this
mailserver when possible):
First issue the certificate with the explicitly enabled sidecar; do not start
the mailserver in letsencrypt mode before this succeeds:
This temporary container reads the shared certificate volume, so confirmation still works after the one-shot bootstrap has stopped.
If first issuance fails, Certbot stops instead of retrying the ACME request in
a tight loop. Correct the DNS/port-80/domain prerequisite, then explicitly run
the same docker compose --profile letsencrypt-bootstrap up -d certbot-bootstrap
command again.
The entire /etc/letsencrypt tree is a persistent named volume shared with
the mailserver read-only: Certbot's live/ files are symlinks into archive/,
so mounting only live/ is incorrect. Once the first certificate exists,
start the full opt-in stack and verify the public endpoints. Do not enable
letsencrypt-bootstrap and letsencrypt together: both publish host TCP 80.
After bootstrap, the renewal sidecar runs renew every 12 hours and restarts
with Docker. docker-mailserver's change-detection service watches
SSL_TYPE=letsencrypt certificate updates and reloads Postfix and Dovecot, so
renewed certificates take effect on 465/993 without a manual container restart.
Keep the letsencrypt profile enabled for normal operation. If it is omitted,
the sidecars and TCP 80 are absent and the original self-signed path is unchanged.
Create an identity and hand your agent its scoped token (shown once):
The API binds to 127.0.0.1 by default — reach it from other hosts over an
SSH tunnel or a TLS proxy: docs/security.md.
Paths are exact: call /v1/notify, not /v1/notify/ — a trailing slash
returns a plain 404 rather than the API error format.
Already have a mail provider for your domain? Run the API by itself with
compose.api-only.yaml, connected to that provider's
catch-all mailbox. The external mail server guide
covers the required catch-all setup, Portainer deployment, SMTP sender limits,
and TLS certificate verification.
The standalone default project name is openagentemail. If the full
compose.yaml stack also runs on the same host, the API-only stack must not
share that default project: give it an explicitly different -p value or
COMPOSE_PROJECT_NAME so the two stacks cannot adopt each other's resources.
To run multiple API-only instances on one host, give every instance its own
environment file, unique Compose project, and host API_PORT. The API always
listens on port 3100 inside its container; API_PORT changes only the host-side
mapping. For example:
You may set a unique COMPOSE_PROJECT_NAME for each command instead of using
-p. The project names make Compose generate distinct container names and
project-scoped named volumes (for example oae-alpha_api-data and
oae-beta_api-data). Do not reuse a project name or API_PORT between the two
instances. Each instance must have independently generated API_KEYS and
TASK_SIGNING_SECRET values; configure its IMAP and SMTP credentials for that
instance's intended mailbox/provider boundary as well. Keep these populated
environment files outside the repository, as in the example above.
Open http://localhost:3100/ui and paste an admin
or identity API token. The built-in dashboard lists the addresses the token
may access, shows Inbox / All Mail (IMAP) and Sent (API/MCP send audit
for 30 days; direct SMTP is not listed) with cursor paging, extracts
verification codes and links at the top of a message, offers Rendered
(isolated HTML iframe), Plain text, or Source views, and can mark messages
read or unread. Source is fetched on demand from a size-capped no-store
endpoint and never injected as HTML. Scheduled and Trash are omitted until
the backend can serve them.
Admin sessions can also create identities (with custom address prefixes),
rotate tokens, and delete identities directly from the overview table.
The browser exchanges the token once for an HttpOnly session cookie; manually
pasted tokens never enter the URL or browser storage. You can also bookmark
https://myinstance:3100/ui?token=<admin-token> for direct login. The server
automatically issues a single-use exchange code (TTL ≤ 10 min) via a 302 redirect
and sanitizes the URL with Cache-Control: no-store and Referrer-Policy: no-referrer,
followed by client history.replaceState cleanup. Tokens passed in URLs can still linger
in upstream reverse proxy access logs before redirection — see docs/security.md for
reverse-proxy scrubbing guidance.
Percent-encode the token if it contains URL-reserved characters like +, &, or #
(e.g. a+b → a%2Bb), as + decodes to a space and & truncates the value.
Only open ?token= links you generated yourself; if you suspect a link has leaked,
rotate the token from the UI immediately. Do not open ?token= links sent by
others — the link signs you into the sender's session (the app displays a
visible "Signed in via link" banner across the interface for the session as the tell).
Sessions live only in API process memory, so restarting the API signs every
browser out. They expire after 12 idle hours or 24 hours total — or tick Trust
this device at login to keep a sliding 30-day session on that browser. Each
token holds at most five sessions; a sixth login evicts that token's
least-recently-used one instead of locking you out.
For another computer, use the same SSH tunnel recommended for the API or put a
TLS reverse proxy in front. The login form refuses non-local plain HTTP, and
session cookies are Secure away from localhost. Set UI_ENABLED=false in
.env to make every /ui route return 404.
HTML email is treated as hostile input. The UI removes scripts, images, forms,
links, sender CSS, and all attributes except numeric table spans, then loads the
result in a separately sandboxed frame with a restrictive CSP. The
sanitize-html 2.x dependency is deliberately pinned to an exact version;
upgrade it in a dedicated change and rerun the full poison-message corpus.
An admin session lands on Overview: every identity in one table with the
message count, unseen count, last delivery, and creation day, plus totals across
the top. Each row also shows whether the identity has a token (green dot) and
has Rotate and Delete action buttons. A Create Identity button
above the table opens a form where you can set a custom address prefix (e.g.
qa-bot) or leave it blank for a random one; the new token is shown once in a
copy-to-clipboard modal. Identity sessions never see the overview or management
controls — they go straight to their own inbox.
The page is served from the same in-process API as the rest of /ui; there is no
new public endpoint outside /ui/api.
What the numbers mean, and where they stop:
newest N of M in the mailbox so the window
is never mistaken for history. A message addressed to two identities counts
once for each row and once — not twice — in the totals.≥N or Unknown rather than a confident wrong number,
the page explains why, and unmatchedInWindow is reported as null instead of
a made-up zero. The same applies to an identity created after the last scan:
it reads Unknown until the next one, never a false 0.Retry-After), the table keeps showing the previous
numbers, and the header says the last refresh failed. Once a snapshot is older
than 10 minutes it is not revived by a failed refresh: the page reports the
counts as unavailable instead of showing stale data as current. While a cold
scan is still running the endpoint answers 202 and the address list renders
immediately with Loading… in the count columns.GET /ui/api/overview (browser session only, admin only) returns exactly
the fields the page renders — never message content. ?refresh=1 asks for a
new scan, subject to the 5-second floor and the failure cooldown.Deliberate limits, so nothing here is a surprise later:
Refresh fetches sooner.
There is no steady-state polling: the page only schedules a follow-up while
counts are loading or a refresh is pending, capped at 15 attempts over 20
seconds and paced by the server's own retry hint./ui/fonts/*, the same
typeface as the website) so it renders identically on every machine;
font-src is 'self'. The favicon is an SVG (/ui/favicon.svg) so it
needs no build step, and /ui/favicon.ico keeps returning 204 as before.--line-control border token so their outlines
stay above 3:1 contrast. It is the one intentional deviation from the website's
palette and is a one-line revert.anything@yourdomain
addresses. No provisioning, no per-inbox cost.mail_wait_for / POST /v1/messages/wait — long-poll an inbox until a
matching message arrives, with OTP codes and verification links already extracted.
Built for automated signups.mail_mark_seen / POST /v1/messages/:id/seen lets an
agent (or the human in the dashboard) mark a message handled, so the unseen
count means "still needs attention". An identity may flag mail it received
or that this server actually sent (TO ∨ trusted Sent: From match and
Message-ID in the outbound registry), matching the Sent folder (#26 PR 2);
a forged From does not count. It cannot flag another identity's mail. Reading
a message never changes the flag by itself./ui (Inbox
is the default landing for every session). Inbox is a three-pane mail client
(identity/folder, list, detail) with Rendered / Plain text / Source; HTML
stays in a sandboxed iframe. Real /ui/* History routes cover Overview,
Tasks, Notifications, and Configure; the shell stays a zero-bundler
/ui/styles.css + /ui/app.js pair.deploy/dns-records.sh generates your exact DNS records;
deploy/doctor.sh diagnoses deliverability before your agents depend on it.One catch-all account on your domain receives everything. The API logs into it over
IMAP, matches messages to identities by the To/Delivered-To header, and sends
via SMTP with the From rewritten to the chosen identity. Polling + IMAP IDLE for
low-latency waits.
You can configure secondary domains alongside the primary DOMAIN by setting EXTRA_DOMAINS=domain2.com,domain3.org in .env.
POST /v1/identities with domain), MCP tools (mail_new_identity), or the Web Dashboard modal. Note that creating the same localpart across different domains is not allowed for now and will be revisited in Part 2.X-OA-Mail-Stamp) recognize all configured domains.deploy/dns-records.sh, deploy/doctor.sh, Certbot SANs, and multi-domain DKIM key generation) currently configure the primary DOMAIN. Automated multi-domain provisioning scripts will follow in Part 2.Set ALWAYS_BCC=archive@example.net only when your compliance policy permits
an additional external delivery copy. It is off by default and adds the archive
once to the SMTP envelope for API, MCP, and task sends, preserving visible
recipient order and matching an existing recipient only by exact local-part and
case-insensitive domain—without adding a MIME Bcc header or
changing visible To, header From, envelope MAIL FROM, SPF, DKIM content, or
DMARC alignment. Archive mailbox access, privacy, retention, aliases, and
forwarding are operator/MTA responsibilities.
The archive is an independent, off-domain compliance destination—not an
ordinary untrusted recipient. For all-local visible recipients it receives the
exact MIME, including the X-OA-Mail-Stamp that preserves local internal
classification. Same-domain ALWAYS_BCC values are rejected at startup: on a
shared catch-all deployment they are not an independent archive destination,
and duplicate suppression is not a substitute for that boundary. Configure a
controlled external compliance mailbox or leave ALWAYS_BCC unset.
Every enabled archive requires an explicit TASK_SIGNING_SECRET of at least
32 characters. The application enforces only presence and that length minimum;
it does not prove entropy, so operators must generate a high-entropy secret
(the Compose examples use openssl rand -hex 32). The historical SMTP-password
fallback remains only when the archive is absent or blank. Both Compose
deployments already require the dedicated secret.
If at least one original recipient is accepted and the configured archive RCPT is rejected, the API preserves Nodemailer's partial-success semantics and logs a content-free warning, even when another original recipient was rejected. If the archive is accepted while no original recipient is reported accepted, the API fails instead. After an upstream SMTP server accepts or queues the archive RCPT, later delivery failures appear in SMTP/Postfix/relay logs or DSNs rather than synchronously in the API response.
SMTP result matching deliberately keeps local-part spelling exact. A relay that rewrites accepted RCPT local-part case can therefore cause a conservative failure/retry; configure relays to preserve RCPT spelling. Case-distinct archive and original local-parts are intentionally separate SMTP mailboxes and can produce two copies with a case-folding provider, so use consistent spelling.
Requires Node.js 18+ on the machine running the MCP client — no install step,
npx downloads and runs the package on first use. The stdio client only needs
OPENAGENTEMAIL_API_URL and OPENAGENTEMAIL_API_KEY; it does not read the
mail server's DOMAIN / API_KEYS / IMAP / SMTP variables.
Or the raw JSON config (Claude Desktop, Cursor, Kimi Code):
| Tool | Description |
|---|---|
mail_new_identity(name?, localpart?, scopes?) | Admin only: create an identity and one-time token; pass scopes: ["read:messages"] for read-only own-mailbox access, scopes: [] for no API operation permissions, or omit for legacy full identity permissions |
mail_list_identities() | List all identities |
mail_list_messages(address, limit?) | List messages for an address (id/from/to/subject/date/seen/snippet) |
mail_read_message(address, id) | Full message: text, html?, and otp:{codes:[],links:[]} |
mail_mark_seen(address, id, seen?) | Mark a message read (default) or unread — reading never changes the flag by itself |
mail_wait_for(address, fromContains?, subjectContains?, timeoutSec?) | Block until a matching message arrives (default 120s, max 600s) |
mail_send(from, to, subject, text, html?) | Send mail; from must be an existing identity |
notify_user(title, message, level?, tags?) | Send a human alert (needs the server-side can_notify_user grant) |
notify_agent(name, title, message, level?, tags?) | Wake a named agent without exposing an ntfy topic or token |
notify_check(since?) | Read recent notifications for the calling identity only |
notify_verify() | Publish and poll a harmless server-side notification self-check |
task_create(to, subject, body?, kind?, approval?, wait?, parentTaskId?) | Assign an ordinary email-backed task (required body) or typed approval (kind: "approval" with required approval:{action,expiresAt}); parentTaskId selects a durable readable parent |
task_list_children(parentTaskId, limit?, cursor?) | List only direct children readable by the current viewer, with 20/50/100 pagination, a parent/viewer/limit-scoped cursor, and no totals or descendants |
task_decide(id, decision) | Stored reviewer approves or rejects a pending typed approval |
task_claim(id, leaseSec?) | Claim a recipient task for an optional lease duration |
task_renew(id, leaseToken, leaseSec?) | Renew a claimed task using its opaque bearer |
task_release(id, leaseToken, reason?) | Release a claimed task using its opaque bearer |
task_list(state?) | List task threads visible to the calling identity |
task_get(id, wait?) | Read a task thread and its stamped state history; optionally wait up to 10 minutes |
task_update(id, state, body?, result?, leaseToken?) | Advance a participating task; structured output goes in result |
Typed approval actions are JSON-only and limited to 65,536 canonical UTF-8 bytes, root-inclusive depth 10, and a server-clock lifetime of 30 days. Exact limits pass; REST and MCP surface approval_action_too_large, approval_action_too_deep, or approval_expiry_too_far as stable client errors when a bound is exceeded.
The exact v1 action-digest recipe and public cross-runtime vectors are in docs/approval-digest.md.
Task leases are opt-in; TASK_LEASES_ENABLED defaults to false, so existing clients remain compatible. If the flag is turned off after a lease exists, list/detail responses retain its safe timing and generation fields and add leaseStatus: "disabled"; no lease is silently cleaned up. A lease generation is capped at 24 hours from its initial claim, and no claim or renewal is allowed at or after seven days from the task's first claim; renewals cap their deadline rather than resetting either anchor, and equality is rejected. During an active recipient lease, omitting the optional credential retains task_already_terminal; a supplied wrong, malformed, expired, or reclaimed-generation credential returns task_lease_required at HTTP 409. The opaque leaseToken is only the claim bearer and is never listed, rendered, logged, or emailed. When TASK_LEASES_ENABLED=true, each mailbox requires exactly one API process (single-replica deploy). Multi-process sharing of a mailbox is an unsupported boundary: concurrent claims can assign the same generation, and durable rebuild fail-closes on same-generation different-body claims (the task is hidden; this is not silent corruption and has no cross-task impact). Future cross-process coordination, if introduced, will be derived from the durable stream under the already-decided redesign in issues #80 and #84. Optional TASK_LEASES_EXPIRY_AUDIT_M3 (default false) decouples reclaim from expiry-audit SMTP: when on, claim derives lease expiry from server time so audit delivery cannot block a new claim. Late matching expiry receipts are always rebuild no-ops (ungated). Optional TASK_LEASES_OVERLAY_BOUND (default false) stops public list/detail replay of unindexed lease overlay events after 15 minutes from sentAt.
Full per-client setup (Claude Code, Claude Desktop, Cursor, Kimi Code, generic): docs/mcp-clients.md · server details: packages/mcp/README.md
Measured on our own production instance, idle: ~190 MB RAM total, ~0% CPU, and ~2 GB of disk for the Docker images. Mail itself is a rounding error — retention auto-deletes after 30 days.
| Tier | Spec | Notes |
|---|---|---|
| Minimum | 1 vCPU / 1 GB RAM / 10 GB disk | works with the defaults (ClamAV and SpamAssassin off) |
| Comfortable | 1 vCPU / 2 GB RAM / 20 GB disk | headroom to enable SpamAssassin |
| With antivirus | 4 GB RAM | ClamAV alone needs ~1 GB extra |
That's a $5/mo VPS — or a $10–15/year deal box. The real prerequisite isn't size, it's port 25: AWS, GCP, Azure, DigitalOcean and Vultr block it by default (some unblock on request). Check before you buy — or route outbound through a relay and you don't need port 25 out at all.
| openagent.email | AgentMail.to | MailSlurp | |
|---|---|---|---|
| Open source | ✅ Apache-2.0 | ❌ | ❌ |
| Deployment | ✅ Any VPS (true self-host) | SaaS, or enterprise BYOC (Outposts on AWS) | SaaS only |
| Price | Flat VPS cost | Per-inbox subscription | Usage-based subscription |
| Unlimited inboxes | ✅ (catch-all) | Paid tiers | Paid tiers |
| MCP-native | ✅ | ✅ | ❌ (REST/SDKs) |
| OTP/link extraction | ✅ | ✅ | ✅ |
| Mail data residency | Your box* | SaaS: theirs; Outposts: your AWS† | Always theirs |
| Vendor control plane | None | Yes (incl. Outposts) | Yes |
| You run a server | Yes — that's the point | No (BYOC still vendor-operated) | No |
* Push tiers 2/3 may relay subject/from or body/OTP via ntfy (off by default). † AgentMail Outposts: email content stays in your AWS account; AgentMail still runs dashboard, auth, billing, and upgrades. BYOC ≠ open-source self-host on any VPS.
wait_for with OTP/link
extraction, DNS wizard + doctor, optional SMTP relay.Issues and PRs welcome — see CONTRIBUTING.md.