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):

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)

Files agents/contributors should read first

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

Connections

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):

The atomic notes inside each cluster cite mxr's specific implementation files as concrete grounding.

Lessons learned (worth carrying to lazydap and beyond)

  1. The crate dependency graph is the architecture. Not the diagram, not the docs — the Cargo.toml files. Once enforced there, violations stop at build time and discussions get short.
  2. 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.
  3. 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.
  4. Auto-spawning daemons remove a class of UX bugs. "Daemon not running" is never a user-facing error if the CLI handles it.
  5. $EDITOR for compose is more powerful than custom editors. Markdown with YAML frontmatter is the format; the editor is whatever the user already loves.
  6. Bundle credentials for OAuth. Users shouldn't need to set up Google Cloud to read their email. Ship sensible defaults; let power users override.
  7. --dry-run and --yes are non-negotiable for mutations. Same code path. No exceptions. See Same-Code-Path Preview.
  8. 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.
  9. 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.
  10. 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.
  11. Embed the SPA in the daemon binary. One artifact ships server and UI; impossible to mismatch versions. See Embedded SPA in Daemon Binary.
  12. 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.