Architecture

One Go binary, one SQLite file, and a server-rendered front end with no JavaScript build step beyond the CSS pipeline.

            browser (htmx + Alpine.js + Tailwind)
                          |  HTML fragments, SSE on /events
                          v
  +------------------------------------------------------------+
  |  cmd/mimux - one process                                   |
  |                                                            |
  |  internal/server     chi router, html/template pages       |
  |                      and htmx fragments, session cookies   |
  |  internal/mail       one worker + IMAP connection per      |
  |                      account: IDLE/poll sync, SMTP send,   |
  |                      threading, sanitising, scheduler      |
  |  internal/store      SQLite: messages, folders, drafts,    |
  |                      filters, settings, tokens, FTS index  |
  |  internal/filter     pure rules engine (no I/O)            |
  |  internal/search     query parser + SQL builder            |
  |  internal/ai         optional, OpenAI-compatible           |
  |  internal/translate  optional, Google Translate            |
  |                                                            |
  |  internal/ext  <- the only door the pro layer comes in by  |
  |  pro/          REST API, MCP server, webhooks     [ELv2]   |
  +------------------------------------------------------------+
          |                                     |
          v                                     v
     IMAP / SMTP                          data/mimux.db
     (your providers)                     data/secret

The web layer

internal/server is a chi router rendering html/template pages and htmx fragments. The browser side stays deliberately dependency-light: htmx for server-driven updates, Alpine.js for small local UI state (menus, forms), Tailwind for styling. There is no bundler and no client-side router — the only build step for the front end is the Tailwind CSS pass, and its output is embedded in the binary along with the templates, icons and service worker.

Live updates arrive over a Server-Sent Events stream at /events: new mail, sync status changes, deep-search results as they land. That stream is why the HTTP server has no global write timeout.

Mail: sync, send, threading

internal/mail owns a Manager with one worker and one IMAP connection per account. Each worker loops: sync, then wait. The wait is IMAP IDLE where the server advertises the capability — new data breaks the idle and triggers a resync — with a ceiling equal to the poll interval, so the Check every N minutes setting still applies under IDLE. Servers without IDLE, and connections that drop, fall back to plain polling. A queued foreground command (attachment fetch, server-side search) also breaks IDLE so it can run on the same connection.

Sending goes out over SMTP from the same package, which also renders the message: plain text, HTML, or Markdown rendered to HTML with a plain-text alternative. Scheduled send and undo-send are an outbox table plus a scheduler goroutine.

Threading is the standard JWZ container algorithm over Message-ID, References and In-Reply-To (References wins where the two disagree). There is deliberately no subject fallback: merging header-less mail that merely shares a subject collapsed unrelated notification mail into one enormous thread, and it is not what Gmail does either. Messages carrying no threading headers stand alone. Gmail's X-GM-THRID is not used — the upstream IMAP library cannot fetch it yet — and in practice JWZ threads Gmail conversations correctly anyway.

Incoming HTML bodies are sanitised before they are ever rendered, and external resources are rewritten so nothing loads until you ask for it.

Storage

internal/store is SQLite and nothing else — no ORM, hand-written SQL, schema managed by numbered migration files applied at boot. One file holds messages and their bodies, folders, drafts, filters, sessions, app settings, push subscriptions, OAuth tokens, API tokens and webhook rows.

It runs in WAL mode, which is what lets a second process open the same database read-only while the server is live — that is how mimux licence status can report on a running install without touching it.

Message bodies are cached with an LRU and a per-account cap, and a warmer prefetches bodies for the newest inbox page so the message you are about to click is already local.

Search

Two searches share one query language.

  • Local. An FTS5 virtual table over subject, snippet and the address lists, kept in step with the messages table by triggers. internal/search parses the query into terms and builds the SQL. This is instant and covers everything that has been synced.
  • Deep. The same parsed query is mapped to IMAP SEARCH criteria and fanned out to every relevant account's server. Results stream back over SSE as each server answers, because one slow provider should not hold up the rest. This is the escape hatch for mail too old or too rarely synced to be in the local index.

The query language: bare words match anywhere; from:, to:, cc:, subject:, body:, in:, label: narrow to a field; is:unread, is:starred, has:attachment; before:/after: take YYYY-MM-DD; larger:/smaller: take a size; quoted strings are exact phrases; a leading - negates. Every term is ANDed — there is no OR.

Filters

internal/filter is a pure rules engine with no I/O, so it is driven identically by the sync loop and by the HTTP layer. A rule is a list of conditions ANDed together — field (from, to, subject, body) × operator (contains, regex) × value — and a list of actions run when they all match: move, label, forward, mark_read, star, delete, notify. Rules are evaluated in position order and every match contributes its actions; a rule with no conditions would match everything, so at least one is required.

The sync loop applies them through one gate, in runRules: a message is filtered only when it is an arrival (the same flag that decides whether a stored message is worth announcing, off for a folder's first full pass) in an inbox. Both halves are load-bearing. Without the first, a fresh install — or a folder newly ticked in Settings → Syncing, or a UIDVALIDITY re-fetch — replays every rule over the whole downloaded window. Without the second, a cycle that walks Sent, Drafts and Archive fires the same rule again on the copy of your own reply, once more on Gmail's All Mail copy, and on the IMAP draft you are still typing.

Actions run on the sync's own connection rather than the worker's command queue, which the (busy) worker goroutine is the only one to drain. The two that cannot: forward, which talks SMTP and appends to Sent, is handed to a goroutine; notify broadcasts a rule-notify event on the same hub the arrival event uses, and the debounced notifier batches it with everything else in the window.

Because the engine is pure, the filters page can run a rule's conditions over the newest stored inbox messages and show what would have matched — filter.DryRun over store.RecentInbox, one read, no side effects.

The free/pro split

pro/ is the separately licensed automation layer. Every file in it carries //go:build pro, including the registration hook, so the default build excludes the package from the build graph entirely — not compiled, not linked, not present.

make build            # free binary — contains zero ELv2 code
make build-pro        # commercial binary — AGPL client + ELv2 pro layer
make verify-free      # proves the free binary links nothing from pro/
make verify-licence   # proves every SPDX header is on the right side
make verify-boundary  # proves pro/ binds via internal/ext, not internal/server

All three verifications run in CI and in make check, so the split is proven rather than claimed.

The boundary

The pro layer binds to the client through internal/ext: one struct, ext.Deps, handing it the mail manager, the store and the config, plus a Register hook called from an init(). It is not allowed to import internal/server, and make verify-boundary enforces that.

That rule is the reason there is no speculative "mail engine" interface. Anything pro/ needs that lives as a private method on *server.Server has to move down into internal/mail or internal/store first, where the HTML handler then calls the same code. Shared operations end up in the domain layer because a real caller needed them there, not because someone guessed in advance what an API would want.

Extensions mount outside the session-cookie auth group. Nothing in pro/ ever reads a browser session: API callers authenticate with a personal access token. See The automation layer.

Licence enforcement, and what it does not touch

Verification is offline and signature-only: an ed25519 public key is linked into the binary, a signed key is presented, the signature verifies or it does not. There is no phone-home, no activation call and no machine fingerprint — the licence-selling service at account.mimux.dev is published in the same repository so you can check that for yourself.

The gate wraps exactly two route groups: /api/v1/* and /api/mcp. /api/health and /api/v1/openapi.json stay open unconditionally — a probe and the documentation are not the product — and nothing in the mail client is behind it at all. Sync, send, search and the whole web interface are AGPL code that has no idea the licence file exists.