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
messagestable by triggers.internal/searchparses 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.