- Confirmed core bet via spike/mimecheck: mox/message parses a messy multipart message standalone (envelope, MIME tree, attachment, body) with only an io.ReaderAt — no mox store/config. Option B is viable. - cmd/openmail single binary: serve|migrate|smtpd|sender|version subcommands. - internal/api: chi router, constant-time bearer auth, /healthz, stubbed v0 AgentMail-shaped routes (501 until core services land). - internal/store: pgxpool + embedded, idempotent, tracked SQL migrations. - internal/store/migrations/0001_init.sql: full native schema (pods, inboxes, threads, messages, attachments, drafts, api_keys, webhooks, outbox, events, domains) with FTS + GIN indexes. Validated end-to-end against Postgres 16. - deploy/: docker-compose (postgres+minio+openmail+caddy), Caddyfile, DNS.md (MX/SPF/DKIM/DMARC/DANE/MTA-STS). Makefile, .gitignore. - Verified: go vet clean; build OK; health/auth smoke tests; migrate idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
OpenMail — Architecture
Agent-native, self-hosted mail server. One Go binary that embeds Mox's (MIT) mail internals for the correctness-critical 95% — DKIM, SPF/DMARC, DANE + MTA-STS delivery, real-world MIME, spam filtering — and owns a native, agent-shaped data model (Postgres + object store) with a REST API and MCP server on top. This document is the design of record. It favors an MVP that actually receives and sends real mail over a perfect feature set.
0. The decision: embed Mox, don't rebuild and don't just wrap it
An agent-mailbox API is ~5% of the work. The other 95% is mail infrastructure (inbound :25 hardening, outbound deliverability, MIME, threading, quote-stripping, anti-abuse). Three ways to get that 95%:
- Build from scratch — re-implement DKIM/SPF/DMARC/DANE/MTA-STS/MIME ourselves. Correct but slow, and we'd do it worse than a production server at first.
- Sidecar an existing server (run Mox as a separate process, read it over IMAP). Fast, but we
don't own the data model — threads/labels/
extracted_textwould be re-derived from IMAP, and we ship two coupled processes. - Embed Mox's packages into our own binary and own the storage + API. ← chosen.
Why (3) is viable: Mox has no internal/ directory — every package is importable — and the
README states "most non-server Go packages are written to be reusable." The real boundary is not
visibility, it's storage coupling: Mox's server + store packages are welded to its embedded
bstore/bbolt store and global config; its protocol/crypto/parsing packages are stateless and
clean. We embed the latter and replace the former with our own Postgres-backed server.
What we embed from Mox vs. what we own
| Concern | Source | Notes |
|---|---|---|
| MIME parsing | mox message |
Battle-tested on real, broken senders. Better than enmime for our needs. |
| DKIM sign + verify | mox dkim |
|
| SPF / DMARC | mox spf, dmarc |
Inbound auth verification + policy. |
| Secure outbound delivery | mox smtpclient, dane, mtasts, dns |
The deliverability moat — DANE/MTA-STS MX delivery, mostly solved by reusing this. |
| Bounces (DSN) | mox dsn |
Parse/generate delivery status notifications. |
| Anti-abuse | mox iprev, dnsbl, ratelimit |
rDNS check, blocklists, rate limiting on :25. |
| Spam filtering | mox junk |
Built-in Bayesian filter; per-inbox train/score. |
| SMTP auth | mox sasl, scram |
For the submission (send) path. |
| — | — | — |
| Inbound SMTP server loop | emersion/go-smtp |
Clean, embeddable listener/session. We do NOT import mox smtpserver (store-coupled). |
| Storage | OpenMail (Postgres + S3) | Native agent data model. NOT mox store/bbolt. |
| API, MCP, threading, extraction, webhooks, auth, multi-tenancy | OpenMail | The agent-native layer — our differentiation. |
Boundary rule: never import
smtpserver,imapserver,queue,store, ormox-(config) — they assume Mox's storage/config. Importing them is the line between "embed as a library" and "fork Mox." We stay on the library side.
1. Component overview
┌──────────────────────────────────────────────────────┐
Internet senders ───▶ │ smtpd (go-smtp on :25) │
(MX → this VPS) │ iprev/dnsbl/ratelimit → SPF/DKIM/DMARC verify │
│ → mox/message parse → mox/junk score → enqueue │ ← mox pkgs
└───────────────┬──────────────────────────────────────┘
│ internal queue (Postgres LISTEN/NOTIFY)
┌───────────────▼──────────────────────────────────────┐
│ core (Go) │
Agents / SDKs ──REST──▶ • inbox/message/thread/draft services │
MCP clients ───MCP──▶ │ • threading, extracted_text, labels, FTS search │
│ • auth (API keys, pods), webhooks/WS │
└──────┬───────────────────────────────┬────────────────┘
│ │
┌────────────▼─────┐ ┌─────────────▼──────────────┐
│ Postgres │ │ Object store (S3/MinIO) │
│ metadata + FTS │ │ raw .eml + attachments │
└──────────────────┘ └────────────────────────────┘
▲
┌───────────────┴──────────────────────────────────────┐
Outbound to world ◀── │ sender (mox/dkim sign → backend) │
│ self-host: mox/smtpclient + dane + mtasts (MX) │ ← mox pkgs
│ relay: SES / Postmark / Resend API │
└──────────────────────────────────────────────────────┘
All roles (smtpd, core API, sender, mcp) ship in one binary with subcommands; deploy together
or split later. Caddy terminates TLS for the HTTP API and auto-manages certs.
2. Tech stack
- Language: Go — single static binary, and the whole mature self-hosted-mail ecosystem (Mox, go-smtp, go-message) is Go, keeping us MIT-compatible. Rust (Stalwart) is AGPL → ruled out.
- HTTP:
chirouter;sqlc+pgxfor typed Postgres access. - Inbound server:
emersion/go-smtplistener → handed to mox packages for verify/parse/score. - Mail correctness/delivery: mox
message,dkim,spf,dmarc,dane,mtasts,dns,smtpclient,dsn,junk,iprev,dnsbl,ratelimit,sasl,scram(see §0 table). - Quote stripping: a
talon/EmailReplyParser-style heuristic pass forextracted_text. - Storage: Postgres (metadata +
tsvectorFTS); S3-compatible object store (MinIO self-host, or S3) for raw.emland attachment blobs. - Events: Postgres
LISTEN/NOTIFYfor the internal inbound queue and to fan out webhooks + a WebSocket stream. (Swap to NATS only if volume warrants.) - Deploy:
docker compose—caddy,openmail(one image, multiple commands),postgres,minio. MX + SPF/DKIM/DMARC/DANE/MTA-STS DNS documented indeploy/.
3. Data model (Postgres)
Native and agent-shaped (not Mox's per-account bbolt index). Resource shapes mirror what agent tooling expects so SDKs/MCP map cleanly.
pods (id, name, created_at) -- tenant isolation
inboxes (id, pod_id, address UNIQUE, display_name, dkim_selector, metadata jsonb, created_at, updated_at)
threads (id, inbox_id, subject, last_message_id, message_count, labels text[], updated_at, created_at)
messages (id, inbox_id, thread_id, message_id_hdr, in_reply_to, references text[],
from_addr, to_addrs text[], cc text[], bcc text[], subject,
preview, text, html, extracted_text, extracted_html,
raw_object_key, -- pointer to raw .eml in object store
spf, dkim, dmarc, -- inbound auth verdicts (from mox pkgs)
junk_score, labels text[], size_bytes, headers jsonb, ts tsvector, -- FTS
created_at, updated_at)
attachments (id, message_id, filename, content_type, size_bytes, object_key, inline bool, content_id)
drafts (id, inbox_id, thread_id, to_addrs text[], cc, bcc, subject, text, html, send_at, client_id)
api_keys (id, pod_id, hash, scopes text[], created_at) -- bearer tokens; store only the hash
webhooks (id, pod_id, url, event_types text[], secret) -- HMAC-signed deliveries
outbox (id, message_id, status, attempts, last_error, next_attempt_at) -- send queue + retries
events (id, inbox_id, type, payload jsonb, created_at) -- audit + WS replay
domains (id, pod_id, name, dkim_privkey_ref, verified bool) -- per-domain DKIM keys + DNS state
Indexes: inboxes(address), messages(thread_id), messages(inbox_id, created_at desc),
GIN on messages.ts (search) and *.labels. Raw MIME + attachments live in object storage; rows
hold pointers.
4. Request / data flows
Inbound (receive)
go-smtpaccepts on :25; anti-abuse gate via moxiprev/dnsbl/ratelimit+ size cap; raw.emlwritten to object store.- mox
spf/dkim/dmarcverify the message; verdicts recorded. moxmessagebuilds the MIME tree →text/html, attachments, headers. moxjunkscores spam. Quote-stripper computesextracted_text+preview. - Threading resolves
thread_idfromIn-Reply-To/References, falling back to normalized-subject- participants within a window.
- Row inserted;
LISTEN/NOTIFYfires →message.receivedwebhook + WebSocket event.
Outbound (send / reply)
- API builds the RFC 5322 message (sets
In-Reply-To/Referenceson replies); persists amessagesrow +outboxentry. sendermox-dkim-signs, then dispatches via the configured backend:- self-host: mox
smtpclient+dane+mtastsfor authenticated, secure MX delivery. - relay: SES/Postmark/Resend API.
- self-host: mox
- Bounces/complaints (mox
dsnparse, or relay webhook) updateoutbox.statusand emit events.
API surface (v1 — AgentMail-shaped)
POST /v0/inboxes create inbox
GET /v0/inboxes / /v0/inboxes/{id} list / get
POST /v0/inboxes/{id}/messages/send send
GET /v0/inboxes/{id}/messages list (limit, page_token, labels)
GET /v0/inboxes/{id}/messages/{id} get
POST /v0/inboxes/{id}/messages/{id}/reply
GET /v0/inboxes/{id}/threads / /{id} list / get
…drafts, webhooks, search…
Bearer auth (Authorization: Bearer). Keeping paths/shapes close to AgentMail's public v0 lets an
existing client (or MCP server) target a self-hosted OpenMail by base-URL swap. Compatibility yields
to a cleaner native shape where they conflict.
MCP server
A thin MCP front-end over core: tools create_inbox, list_messages, get_thread, send_message,
reply, search — so an agent drives its own mailbox directly.
5. Deliverability plan (the moat — and why embedding Mox helps most here)
- Self-host send is achievable from day one for the protocol parts because mox
smtpclient+dane+mtasts+dkimalready implement authenticated, DANE/MTA-STS-secured MX delivery. What remains is operational, not code: dedicated IP with PTR/rDNS, SPF (v=spf1 ip4:… -all), published DKIM key, DMARC (p=quarantine→p=reject), gradual IP warmup, FBL enrollment, suppression lists. - Relay backend (SES/Postmark/Resend) ships alongside for instant inbox placement while a self-host IP warms up — chosen per-domain.
- Inbound auth: SPF/DKIM/DMARC verified on receipt (mox pkgs); verdicts exposed in message metadata; optional reject/quarantine policy per inbox.
6. MVP milestones
- Core API + storage — inboxes/messages/threads/drafts CRUD on Postgres + MinIO; bearer auth; ingest endpoint to seed without real mail. Proves the data model + API.
- Inbound —
go-smtpon :25 + mox verify/parse/junk + threading +message.receivedwebhook. MX a test domain at the VPS. Proves receive. - Outbound via relay —
send/reply, mox-DKIM-signed, through a relay. Proves the loop: an agent receives and replies. - MCP server — agent owns and operates an inbox end-to-end.
- Self-host SMTP send — wire mox
smtpclient/dane/mtasts; the deliverability/warmup long tail.
7. Repo layout (planned)
cmd/openmail/ # single binary: `serve`, `smtpd`, `sender`, `mcp` subcommands
internal/core/ # inbox/message/thread/draft services
internal/smtp/ # go-smtp inbound server; calls mox verify/parse/junk + threading
internal/sender/ # Sender interface: smtp (mox smtpclient+dane+mtasts) & relay backends; mox dkim
internal/store/ # pgx/sqlc queries, object-store client, embedded migrations
internal/store/migrations/ # SQL migrations (go:embed'd into the binary)
internal/api/ # chi HTTP handlers (v0 surface)
internal/mcp/ # MCP server over core
deploy/ # docker-compose.yml, Caddyfile, DNS (MX/SPF/DKIM/DMARC/DANE/MTA-STS) notes
spike/ # throwaway feasibility spikes (mimecheck: mox standalone parse — PASSED)
8. Licensing & attribution
OpenMail is MIT (see LICENSE). Every dependency is permissive: Mox (MIT), emersion/go-smtp
and go-message (MIT), pgx/chi (MIT/BSD). We import Mox as a Go module (no vendored source in
this repo), but credit it prominently in the README; if we ever vendor Mox source we must retain its
MIT notice. No GPL/AGPL code anywhere — Maddy (GPL-3.0) and Stalwart (AGPL) are deliberately
avoided so the project stays MIT.
9. Open questions
- mox
message⇄ go-smtp seam: confirm mox's parser consumes a raw reader cleanly outside mox's store (spike in milestone 2). If any Tier-1 package drags inmox-/storetransitively, isolate behind a thin adapter or vendor just that file. - How far to chase AgentMail API compatibility vs. a cleaner native shape.
- Search: Postgres FTS is enough for v1; revisit (Meilisearch/Typesense) only if needed.
- Multi-tenancy depth —
podsnow, full RBAC later. - Whether to expose an optional IMAP read path (via mox
imapserverover our store would mean forking it — more likely a thin standalone IMAP front-end later, kept out of v1).