Architecture: self-hosted AI-native mailbox for agents
Design of record for OpenMail — one Go binary (smtpd/core/sender/mcp) + Postgres + object storage on a VPS. Covers inbound SMTP/MIME/threading, outbound deliverability (relay-default, self-host opt-in), data model, AgentMail-shaped v0 API + MCP server, and an MVP milestone path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+171
@@ -0,0 +1,171 @@
|
||||
# OpenMail — Architecture
|
||||
|
||||
Self-hosted, AI-native mailbox for agents. One Go service, Postgres, and object storage on a VPS,
|
||||
fronted by Caddy for TLS. 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 honest framing
|
||||
|
||||
An agent-mailbox API is ~5% of the work. The other 95% is **mail infrastructure**:
|
||||
|
||||
| Hard part | Why it's hard |
|
||||
|---|---|
|
||||
| **Inbound** | You must run an SMTP server on port 25 with MX records pointing at it, accept from arbitrary senders, survive spam/abuse, and parse messy real-world MIME. |
|
||||
| **Outbound deliverability** | Anyone can send SMTP; getting it to the *inbox* needs SPF + DKIM signing + DMARC alignment, PTR/rDNS, warmed IP reputation, feedback loops (FBLs), and bounce/complaint handling. This is the real moat. |
|
||||
| **MIME parsing** | Multipart trees, encodings, inline vs attachment, calendar parts, broken senders. |
|
||||
| **Threading** | Stitching messages into conversations from `Message-ID` / `In-Reply-To` / `References` plus subject heuristics. |
|
||||
| **Quoted-history stripping** | The `extracted_text` feature (reply content without the quoted thread) is its own parsing problem. |
|
||||
|
||||
OpenMail is structured so each of these is an isolated, swappable component, and so v1 can lean on a
|
||||
relay for deliverability while the self-hosted SMTP path matures.
|
||||
|
||||
## 1. Component overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
Internet senders ───▶ │ smtpd (inbound, port 25, go-smtp) │
|
||||
(MX → this VPS) │ → parse MIME → normalize → enqueue │
|
||||
└───────────────┬──────────────────────────────┘
|
||||
│ internal queue (Postgres LISTEN/NOTIFY or NATS)
|
||||
┌───────────────▼──────────────────────────────┐
|
||||
│ core (Go) │
|
||||
Agents / SDKs ──REST──▶ • inbox/message/thread/draft services │
|
||||
MCP clients ───MCP──▶ │ • threading, extraction, labels, search │
|
||||
│ • auth (API keys, pods), webhooks/WS │
|
||||
└──────┬───────────────────────┬───────────────┘
|
||||
│ │
|
||||
┌────────────▼─────┐ ┌───────────▼───────────────┐
|
||||
│ Postgres │ │ Object store (S3/MinIO) │
|
||||
│ metadata + FTS │ │ raw MIME + attachments │
|
||||
└──────────────────┘ └────────────────────────────┘
|
||||
▲
|
||||
┌───────────────┴──────────────────────────────┐
|
||||
Outbound to world ◀── │ sender (DKIM-sign → self-host SMTP OR relay)│
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
All four roles (`smtpd`, `core` API, `sender`, MCP) ship in one Go 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; matches the deployment-simplicity goal and OpenMail's
|
||||
systems nature).
|
||||
- **HTTP:** `chi` router; `sqlc` + `pgx` for typed Postgres access.
|
||||
- **Inbound SMTP:** `emersion/go-smtp` (server) — listens on :25, hands raw bytes to the parser.
|
||||
- **MIME:** `emersion/go-message` (+ `enmime` for lenient real-world parsing); a quote-stripper
|
||||
(port of `talon`/`EmailReplyParser` heuristics) for `extracted_text`/`extracted_html`.
|
||||
- **DKIM:** `emersion/go-msgauth/dkim` for signing (outbound) and verifying (inbound).
|
||||
- **Outbound:** pluggable `Sender` interface — `smtp` (self-host, MX delivery) or `relay`
|
||||
(SES / Postmark / Resend API). Default to relay in v1 for deliverability; self-host is opt-in.
|
||||
- **Storage:** Postgres (metadata + full-text search via `tsvector`); S3-compatible object store
|
||||
(MinIO self-hosted, or AWS S3) for raw `.eml` and attachment blobs.
|
||||
- **Events:** Postgres `LISTEN/NOTIFY` for the internal inbound queue and for fanning out
|
||||
webhooks + a WebSocket stream. (Swap to NATS if volume warrants.)
|
||||
- **Deploy:** `docker compose` — `caddy`, `openmail` (one image, multiple commands), `postgres`,
|
||||
`minio`. MX + SPF/DKIM/DMARC DNS documented in `deploy/`.
|
||||
|
||||
## 3. Data model (Postgres)
|
||||
|
||||
Mirrors the resource shapes agent tooling already expects, so SDKs/MCP map cleanly.
|
||||
|
||||
```sql
|
||||
pods (id, name, created_at) -- tenant isolation
|
||||
inboxes (id, pod_id, address UNIQUE, display_name, client_id, 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
|
||||
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
|
||||
```
|
||||
|
||||
Indexes: `inboxes(address)`, `messages(thread_id)`, `messages(inbox_id, created_at desc)`,
|
||||
GIN on `messages.ts` (search) and `*.labels`. Raw MIME and attachments live in object storage, not
|
||||
the DB — rows hold pointers.
|
||||
|
||||
## 4. Request / data flows
|
||||
|
||||
### Inbound (receive)
|
||||
1. `smtpd` accepts on :25, enforces basic anti-abuse (rate limit, SPF check, size cap), and writes
|
||||
the raw `.eml` to the object store.
|
||||
2. Parser builds the MIME tree → extracts `text`/`html`, attachments, headers; computes
|
||||
`extracted_text` (strip quoted history) and a `preview`.
|
||||
3. Threading resolves `thread_id` from `In-Reply-To`/`References` → fallback to normalized-subject +
|
||||
participants within a window.
|
||||
4. Row inserted; `LISTEN/NOTIFY` fires → webhook + WebSocket `message.received` event.
|
||||
|
||||
### Outbound (send / reply)
|
||||
1. API builds the RFC 5322 message (sets `In-Reply-To`/`References` for replies), persists a
|
||||
`messages` row + `outbox` entry.
|
||||
2. `sender` DKIM-signs and dispatches via the configured backend (self-host SMTP or relay API).
|
||||
3. Bounces/complaints (SMTP DSN, or relay webhook) update `outbox.status` and 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 means an
|
||||
existing Go/Python/Node client (or MCP server) can target a self-hosted OpenMail by base-URL swap.
|
||||
|
||||
### MCP server
|
||||
A thin MCP front-end over the same core: tools `create_inbox`, `list_messages`, `get_thread`,
|
||||
`send_message`, `reply`, `search` — so an agent (Claude Code, etc.) drives its own mailbox directly.
|
||||
|
||||
## 5. Deliverability plan (the part that actually matters)
|
||||
|
||||
- **v1: relay-by-default.** Send through SES/Postmark/Resend for inbox placement on day one; OpenMail
|
||||
still owns inbound, storage, threading, API. Lowest-risk way to be useful immediately.
|
||||
- **Self-host SMTP send (opt-in):** dedicated IP with PTR/rDNS, SPF (`v=spf1 ip4:… -all`), DKIM
|
||||
signing (publish the public key), DMARC (`p=quarantine` → `p=reject`), gradual IP warmup, FBL
|
||||
enrollment, and bounce/complaint suppression lists. Documented honestly as a multi-week reputation
|
||||
effort, not a flag flip.
|
||||
- **Inbound auth:** verify SPF/DKIM/DMARC on receipt; expose pass/fail in message metadata; optional
|
||||
reject/quarantine policy per inbox.
|
||||
|
||||
## 6. MVP milestones
|
||||
|
||||
1. **Core API + storage** — inboxes/messages/threads/drafts CRUD against Postgres + MinIO; bearer
|
||||
auth; no real mail yet (seed via an ingest endpoint). *Proves the data model + API.*
|
||||
2. **Inbound** — `go-smtp` on :25, MIME parse, extraction, threading, `message.received` webhook.
|
||||
MX a test domain at the VPS. *Proves receive.*
|
||||
3. **Outbound via relay** — `send`/`reply` through SES/Postmark with DKIM. *Proves the loop:
|
||||
an agent receives and replies.*
|
||||
4. **MCP server** — agent owns and operates an inbox end-to-end.
|
||||
5. **Self-host SMTP send + deliverability hardening** — the long tail.
|
||||
|
||||
## 7. Repo layout (planned)
|
||||
|
||||
```
|
||||
cmd/openmail/ # single binary: `serve`, `smtpd`, `sender`, `mcp` subcommands
|
||||
internal/core/ # inbox/message/thread/draft services
|
||||
internal/smtp/ # inbound server + MIME parse + extraction + threading
|
||||
internal/sender/ # Sender interface + smtp & relay backends + DKIM
|
||||
internal/store/ # sqlc queries, object-store client
|
||||
internal/api/ # chi HTTP handlers (v0 surface)
|
||||
internal/mcp/ # MCP server over core
|
||||
deploy/ # docker-compose.yml, Caddyfile, DNS (MX/SPF/DKIM/DMARC) notes
|
||||
db/migrations/ # SQL migrations
|
||||
```
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
- Relay vs self-host SMTP as the *default* — relay is pragmatic, self-host is the point. Likely ship
|
||||
relay default + self-host documented.
|
||||
- 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 — `pods` now, full RBAC later.
|
||||
@@ -0,0 +1,38 @@
|
||||
# OpenMail
|
||||
|
||||
A self-hosted, AI-native mailbox for agents — open-source infrastructure that gives an AI agent its
|
||||
own real email inbox (receive, parse, thread, search, send) on a box you control.
|
||||
|
||||
Think "Gmail for agents, self-hosted." A single Go service + Postgres + object storage you run on a
|
||||
VPS, exposing a clean REST API (and an MCP server) so any agent can own an address, read its mail as
|
||||
structured threads, and reply.
|
||||
|
||||
> **Status: private WIP.** This is a personal learning project and a self-hosting alternative in a
|
||||
> space that hosted products (e.g. AgentMail) serve well. Keep it private. Don't reference it in any
|
||||
> job application, PR, or interview, and shelve it if it would ever conflict with an employer. It was
|
||||
> designed only from public API surfaces and public RFCs — nothing proprietary.
|
||||
|
||||
See **[ARCHITECTURE.md](./ARCHITECTURE.md)** for the design.
|
||||
|
||||
## Why
|
||||
|
||||
The hard, valuable part of an agent-mailbox product is not the API — it's the mail plumbing:
|
||||
receiving over SMTP/MX, sending with real deliverability (SPF/DKIM/DMARC, IP reputation), parsing
|
||||
MIME, threading, and storage at scale. OpenMail is an exercise in building that plumbing properly,
|
||||
self-hostable, with an agent-first API on top.
|
||||
|
||||
## Goals
|
||||
|
||||
- **Self-hostable** in one `docker compose up` on a single VPS, scaling to a small fleet later.
|
||||
- **Agent-first API** — persistent inboxes, structured threads, `extracted_text` (quoted-history
|
||||
stripped), labels, search, drafts, webhooks/WebSocket events.
|
||||
- **AgentMail-API-shaped** where reasonable, so existing agent tooling/MCP can point at a self-hosted
|
||||
endpoint with minimal change. (Compatibility is a non-goal where it conflicts with a cleaner design.)
|
||||
- **Deliverability taken seriously** — DKIM signing, SPF/DMARC guidance, and a pluggable send path
|
||||
(self-host SMTP **or** relay through SES/Postmark) because reputation is the real moat.
|
||||
|
||||
## Non-goals (for v1)
|
||||
|
||||
- A hosted multi-tenant SaaS. OpenMail is self-host-first.
|
||||
- Beating a mature provider on deliverability out of the box — that takes IP warmup and time.
|
||||
- A webmail UI. The product is the API + MCP; humans use their own client or the CLI.
|
||||
Reference in New Issue
Block a user