Files
Karti TripathiandClaude Opus 5 70e7eccd3c ROADMAP: scaffold complete; record the seven traps already paid for
Definition-of-ready is met: repo pushed to both remotes, CI green on all
three jobs, openmail.karti.ai live, and all three tier-1 crates pass
cargo publish --dry-run.

Two items left open on purpose and written down rather than dropped: no
x86_64 CI job while GitHub-hosted runners are unavailable for billing, and
the repo stays private until v0.1 closes the ingest loop per ADR 0004.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkyvfNJGTshJNE9FtwPLk7
2026-09-02 14:03:29 -07:00

7.3 KiB

OpenMail — goal and roadmap

The goal

Be the mail server agents run on. Self-hosted, Apache-2.0, agent-native — the option an enterprise can point MX at without trusting us, and a builder can build a commercial product on without asking us.

Not a hosted product. Not open-core. See docs/adr/0006-self-hosted-first.md.

How we'll know it worked

Signal
Technically An agent creates its own inbox, receives real mail from Gmail, and replies — with the reply landing in the inbox, not spam — with no human in the loop.
In the ecosystem mail-dane and mail-mta-sts are depended on by a Rust project that is not ours.
Commercially One business we did not talk into it is running OpenMail on their own infrastructure, on their own domain.

None of those are v0.1. They are what v0.1 is aimed at.


Definition of ready: what "ready to build" means

The scaffold is done. This is the checklist that says the foundation is finished and feature work can start without stopping to fix plumbing.

  • Apache-2.0, NOTICE, licence policy enforced in CI (cargo-deny)
  • 12-crate workspace; check/test/clippy/fmt green on x86_64 and aarch64
  • unsafe_code = "forbid" workspace-wide
  • Postgres schema as an embedded migration
  • Six decisions recorded as ADRs, each with its cost stated
  • Multi-stage Dockerfile, non-root, no shell in the runtime image
  • OCI outbound port block measured, not assumed — 25 blocked, 587/465/2525 open
  • Repo pushed to karti-ai/openmail (and mirrored to Gitea OSS/openmail)
  • CI green — all three jobs on the spark-1 aarch64 runner
  • openmail.karti.ai live
  • cargo publish --dry-run clean on all three tier-1 crates

The scaffold is done. Feature work can start.

Two things deliberately left open

  • GitHub-hosted runners are unavailable on the account"recent account payments have failed or your spending limit needs to be increased." Every job runs on spark-1 instead, which is also the deployment architecture, so this is not purely a workaround. An x86_64 job should be added back once hosted runners work (or once the repo is public, which makes them free), to catch width- and endianness-dependent bugs that aarch64-only CI cannot.
  • The repo is still private. ADR 0004 argues for going public once v0.1 closes the ingest loop, so the first impression is a working thing rather than a scaffold.

v0.1 — the agent layer, provable without mail

Thesis to prove: the agent-native model is worth having, independent of whether we can move mail yet. Everything here is testable in CI with no mail infrastructure at all.

  • openmail-core::extract::strip_quoted — the first real algorithm. Prefer under-stripping to over-stripping; losing new content is unrecoverable, keeping a few quoted lines only costs tokens.
  • openmail-core::thread — both bases (Headers, SubjectHeuristic) with the time window that bounds the wrong answer
  • openmail-store — inbox/message/thread queries, S3 blob client
  • openmail-api — the v0 handlers behind bearer auth
  • POST /v0/inboxes/{id}/ingest — the loop-closer. Feed it a raw .eml, get back a threaded, extracted message. This is the milestone.
  • A corpus of ugly real-world .eml fixtures. Not synthetic — Outlook, Gmail, mobile clients, and at least one message that is legally MIME and practically deranged.

Done when: docker compose up, POST a real message, get correct threading and clean extracted_text back.

v0.2 — receive

  • openmail-smtpd on :25 with Limits enforced and a timeout on every state
  • openmail-guard — iprev, DNSBL, rate limit, in that order (never spend a DNS round trip on a connection a counter can reject)
  • mail-auth SPF/DKIM/DMARC verdicts recorded per message
  • openmail-junk per-inbox scoring
  • message.received webhooks + WebSocket
  • Real MX for a test domain; open OCI inbound :25 and prove mail arrives

v0.3 — send, and the surface agents actually use

  • Relay send via both SES and Oracle from the start — two providers is what forces the abstraction to stay data-driven
  • Local DKIM signing
  • mail-dsn bounce handling wired to outbox state
  • openmail-mcp served — an agent creates an inbox, receives, and replies, with no human involved

v0.4 — direct-to-MX, and the crates ship

  • mail-dane complete — DNSSEC-validated TLSA, and the property that an unauthenticated set can never return Match
  • mail-mta-sts complete — policy cache where a fetch failure never evicts a valid policy
  • Publish both to crates.io. First permissive implementations in Rust.
  • Direct MX delivery with both enforced. Not on OCI — that host blocks :25.
  • IP warmup, FBL enrolment, suppression lists

v1.0

  • IMAP front-end so a human uses their own client against the same mailbox
  • Multi-tenancy beyond pods
  • A deliverability track record worth publishing

Standing rules

  1. Never copy from an AGPL/GPL tree. Stalwart's server, maddy, BillionMail are read-only reference. Work from RFCs. ADR 0003.
  2. Silent failures are the highest-severity class. A DANE downgrade or a mis-cached MTA-STS policy does not error — it quietly removes a security property or defers mail nobody sees. Every outcome in those crates is an explicit enum with no Default and no bool.
  3. Measure, don't assume. The OCI port block was a documented "fact" that community reports contradicted; ten minutes of testing settled it. Do that every time.
  4. pods is not dead code. It is the only thing keeping a future hosted offering migration-free.
  5. Zero clippy warnings is the gate, not an aspiration.

Traps already paid for

Recorded because each cost real time, and the next person (or the next session) should not pay again.

Trap What happens Rule
Swatinem/rust-cache on a self-hosted runner Its post-step prunes ~/.cargo, deleting the real toolchain. The first run passes, then every later run fails exit 127. Never use it on self-hosted. Toolchain lives at /opt/rust.
Runner .path / .env files Were not applied by this runner. Set PATH, RUSTUP_HOME, CARGO_HOME in the workflow env: block.
Symlinking cargo-clippy into /usr/local/bin Breaks clippy's sysroot resolution — cargo clippy silently behaves like rustc and rejects --workspace. Use a full rustup install and put its bin on PATH; do not symlink individual toolchain binaries.
mail-auth's generate feature Pulls in rsa, carrying RUSTSEC-2023-0071 (Marvin Attack, no fix available). Don't enable it. Generate DKIM keys with openssl genpkey, out of process.
cloud-2 Caddy without bind 10.0.0.2 Serves a valid certificate and an empty 200 — looks deployed, returns nothing. Always include the bind; verify with curl, never by eye.
GitHub push with a real email remote rejected … email privacy restrictions. Repo is configured with the users.noreply.github.com address.
git.karti.ai:2223 from amd-server Times out; Gitea SSH is tailnet-only. Use the gitea SSH alias (gitea:OSS/openmail.git).