Mxr
Mxr
A daemon-backed, terminal-first email client. CLI core, JSON-over-Unix-socket protocol, multiple frontends. The architectural blueprint that I later applied to Lazydap.
Project lives at: ~/code/planetaryescape/mxr/
Repository: https://github.com/planetaryescape/mxr
What mxr is, in one paragraph
A single binary mxr with subcommands. Bare mxr enters a TUI if interactive; mxr search, mxr cat, mxr compose, etc. are CLI subcommands that auto-spawn a daemon and talk to it over a Unix socket using length-delimited JSON. The daemon owns SQLite (canonical state), Tantivy (search index, rebuildable from SQLite), and provider adapters (Gmail, IMAP, SMTP). Every operation flows through one IPC contract — the TUI doesn't get privileged access, the agent skill doesn't either, neither does anything anyone builds on top. The CLI is the canonical surface; everything else is one of N possible clients.
Philosophy
1. Local-first, daemon-backed, scriptable
Email lives on your machine. SQLite is the source of truth. Tantivy can be rebuilt from SQLite if it's lost. No phone-home. No telemetry. The daemon is local; clients are local. If you want remote access you write a bridge using the protocol, but mxr itself is single-machine.
This is the Local-First Software principle applied to communication tooling — your email shouldn't disappear when your network does, and you shouldn't have to ask Google for permission to grep your own inbox.
2. CLI-first, scriptable from any language
The CLI is the product. The TUI is one client. The agent skill is another. Anything anyone wants to build (Electron app, web dashboard, vim plugin, Slack bot, custom analytics) is just another client speaking the same JSON-over-Unix-socket protocol. No client gets special access.
This is the porcelain on plumbing pattern: SQLite + IMAP/SMTP/Gmail are the plumbing; mxr is the porcelain. Any tool consuming mxr is a porcelain on top of mxr's porcelain. Wrappers all the way down. (See Yes It's a Wrapper thinking applied here too.)
The single rule, enforced by Cargo's dependency graph rather than convention: the TUI cannot depend on the daemon-internal crates. It cannot reach past the protocol. If you want a feature, it has to flow through the IPC contract.
3. Provider-agnostic internal model
mxr's internal types (Email Internal Model) are provider-agnostic. Gmail's labels, IMAP's folders, and Microsoft's both-of-them all collapse to mxr's Label enum with LabelKind: System | Folder | User. The provider quirks live in adapter crates (crates/provider-gmail, crates/provider-imap, crates/provider-smtp); the daemon talks to a stable trait.
This is the adapter pattern applied to email. Same shape lazydap uses for debug adapters. Generalisable: when you wrap N backends, define a common trait, push backend quirks into adapter crates, keep the core clean.
4. JSON output is a product feature
Every command supports --format json. Auto-detected from TTY: if stdout is a terminal, default to table; if piped, default to json. Schemas are stable. Breaking the JSON shape is a major version bump. Documented in docs/blueprint/15-decision-log.md under "Non-negotiables."
This is what makes mxr agent-friendly without being agent-only. An LLM, a shell pipeline, and a web app all consume the same JSON happily.
5. Test with real systems, not mocks
mxr's testing discipline: a FakeProvider exists for fast unit tests, but the canonical integration tests run real IMAP via a Dovecot fixture and real Gmail via OAuth-mocked transport. Mocks of provider behaviour silently mask real-world bugs (label reorderings, IDLE disconnects, OAuth refresh races). Real systems catch them.
6. Mutations are dry-runnable
Every mutation supports --dry-run. The selection logic that previews what would change is the same code path as the real mutation; you can't have a --dry-run that "works in preview but does something different in real." Inviolable.
This becomes Lazydap's rule too. It came from mxr's pain: a bulk-archive bug where preview said "1,200 messages" and execution archived 12,000 because the queries diverged. After that, framework rule: same code, same selection, no exceptions.
Architecture summary
Agent skill TUI Web app (SPA) Custom script
│ │ │ │
│ │ ▼ │
│ │ Web bridge (HTTP+WS) │
│ │ │ │
└──────────────┴──────────────┴──────────────────────┘
│
│ mxr protocol
│ (length-delimited JSON over Unix socket)
▼
mxr-daemon
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
SQLite Tantivy Provider adapters
(canonical) (search, (Gmail/IMAP/SMTP/Fake)
rebuildable)
The web app SPA is the GUI surface. It speaks HTTP and WebSocket to the web bridge (crates/web/), which translates to the Unix socket protocol the daemon speaks. The SPA dist is embedded in the daemon binary via include_dir!() and served at / — one artifact ships both. Launched via mxr web. See Embedded SPA in Daemon Binary for the distribution pattern.
Crate dependency rule (enforced by Cargo, not convention):
core— types, traits, errors. Zero I/O.protocol— IPC contract. Depends only oncore.store— SQLite. Depends only oncore.search— Tantivy. Depends only oncore.provider-*— Gmail, IMAP, SMTP, Fake. Depend oncore+mail-parse+outboundonly.tui,web— clients. Can depend oncore,protocol,config,compose,reader,mail-parse. Not on daemon, store, search, sync, or provider crates.daemon— orchestrates everything. Depends on all the above excepttui/web.
If a feature wants to break these rules, the architecture is wrong, not the rule. Violations are caught at build time.
IPC contract — four buckets
Every IPC request must classify into one. New buckets require a decision-log entry.
| Bucket | Purpose | Examples |
|---|---|---|
| CoreMail | Read/write email content | Search, Cat, Thread, Compose, Send, Archive, Trash, Label |
| MxrPlatform | Per-account state | Accounts::List/Add, Rules::*, SavedSearches::*, Semantic::* |
| AdminMaintenance | Daemon health | Status, Logs, Doctor, BugReport, LocalReset |
| ClientSpecific | Pane state, focus, scroll | (TUI/web only — daemon doesn't see these) |
This bucketing inspired Lazydap's own four-bucket protocol (Session / Project / Diagnostics / ClientSpecific).
Key technical decisions (the load-bearing ones)
- SQLite as canonical state — single-writer + concurrent readers, WAL mode. Migrations compile-time-checked via sqlx.
- Tantivy for lexical search — BM25, rebuildable from SQLite at startup if dirty. Search readiness is a separate concern from sync correctness.
- Direct Gmail API, not IMAP for Gmail — Gmail-over-IMAP is a leaky abstraction (no native labels-as-tags semantics). Direct API gives proper labels, snippet, history-based delta sync.
MailSyncProvider+MailSendProvideras separate traits — one address can read via Gmail API and send via SMTP; the split is real.- YAML frontmatter in compose — open
$EDITOR, write a markdown-ish message with YAML headers (To, Subject), save → mxr parses it. No bespoke compose UI. - Plain-text rendering by default — HTML-to-text via reader mode. Remote content (tracking pixels, web fonts) blocked by default.
- Semantic search as optional layer — local embeddings, dense retrieval. Off by default. Doesn't gate sync.
- Bundled Gmail OAuth credentials — mxr ships with its own Google OAuth client ID/secret. Users can override in config. Means the user-facing experience is "click here to sign in" without setting up a Google Cloud project.
- Compile-time checked SQL via sqlx — bad query = build error.
Files agents/contributors should read first
ARCHITECTURE.md— the high-leveldocs/blueprint/15-decision-log.md— every decision with rationaledocs/blueprint/01-architecture.md— full architecturedocs/web-app.md— the institutional knowledge for the web app: locked decisions, embedded SPA serving, auth model, action registry, optimistic mutations, sandboxed HTML renderingdocs/vision.md— the delight-plan maintainer notes: daemon-first, CLI-first, exactness-before-cleverness, pivots and non-goalsdocs/guides/writing-docs.md— the docs discipline; see also How I Write Software Docsdocs/guides/http-bridge.md— the HTTP+WS surface, auth model, DNS rebinding defenses; see also DNS Rebinding Hardeningdocs/reference/ai-email.md— the AI email layer: pre-send safety, citations, single-use override tokens, materialization restraint; see also Deterministic Before LLM, Citations Required, Validator Enforces, Materialize Only When Forced, Single-Use Override Tokensdocs/reference/tokio-runtime-guide.md— house style for async Rust; see also Tokiodocs/reference/email-standards.md— the RFC reference for SMTP/IMAP/MIME/authdocs/idiomatic-rust-tests.md— test quality standard; see also Tests Fail on Realistic Bugsdocs/articles/the-spec-is-the-innovation.md— the philosophy in prosedocs/articles/why-local-first-daemon-backed-email.md— why local-first matters for emailAGENTS.md/CLAUDE.md— agent guidance, dual-purposemxr.skill(a ZIP) — the agent skill manifest
Why mxr exists
Cards on the table: I wrote mxr because I wanted the email client I couldn't find. Specifically: one that lives in the terminal, doesn't require Electron, doesn't require an IDE, doesn't ship analytics, lets me grep my own data, lets agents drive it via shell, and treats SQLite as the canonical store.
The closest existing tools — mutt, notmuch, aerc, meli — are good but each has limits I kept hitting. None had agent-native CLI surface. None auto-spawned a daemon for snappy per-command response. None made gh-style JSON output a first-class feature. None imported Gmail labels with proper semantics.
mxr fills that gap, and along the way it's become the architectural pattern I now apply to other domains. Lazydap is "mxr's pattern, but for debugging." Future tools probably will be too.
What mxr is NOT
- Not a webmail replacement. Not for non-technical users.
- Not a hosted service. Local-first means local-first. Bring your own credentials.
- Not a unified inbox aggregator with proprietary backend. mxr is your email; there's no mxr cloud.
- Not a marketing-style email tool. No campaign sequences, no merge fields, no scheduled sends with templates.
- Not Outlook/Gmail-feature-parity-aspirant. Mxr does the 90% case fast, not the 100% case.
Connections
- Lazydap — same architecture, different domain (email → debugging)
- Spotuify — same architecture, different domain (email → music)
- Plumbing and Porcelain — the metaphor that runs through both
- The Elm Architecture — what the TUI uses internally for state management
- Headless Architecture — the broader pattern mxr is an instance of
- The Local Daemon Pattern — the specific pattern mxr/lazydap share
- Agent-Native Interfaces — what the JSON output + dry-run + skill enable
- How Email Actually Works — for the email-mechanics half (SMTP, IMAP, MIME, threading)
Vault clusters this project touches
If you're reading this in a year and want to traverse, mxr connects to these synthesis notes (each opens its own cluster):
- Email mechanics → How Email Actually Works — SMTP, IMAP, MIME, threading, OAuth, the provider-agnostic internal model. mxr is the concrete reference implementation.
- Daemon architecture → How Daemons Work — auto-spawn, PID files, signals, IPC server. mxr's daemon is the canonical instance.
- IPC plumbing → How Processes Talk to Each Other — Unix sockets + length-prefixed JSON. The wire mxr's clients ride.
- Client-agnostic cores → Client-Agnostic Cores — the architectural pattern (headless core + many clients) mxr embodies.
- TUI state management → The Elm Architecture (TEA) — what mxr's TUI uses internally.
- Docs discipline → How I Write Software Docs — the principles
docs/guides/writing-docs.mdcodifies (runnable per section, document every surface, recipes solve goals, Diátaxis, dry-run-first mutations). - Web app patterns → Shared Action Registry, Optimistic Mutation Funnel, Embedded SPA in Daemon Binary, Locked Decisions in Docs — the load-bearing patterns from
docs/web-app.md. - Preview vs execute → Same-Code-Path Preview — the rule that drives mxr's
--dry-rundiscipline. - AI email layer → Deterministic Before LLM, Citations Required, Validator Enforces, Materialize Only When Forced, Single-Use Override Tokens — the load-bearing patterns from
docs/reference/ai-email.md. - HTTP bridge security → DNS Rebinding Hardening — the three-layer defense for loopback HTTP servers.
- Async Rust → Tokio hub → Concurrent Is Not Parallel, Don't Hold a Lock Across Await, Classify Async Work Before Refactoring, Bounded Concurrency First — the rules from
docs/reference/tokio-runtime-guide.md. - Test quality → Tests Fail on Realistic Bugs — the 5-question gate from
docs/idiomatic-rust-tests.md. - Idiomatic Rust → Idiomatic Rust Rubric — mxr's 26 crates audited against it; the kept lints ratcheted into the build (Ratchet Lints Into the Build), plus the snooze daemon-panic that named expect Is for Invariants Not Input.
The atomic notes inside each cluster cite mxr's specific implementation files as concrete grounding.
Lessons learned (worth carrying to lazydap and beyond)
- The crate dependency graph is the architecture. Not the diagram, not the docs — the
Cargo.tomlfiles. Once enforced there, violations stop at build time and discussions get short. - Test with real systems first, mocks second. Real IMAP, real Gmail OAuth, real SMTP. Mocks lie. The bugs that bite are the ones mocks paper over.
- Stable JSON is a product feature. Once one client depends on a schema, all clients do. Treat the protocol the way you'd treat a public API.
- Auto-spawning daemons remove a class of UX bugs. "Daemon not running" is never a user-facing error if the CLI handles it.
$EDITORfor compose is more powerful than custom editors. Markdown with YAML frontmatter is the format; the editor is whatever the user already loves.- Bundle credentials for OAuth. Users shouldn't need to set up Google Cloud to read their email. Ship sensible defaults; let power users override.
--dry-runand--yesare non-negotiable for mutations. Same code path. No exceptions. See Same-Code-Path Preview.- Stop using new bucket categories. Once you have N buckets in your IPC, every new request must justify why it doesn't fit. Adding a bucket is a decision.
- One registry beats three hardcoded lists. A web app with palette + keymap + help dialog will drift unless all three derive from a single action registry. See Shared Action Registry.
- Funnel all mutations through one optimistic hook. Per-mutation hooks copy-paste snapshot/restore logic and diverge. One funnel, per-action projection functions. See Optimistic Mutation Funnel.
- Embed the SPA in the daemon binary. One artifact ships server and UI; impossible to mismatch versions. See Embedded SPA in Daemon Binary.
- Lock the decisions that get relitigated. A "do not relitigate" section in the doc saves every reviewer the same conversation. See Locked Decisions in Docs.