fishman/notmutt

Async terminal mail client for notmuch: staged undoable tag ops, diff-and-insert refresh, HTML mail rendered in-terminal, easyjump link mode, dry-run filtering by default, optional MCP server (read-only thread metadata), TOML config, vim keys. Written in Go (tcell TUI).

7

stars

547

commits

Go

primary language

Sep 10, 2026

updated

fishman.github.io/notmutt/
cli
email
email-client
go
golang
mail-client
mutt
neomutt
notmuch
privacy
terminal
tui
vim

README

notmutt

Build and test CodeQL Govulncheck

An async mail client built on notmuch. Tags are the logical model: every view, filter, and trigger is a notmuch query or tag operation; folders exist only for sync-tool compatibility. Written in Go

  • tcell v3 TUI (lipgloss v2 for layout math), go-message for mail parsing and composition, TOML config, vim keybindings by default.
1787024932790608185

Try it now

Requirements: a recent Go toolchain, libnotmuch, and a notmuch-indexed mailbox (mbsync or vdirsyncer into maildirs plus notmuch new).

git clone git@github.com:fishman/notmutt.git
cd notmutt
make
./notmutt

The Makefile drives build and test (make build, make test, make fuzz, make vet); make build carries the Lua runtime and the cgo backend (R8), make build-cli produces the Apache-clean CLI variant. Both ship at release: notmutt (cgo, GPL-3.0) and notmutt-cli (subprocess backend, Apache-2.0) - see docs/licensing.md.

An optional MCP server (make build TAGS="lua mcp", then ./notmutt mcp) exposes read-only thread metadata to LLM clients - subject, author, timestamp, tags, message count, references, never mail content. See docs/usage.md for registration and the metadata-only privacy rule.

Packages

The release workflow builds a deb, an rpm (nfpm), and an Arch package (makepkg) from a vMAJOR.MINOR.PATCH tag. The deb and rpm embed libnotmuch statically and are buildable but untested: the client targets libnotmuch 0.40, which no stock distro ships, so the runtime notmuch dependency cannot be satisfied there. The Arch package is the usable one - it links the distro's current libnotmuch. See docs/installation.md.

If notmuch sees your mail, notmutt reads it. Your tags, views, and queries stay yours and stay queryable by every other notmuch tool. The built-in defaults live in src/config/base.toml (search it first); ~/.config/notmutt/config.toml overlays them. The setup walkthrough is in docs/installation.md; keybindings and configuration in docs/usage.md.

Keybindings: enter opens a thread (marks it read), P previews without marking read, v toggles the plain/html view, alt+i loads remote images, F enters the easyjump link mode, $ applies staged tag ops, u undoes them. The help overlay (?) derives from the binding map, so rebinds update the hints.

Background sync (systemd)

The example units in config/examples/systemd/ run the reference pipeline on a timer: every 30 minutes mail-sync.timer fires mail-sync.service, which starts vdirsyncer (contacts/calendars) in parallel and syncs all mbsync accounts, delivery triggering the notmuch pipeline (post-new tags and moves). A sync marker - files delivered by the run are newer than it - lets the post-new hook untag mail an external client moved into an INBOX.

cp config/examples/systemd/* ~/.config/systemd/user/
systemctl --user enable --now mail-sync.timer

mbsync-all.service launches one mbsync process per Channel line in ~/.mbsyncrc, all in parallel (mbsync locks each mailbox, so concurrent runs are safe) - add a channel there and it syncs, nothing to edit in the unit.

The service's ExecCondition is the environment-specific part: it skips the sync while the network is down (nmcli), while a video is playing (playerctl, pw-dump) or a fullscreen app is up (mmsg), so a sync never stutters playback. Drop the whole ExecCondition line for a plain timer if you do not want those guards.

If notmutt works for you, star the repository. When something breaks, open an issue

  • reproduce with fabricated mail if the bug is message-specific.

What is notmutt

notmutt starts from the neomutt pain its author lived: neomutt's notmuch integration loads threads synchronously, rebuilds the whole thread tree on new mail, and makes every tag application final. notmutt inverts all three: async thread loading, diff-and-insert refresh, and staged undoable tag operations. A 33k-thread inbox walks in ~1.6s, and steady-state keypresses are sub-150us.

Status: M1 (mailbox view: thread tree, index cache, pager, search, default plain/html views) and M2 (staged tag ops, send dialogue with attach commands and preview, async send) are done. On the roadmap: crypto send through your system gpg, algorithmic filters (bayes, DKIM), Lua hooks and UI callbacks, an emacs keymap scheme. GUI and IMAP/POP3 transport are out of scope - notmutt is a terminal client that reads what notmuch sees.

Terminal mail clients

Where notmutt sits among the terminal mail clients: the notmuch-native set (mutt/neomutt via sideband queries, aerc, alot, notmuch-emacs, mu4e), the CLI tools (himalaya), and the older tag-based MUAs (sup). The differentiators are the middle columns: notmutt's reads are async with diff-and-insert refresh, and its tag writes are staged and undoable instead of hitting notmuch at keypress time.

ClientLanguageBackendAsync refreshStaged tag opsBuilt-in send
notmuttGonotmuch (cgo)yes - diff-and-insertyes - staged, undoableyes
muttCmaildir / IMAP / POP3no - full reloadno - immediateyes
neomuttCnotmuch sideband + maildirno - sync load, full rebuildno - immediateyes
aercGoIMAP / maildir / notmuchyes - worker channelsno - immediateyes
notmuch-emacsEmacs Lispnotmuchyes - incremental search refreshno - immediatemessage-mode
mu4eEmacs Lispmu index + maildirpartial - emacs threadsno - immediatemessage-mode
alotPythonnotmuchno - syncno - immediateyes
himalayaRustIMAPyes - asyncnoyes
supRubylocal maildir + own indexnonoyes

"Staged tag ops" means tag changes land in a session buffer and hit the backend only on apply ($), with u to undo - mutt's sync semantics. "Async refresh" means reads and updates never block the UI; notmutt inserts new mail into the visible threads instead of rebuilding the list.

Where is notmutt

Features

NameDescription
Staged tag operationsArchive/delete/flag/read stage into a buffer and hit notmuch only on $ (mutt's sync). A mis-tap is one u away - neomutt makes every tag application final
Diff-and-insert refreshNew mail inserts into visible threads between entries - no full rebuild on new mail
Exclusive folder tag groupsOne message, one home: applying any group member removes the others, inbox included. No hand-maintained -tag chains in your config
Async send and composeThe compose dialogue is a state machine separate from the UI - background sync and filter runs never interrupt typing; sends run as background jobs with output kept for review
Terminal imagesSixel by default, kitty opt-in. Remote images fetch only on alt+i (a privacy gate), and 1x1 tracking pixels drop unless opted in
Config as dataTOML everything: themes with palette indirection, declarative per-context keybindings (the help overlay derives from them), tag styles, glyphs
notmuch is the only truthNo own database - a revision-keyed bbolt cache mirrors query output and re-syncs from notmuch's lastmod
Lua pluginsBuild-tag-gated gopher-lua layer with a lib whitelist sandbox; plugins register body-rendering transforms

Commits and AI assistance

All code in this repository is owned by its human author: no code commit carries any AI marker or co-author line, whether or not an AI drafted it. Doc and spec commits carry a Co-Authored-By: Deepseek line (the model that drafted them). Either way the line is like mail typed on an iPhone - the device produced the words, you answer for them, and blaming the device for a dumb decision is not acceptable. Review responsibility stays with the human.

Design decisions

The full records with measurements live in docs/design-decisions.md; the short version:

  • Go over Rust/Zig (R7): integration surface. go-message is aerc's production mail library - the same worker architecture notmutt mirrors; the cgo binding is vendored and pinned, never fetched from the proxy.
  • tcell v3 over BubbleTea (record 23): the vendored v2 renderer was the wrong trust boundary - an out-of-bounds frame bug was fixed model-side, and verifying the diff engine meant re-implementing what tcell's Screen.Show() does natively. tcell is a screen cell buffer and an event source, nothing more; lazygit pairs it with the same state/UI architecture.
  • cgo binding over the notmuch CLI (record 3): a batched threads walk closed the gap - 1.645s full walk vs the CLI's 1.534s on a 33k-thread inbox, with an 11ms peek. The CLI backend survives behind the -tags cli build tag; the two backends are build-exclusive for license separation - cgo links GPL libnotmuch (released GPL-3.0), the CLI variant links nothing and ships Apache-2.0 (make build-cli). The cgo handle stays read-only, reopening read-write only for a tag op.
  • Render coalescing: state updates land at input rate, paints coalesce at an 8ms cadence, a content-addressed row cache restyles only the rows whose selection flips. Measured on the 33k-thread inbox:
scenariobeforeafter
held-key burst, 50 presses50+ full-frame paints6 (one per 8ms window + settle)
single press, full list~2.5ms frame build133us
pager resize, 20k-line document385ms44-74us
fill-window press, whole-fill batch2.61ms147us (17.7x)
frame rebuild, all rows cached (40 visible @ 5k list)182us uncached24us (7.7x)
keypress on the full 30k list (cursor resolve)~8ms flatten+scan per paint12us (O(1) index read)
  • The index cache is a materialized view (R13), not a second truth: revision-keyed, invalidated by notmuch's lastmod, rebuilt from query output only, never written independently.

Credits

The design derives from reading these projects' source; the references/ tree keeps the checkouts. Concepts were studied, not copied - with one code exception, the hyperlink scanner, ported from aerc with its MIT attribution in the source (src/lib/html/links.go).

The client is the idea of merging mutt and notmuch. Mutt and neomutt are the source of correctness of mail content: what the client takes from them is the mail behavior, the style, the compose dialog - the mutt-family surface.

ProjectWhat notmutt takes
notmuchthe whole model: the client is a front-end, notmuch is the single source of truth (R1)
neomuttthe mail behavior, the style, the compose dialog - the mutt-family UX the client mirrors
afewthe filter engine shape: the per-message filter contract, per-account folder priorities, first-existing-folder-wins moves (R2)
aercthe worker action loop behind an async channel (R3/R4), go-message as the mail library, the per-context keybinding model, the crypto CLI-backend pattern (R10)
matchathe Lua plugin layer: one VM on the orchestrator, a lib-whitelist sandbox, deferred side effects (R8)

Documentation

Requirements and architecture are normative in AGENTS.md; the security model lives in SECURITY.md. User documentation (features, installation, usage, FAQ) is on the project site and in docs/.

Contributors

fishman

547 commits

fishman/notmutt

Async terminal mail client for notmuch: staged undoable tag ops, diff-and-insert refresh, HTML mail rendered in-terminal, easyjump link mode, dry-run filtering by default, optional MCP server (read-only thread metadata), TOML config, vim keys. Written in Go (tcell TUI).

7

stars

547

commits

Go

primary language

Sep 10, 2026

updated

fishman.github.io/notmutt/
cli
email
email-client
go
golang
mail-client
mutt
neomutt
notmuch
privacy
terminal
tui
vim

README

notmutt

Build and test CodeQL Govulncheck

An async mail client built on notmuch. Tags are the logical model: every view, filter, and trigger is a notmuch query or tag operation; folders exist only for sync-tool compatibility. Written in Go

  • tcell v3 TUI (lipgloss v2 for layout math), go-message for mail parsing and composition, TOML config, vim keybindings by default.
1787024932790608185

Try it now

Requirements: a recent Go toolchain, libnotmuch, and a notmuch-indexed mailbox (mbsync or vdirsyncer into maildirs plus notmuch new).

git clone git@github.com:fishman/notmutt.git
cd notmutt
make
./notmutt

The Makefile drives build and test (make build, make test, make fuzz, make vet); make build carries the Lua runtime and the cgo backend (R8), make build-cli produces the Apache-clean CLI variant. Both ship at release: notmutt (cgo, GPL-3.0) and notmutt-cli (subprocess backend, Apache-2.0) - see docs/licensing.md.

An optional MCP server (make build TAGS="lua mcp", then ./notmutt mcp) exposes read-only thread metadata to LLM clients - subject, author, timestamp, tags, message count, references, never mail content. See docs/usage.md for registration and the metadata-only privacy rule.

Packages

The release workflow builds a deb, an rpm (nfpm), and an Arch package (makepkg) from a vMAJOR.MINOR.PATCH tag. The deb and rpm embed libnotmuch statically and are buildable but untested: the client targets libnotmuch 0.40, which no stock distro ships, so the runtime notmuch dependency cannot be satisfied there. The Arch package is the usable one - it links the distro's current libnotmuch. See docs/installation.md.

If notmuch sees your mail, notmutt reads it. Your tags, views, and queries stay yours and stay queryable by every other notmuch tool. The built-in defaults live in src/config/base.toml (search it first); ~/.config/notmutt/config.toml overlays them. The setup walkthrough is in docs/installation.md; keybindings and configuration in docs/usage.md.

Keybindings: enter opens a thread (marks it read), P previews without marking read, v toggles the plain/html view, alt+i loads remote images, F enters the easyjump link mode, $ applies staged tag ops, u undoes them. The help overlay (?) derives from the binding map, so rebinds update the hints.

Background sync (systemd)

The example units in config/examples/systemd/ run the reference pipeline on a timer: every 30 minutes mail-sync.timer fires mail-sync.service, which starts vdirsyncer (contacts/calendars) in parallel and syncs all mbsync accounts, delivery triggering the notmuch pipeline (post-new tags and moves). A sync marker - files delivered by the run are newer than it - lets the post-new hook untag mail an external client moved into an INBOX.

cp config/examples/systemd/* ~/.config/systemd/user/
systemctl --user enable --now mail-sync.timer

mbsync-all.service launches one mbsync process per Channel line in ~/.mbsyncrc, all in parallel (mbsync locks each mailbox, so concurrent runs are safe) - add a channel there and it syncs, nothing to edit in the unit.

The service's ExecCondition is the environment-specific part: it skips the sync while the network is down (nmcli), while a video is playing (playerctl, pw-dump) or a fullscreen app is up (mmsg), so a sync never stutters playback. Drop the whole ExecCondition line for a plain timer if you do not want those guards.

If notmutt works for you, star the repository. When something breaks, open an issue

  • reproduce with fabricated mail if the bug is message-specific.

What is notmutt

notmutt starts from the neomutt pain its author lived: neomutt's notmuch integration loads threads synchronously, rebuilds the whole thread tree on new mail, and makes every tag application final. notmutt inverts all three: async thread loading, diff-and-insert refresh, and staged undoable tag operations. A 33k-thread inbox walks in ~1.6s, and steady-state keypresses are sub-150us.

Status: M1 (mailbox view: thread tree, index cache, pager, search, default plain/html views) and M2 (staged tag ops, send dialogue with attach commands and preview, async send) are done. On the roadmap: crypto send through your system gpg, algorithmic filters (bayes, DKIM), Lua hooks and UI callbacks, an emacs keymap scheme. GUI and IMAP/POP3 transport are out of scope - notmutt is a terminal client that reads what notmuch sees.

Terminal mail clients

Where notmutt sits among the terminal mail clients: the notmuch-native set (mutt/neomutt via sideband queries, aerc, alot, notmuch-emacs, mu4e), the CLI tools (himalaya), and the older tag-based MUAs (sup). The differentiators are the middle columns: notmutt's reads are async with diff-and-insert refresh, and its tag writes are staged and undoable instead of hitting notmuch at keypress time.

ClientLanguageBackendAsync refreshStaged tag opsBuilt-in send
notmuttGonotmuch (cgo)yes - diff-and-insertyes - staged, undoableyes
muttCmaildir / IMAP / POP3no - full reloadno - immediateyes
neomuttCnotmuch sideband + maildirno - sync load, full rebuildno - immediateyes
aercGoIMAP / maildir / notmuchyes - worker channelsno - immediateyes
notmuch-emacsEmacs Lispnotmuchyes - incremental search refreshno - immediatemessage-mode
mu4eEmacs Lispmu index + maildirpartial - emacs threadsno - immediatemessage-mode
alotPythonnotmuchno - syncno - immediateyes
himalayaRustIMAPyes - asyncnoyes
supRubylocal maildir + own indexnonoyes

"Staged tag ops" means tag changes land in a session buffer and hit the backend only on apply ($), with u to undo - mutt's sync semantics. "Async refresh" means reads and updates never block the UI; notmutt inserts new mail into the visible threads instead of rebuilding the list.

Where is notmutt

Features

NameDescription
Staged tag operationsArchive/delete/flag/read stage into a buffer and hit notmuch only on $ (mutt's sync). A mis-tap is one u away - neomutt makes every tag application final
Diff-and-insert refreshNew mail inserts into visible threads between entries - no full rebuild on new mail
Exclusive folder tag groupsOne message, one home: applying any group member removes the others, inbox included. No hand-maintained -tag chains in your config
Async send and composeThe compose dialogue is a state machine separate from the UI - background sync and filter runs never interrupt typing; sends run as background jobs with output kept for review
Terminal imagesSixel by default, kitty opt-in. Remote images fetch only on alt+i (a privacy gate), and 1x1 tracking pixels drop unless opted in
Config as dataTOML everything: themes with palette indirection, declarative per-context keybindings (the help overlay derives from them), tag styles, glyphs
notmuch is the only truthNo own database - a revision-keyed bbolt cache mirrors query output and re-syncs from notmuch's lastmod
Lua pluginsBuild-tag-gated gopher-lua layer with a lib whitelist sandbox; plugins register body-rendering transforms

Commits and AI assistance

All code in this repository is owned by its human author: no code commit carries any AI marker or co-author line, whether or not an AI drafted it. Doc and spec commits carry a Co-Authored-By: Deepseek line (the model that drafted them). Either way the line is like mail typed on an iPhone - the device produced the words, you answer for them, and blaming the device for a dumb decision is not acceptable. Review responsibility stays with the human.

Design decisions

The full records with measurements live in docs/design-decisions.md; the short version:

  • Go over Rust/Zig (R7): integration surface. go-message is aerc's production mail library - the same worker architecture notmutt mirrors; the cgo binding is vendored and pinned, never fetched from the proxy.
  • tcell v3 over BubbleTea (record 23): the vendored v2 renderer was the wrong trust boundary - an out-of-bounds frame bug was fixed model-side, and verifying the diff engine meant re-implementing what tcell's Screen.Show() does natively. tcell is a screen cell buffer and an event source, nothing more; lazygit pairs it with the same state/UI architecture.
  • cgo binding over the notmuch CLI (record 3): a batched threads walk closed the gap - 1.645s full walk vs the CLI's 1.534s on a 33k-thread inbox, with an 11ms peek. The CLI backend survives behind the -tags cli build tag; the two backends are build-exclusive for license separation - cgo links GPL libnotmuch (released GPL-3.0), the CLI variant links nothing and ships Apache-2.0 (make build-cli). The cgo handle stays read-only, reopening read-write only for a tag op.
  • Render coalescing: state updates land at input rate, paints coalesce at an 8ms cadence, a content-addressed row cache restyles only the rows whose selection flips. Measured on the 33k-thread inbox:
scenariobeforeafter
held-key burst, 50 presses50+ full-frame paints6 (one per 8ms window + settle)
single press, full list~2.5ms frame build133us
pager resize, 20k-line document385ms44-74us
fill-window press, whole-fill batch2.61ms147us (17.7x)
frame rebuild, all rows cached (40 visible @ 5k list)182us uncached24us (7.7x)
keypress on the full 30k list (cursor resolve)~8ms flatten+scan per paint12us (O(1) index read)
  • The index cache is a materialized view (R13), not a second truth: revision-keyed, invalidated by notmuch's lastmod, rebuilt from query output only, never written independently.

Credits

The design derives from reading these projects' source; the references/ tree keeps the checkouts. Concepts were studied, not copied - with one code exception, the hyperlink scanner, ported from aerc with its MIT attribution in the source (src/lib/html/links.go).

The client is the idea of merging mutt and notmuch. Mutt and neomutt are the source of correctness of mail content: what the client takes from them is the mail behavior, the style, the compose dialog - the mutt-family surface.

ProjectWhat notmutt takes
notmuchthe whole model: the client is a front-end, notmuch is the single source of truth (R1)
neomuttthe mail behavior, the style, the compose dialog - the mutt-family UX the client mirrors
afewthe filter engine shape: the per-message filter contract, per-account folder priorities, first-existing-folder-wins moves (R2)
aercthe worker action loop behind an async channel (R3/R4), go-message as the mail library, the per-context keybinding model, the crypto CLI-backend pattern (R10)
matchathe Lua plugin layer: one VM on the orchestrator, a lib-whitelist sandbox, deferred side effects (R8)

Documentation

Requirements and architecture are normative in AGENTS.md; the security model lives in SECURITY.md. User documentation (features, installation, usage, FAQ) is on the project site and in docs/.

Contributors

fishman

547 commits

Languages

Go

99.1%