- Go 98.8%
- Python 1%
- Makefile 0.2%
| assets | ||
| internal | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| Jenkinsfile | ||
| LICENSE | ||
| main.go | ||
| Makefile | ||
| README.md | ||
NeoMutt, tamed.
A friendly TUI that generates and maintains your entire NeoMutt + mbsync + msmtp mail setup — accounts, contacts, encryption, themes — then drops you straight into your inbox.
Quick start · Features · Contacts · Shortcuts · Developers
Terminal mail with NeoMutt is wonderful once it works — but the setup is spread across half a dozen fiddly files with cryptic option names. Weremutt owns the boring parts. It generates and maintains a clean, correct configuration so you spend your time reading mail, not debugging config.
┌ weremutt ──────────────────────────────────────────┐
│ [logo] │
│ │
│ › Open Mail Unlock, sync, open NeoMutt │
│ Open Contacts Browse your address book │
│ Mail Accounts Add, edit, delete accounts │
│ Contact Accounts Address-book sources │
│ Settings General NeoMutt behaviour │
│ Themes Colour scheme │
│ Keybindings Customise key bindings │
│ Encryption Encrypt local mail at rest │
│ Sync Contacts Sync CardDAV address books │
│ Sync All Mail Run mbsync for everything │
│ Quit │
│ │
│ theme: Catppuccin Mocha · ↑↓ move · ⏎ select · q │
└─────────────────────────────────────────────────────┘
✨ Features
| 📬 Multi-account | IMAP, Microsoft 365 (OAuth2) and Google Workspace (OAuth2) — up to 12 accounts, switched with F1–F12. |
| 👥 Contacts that autocomplete | Address-book sources — CardDAV (synced with vdirsyncer) or a local vCard directory — feed NeoMutt's recipient completion via khard. Global by default, or scoped to specific accounts. |
| 🔐 Mail encrypted at rest | Optional gocryptfs store: ciphertext on disk, plaintext mounted only while you read. |
| 🔑 Secrets done right | IMAP/CardDAV passwords and OAuth tokens are all GPG-encrypted. Nothing sensitive is ever written in plain text. |
| 🎨 20 live-preview themes | Catppuccin (×4), Dracula, Nord, Gruvbox, Tokyo Night, One Dark, Solarized, Monokai, Rosé Pine, Everforest, Kanagawa, Ayu Dark. Needs a truecolor terminal. |
| 🧩 Plain-English settings | Arcane NeoMutt options presented with human-readable names, one-line descriptions and sane defaults. |
| 🌐 HTML mail that renders | Generated mailcap + auto_view text/html so HTML displays as readable text inline; open images/PDFs with v. |
| 🏢 Works around locked-down tenants | Optional per-account Microsoft Graph send mode for M365 tenants that disable SMTP AUTH. OAuth is native Go — no Python. |
| 🧹 Clean terminal | Mount messages, sync progress and background-job notices are suppressed, so quitting leaves a tidy prompt. |
🚀 Quick start
# 1. Install the tools (macOS shown; see Setup for apt/pacman)
brew install neomutt isync msmtp gnupg w3m
# 2. Build & install Weremutt
git clone https://forge.quinlan.cloud/joshquinlan/weremutt.git
cd weremutt && make install # -> ~/.local/bin/weremutt
# 3. Run it
weremutt
Then: Mail Accounts › add your account (IMAP passwords are GPG-encrypted on save; for MS365/GSuite press o to do the OAuth2 browser sign-in) → optionally Encryption › Set Up Encryption → Open Mail. Weremutt unlocks, syncs, opens NeoMutt, and re-locks on exit.
In the account form, typing a plain-IMAP email autodetects the IMAP/SMTP servers from Mozilla's provider database (so common providers "just work"), the Test button probes the IMAP server before you save, and save is blocked if no GPG key exists for the "encrypt to" recipient — so you never seal secrets to a key you can't decrypt with.
That's it. Everything below is reference.
Setup
Requirements
- NeoMutt, isync (
mbsync), msmtp, GnuPG - A terminal browser (
w3m,lynxorelinks) to render HTML mail inline - khard + vdirsyncer (optional) for Contacts — recipient auto-completion from an address book
- gocryptfs (optional) for mail-at-rest encryption; on macOS also macFUSE
- Go 1.22+ to build
# macOS
brew install neomutt isync msmtp gnupg w3m khard vdirsyncer
brew install --cask macfuse
brew install gromgit/fuse/gocryptfs-mac
# approve macFUSE in System Settings › Privacy & Security and reboot
# Debian / Ubuntu
sudo apt install neomutt isync msmtp gnupg gocryptfs w3m khard vdirsyncer
# Arch
sudo pacman -S neomutt isync msmtp gnupg gocryptfs w3m khard vdirsyncer
khardandvdirsyncerare Python tools. They're the one place Weremutt leans on a Python runtime — invoked as external binaries (likembsync/msmtp), never embedded. They're only needed if you use Contacts. OAuth stays native Go.
Install
git clone https://forge.quinlan.cloud/joshquinlan/weremutt.git
cd weremutt
make install # builds and installs to ~/.local/bin/weremutt
Then follow the Quick start above.
Shortcuts
These are the key bindings you'll use in NeoMutt. All bindings are editable from the Keybindings screen in Weremutt (macros are shown there for reference).
Navigation
| Key | Action |
|---|---|
j / k |
Next / previous message |
= / * |
First / last message |
Reading
| Key | Action |
|---|---|
Enter |
Open message (index) / scroll down a line (pager) |
Backspace |
Scroll up a line (pager) |
Space |
Next page (pager) |
- |
Previous page (pager) |
h |
Show all headers — toggle (pager) |
q |
Back / close (pager) |
Mail actions
| Key | Action |
|---|---|
n |
Compose new message |
r |
Reply |
g |
Reply all |
f |
Forward |
s |
Move folder |
a |
Archive |
d |
Delete |
R |
Continue postponed draft |
b |
Bounce (resend) message |
Sending
When you've finished writing in the editor, the compose menu lets you review recipients and send. Shortcuts:
| Key | Action |
|---|---|
y |
Send message |
s |
Edit subject |
t / c / b |
Edit To / Cc / Bcc |
Tab |
Autocomplete the recipient from your contacts (in a To/Cc/Bcc field) |
p / S |
Security — PGP / S/MIME (sign, encrypt) |
a |
Attach file |
P |
Postpone draft |
i |
Check spelling |
q |
Abort send |
Search
| Key | Action |
|---|---|
/ |
Search |
N |
Next search match |
Folders & accounts
| Key | Action |
|---|---|
Ctrl-n / Ctrl-p |
Next / previous folder in sidebar |
Ctrl-o |
Open selected folder |
B |
Show / hide sidebar |
F1 – F12 |
Switch account |
Sync & read state
| Key | Action |
|---|---|
,r |
Mark current folder read |
,,r |
Mark whole account read |
,s |
Sync current account |
,S |
Sync all accounts |
Contacts
Weremutt can feed NeoMutt's recipient auto-completion from an address book. Press Tab in a To/Cc/Bcc field while composing and NeoMutt offers matching contacts. Two external tools do the work, invoked like mbsync/msmtp:
- vdirsyncer syncs each CardDAV collection down into a local directory of
.vcffiles. - khard reads those vCard directories and emits NeoMutt-format completion lines.
Both are Python tools — the one Python dependency in the stack. They're external system packages, not embedded; install them only if you want Contacts. (OAuth remains native Go.)
Manage everything from the Contact Accounts main-menu entry: add/edit/delete sources, choose the type, and set the optional per-account assignment. The screen also shows whether khard/vdirsyncer are installed. To browse your whole address book at any time, use Open Contacts from the main menu (it mounts the encrypted store if needed, then lists every contact with khard).
Source types
- CardDAV (default). A generic CardDAV base URL + username + app-password. The password is GPG-encrypted on save (
secrets/<slug>.carddav.gpg), exactly like an IMAP password. Weremutt uses vdirsyncer's collection discovery, so you only supply the base URL. Google works with an app password. - Local vCard. A directory of
.vcffiles with no sync — drop vCards into the source's contacts directory and they're queried directly.
Microsoft 365 has no CardDAV endpoint and is not supported as a contact source. (Mail still works over IMAP/Graph; only CardDAV contact sync is unavailable for M365.)
Scoping (global by default, optionally scoped per account)
The model is additive, keyed on each source's assignment — not on the active account:
- A source with no account assignment is global: available to every account.
- Assigning a source to one or more accounts makes it visible only to those accounts (it stops being global).
- While account X is active, completion searches the union of (all global sources) ∪ (sources assigned to X).
To keep personal contacts out of a work account, assign the personal source(s) to your personal account(s) — they stop being global, so the work account no longer sees them. A source meant to be everywhere is simply left unassigned. Under the hood, Weremutt writes a base query_command (covering the global books) into neomuttrc and a per-account query_command override (covering global + that account's assigned books) into each account's muttrc, re-sourced by the folder-hook on switch.
Syncing
Contact sync runs only via a dedicated action — never coupled to mbsync / Open mail:
- Sync Contacts in the main menu, or
weremutt sync-contactson the command line, so it can be dropped into cron.
When mail-at-rest encryption is on, the local vCard store lives inside the gocryptfs store, so sync-contacts mounts the store, runs vdirsyncer, then unmounts — self-contained. A consequence: with encryption on, khard completion only works while mail is unlocked/mounted (the books are encrypted at rest alongside the caches).
Adding contacts. Add Contact in the main menu opens khard's new-contact editor (unlocking the store first if needed). vdirsyncer's sync is two-way, so a contact you create locally is pushed to the CardDAV server on the next Sync Contacts.
MS365 contacts. Microsoft 365 exposes contacts via Graph, not CardDAV, so they aren't synced today. Pulling them would need a Graph token with a Contacts scope — the current Graph tokens are scoped to
Mail.Sendonly, so this needs a re-authorization flow and is intentionally left for a future release rather than silently broadening token scopes.
Cron note. For non-interactive runs the gocryptfs passphrase must be supplied via WEREMUTT_GCFS_PW, and gpg-agent must already hold the key that decrypts the CardDAV password (Weremutt primes the agent during interactive use, but cron has no such session):
# every 30 min — assumes gpg-agent is primed and (if encrypted) WEREMUTT_GCFS_PW is exported
*/30 * * * * weremutt sync-contacts >/dev/null 2>&1
Security note —
WEREMUTT_GCFS_PW. While a process that reads this variable is running, the passphrase is visible to other processes owned by your user via/proc/<pid>/environ(Linux) orps -E(some systems). This is an accepted trade-off for unattended cron sync. Treat it accordingly: keep it out of shell history, prefer setting it inside the crontab entry (not a world-readable file), and lock down your crontab perms (chmod 600). For interactive use you never need it — Weremutt prompts for the passphrase in-app.
Command line
Weremutt is an interactive TUI — run it with no arguments. It also exposes a few subcommands (run weremutt --help for the full list with environment overrides):
| Command | What it does |
|---|---|
weremutt |
Start the interactive TUI |
weremutt doctor |
Preflight checks — required tools, a usable GPG key, FUSE, and the deps for any features you've enabled — printed as a green/red checklist with the exact install command per platform. Also available as the Diagnostics menu item. |
weremutt sync-contacts |
Sync CardDAV address books with vdirsyncer — self-contained and cron-friendly (mounts the encrypted store if needed, then unmounts) |
weremutt reindex |
Update the notmuch index now (only when notmuch search is enabled in Settings) |
weremutt export [file] |
Bundle the config — accounts, settings, themes, and the already-GPG-sealed secrets — into a tarball, so a new machine is a restore, not a re-setup |
weremutt import <file> |
Restore a bundle made by weremutt export (needs your GPG private key to use the sealed secrets) |
weremutt --version, -v |
Print version and exit |
weremutt --help, -h |
Show full help |
The remaining subcommands (oauth-token, oauth-authorize, graph-authorize, graph-send, refresh-contacts, resync-loop) are invoked automatically by mbsync / msmtp / NeoMutt and aren't meant to be run by hand.
Sync visibility. Background sync is quiet by design, but no longer silent about failure. Every bulk sync ("Open Mail") and every background resync records its outcome to $XDG_CACHE_HOME/weremutt/weremutt.log and a small sync.state file, and the main menu shows "last synced 14m ago" or, in red, "last sync failed — ". So a mailbox that has quietly stopped updating is now visible at a glance rather than a surprise.
Troubleshooting
Start with weremutt doctor. It checks the tools, GPG key, FUSE and feature dependencies for your setup and prints the exact fix command for your platform. Most of the problems below are caught there first.
| Symptom | Likely cause & fix |
|---|---|
| "Open Mail" fails to mount / hangs at unlock | macFUSE isn't installed or wasn't approved. Install it (brew install --cask macfuse), then approve it in System Settings ▸ Privacy & Security and reboot. doctor reports this as a failed FUSE check. |
OAuth sync fails on macOS (SASL ... no mechanism) |
The XOAUTH2 SASL plugin isn't built. Run the OAuth Plugin menu item (it appears once you have an MS365/Google account) to compile cyrus-sasl-xoauth2. |
| Sync silently stopped | Check the menu's "last synced" line and $XDG_CACHE_HOME/weremutt/weremutt.log. A last sync failed line carries the tail of mbsync's error. |
gpg: decryption failed / pinentry never appears |
gpg-agent doesn't have your key cached. Run Open Mail once (it primes the agent); for cron, the agent must already be primed and WEREMUTT_GCFS_PW exported. |
| HTML mail shows as "unsupported" | No terminal browser. Install w3m (or lynx/elinks). |
| notmuch search does nothing | It's opt-in (Settings ▸ notmuch search) and needs a notmuch-enabled NeoMutt build plus the notmuch binary (doctor checks both once enabled). Run weremutt reindex to (re)build the index. |
| Colours look wrong / "Direct colours support disabled" | Weremutt's themes need a truecolor terminal (e.g. Ghostty, Kitty, modern iTerm). |
| A folder you don't want keeps syncing | On the account list press f (Folders) and untick it — only ticked folders sync (INBOX is always kept). |
Backing up / moving machines: weremutt export bundles your whole config (secrets stay GPG-sealed); weremutt import restores it. Bring your GPG private key across separately — without it the sealed secrets can't be decrypted.
Custom themes: in Themes, press n to clone the highlighted theme and tweak its palette by hex value with live preview; saved themes join the list and are written as a NeoMutt colour file like the built-ins.
For developers
Building and testing
make build # builds ./weremutt
make test # runs go test ./...
make install # installs to ~/.local/bin/weremutt
OAuth2 (MS365 & Google Workspace)
Add the account, then press o on it in the Mail Accounts list to run the browser sign-in. OAuth token minting and refresh are handled natively by Weremutt itself — there is no external Python helper or other runtime dependency. Existing token files written by older (Python-based) versions keep working unchanged.
- MS365 uses Thunderbird's public client ID by default (no app registration needed). If your organisation blocks third-party IDs, register your own app in the Entra portal (Mobile & desktop redirect
http://localhost) and setWEREMUTT_MS_CLIENT_ID. - Google Workspace requires a project in the Google Cloud Console with the Gmail API enabled, an OAuth consent screen, and a Desktop app client ID. Weremutt prompts for your client ID/secret on first authorize.
Microsoft Graph send mode
Some Microsoft 365 tenants run Security Defaults, which disables SMTP AUTH for every mailbox. Sending then fails with 535 5.7.139 ... SmtpClientAuthentication is disabled for the Tenant, because msmtp can no longer authenticate over SMTP — even with a valid OAuth token.
Graph mode works around this by sending through the Microsoft Graph sendMail API (Mail.Send), which those tenants still permit. It is off by default and configured per account (different accounts can use different tenants). Receiving is unaffected — mail still syncs over IMAP XOAUTH2 as usual; only the send path changes.
When enabled, NeoMutt's sendmail is pointed at Weremutt's own graph-send translation layer instead of msmtp. It takes the composed message and POSTs it to Graph as raw MIME, so HTML, attachments and signatures are delivered exactly as composed. Because Graph's MIME send always stores a server-side copy in Sent Items, Weremutt unsets NeoMutt's local record for these accounts (mbsync pulls the single server copy back down — no duplicates).
Enable it in Mail Accounts › edit on an MS365 account: turn graph send mode on and pick an auth flow.
- App-only (client credentials). Register an app in the Entra portal, grant it the
Mail.Sendapplication permission, and grant admin consent. Enter the tenant ID, client ID and client secret in the form (the secret is stored GPG-encrypted, like other Weremutt secrets). Sends as/users/<email>/sendMail; no interactive sign-in. Pressoon the account to run a live credential check. - Delegated (user sign-in). Register an app with the
Mail.Senddelegated permission and a Mobile & desktop redirect ofhttp://localhost. Enter the tenant ID and client ID, then pressoon the account to authorize — this signs in and mints a Graph-scoped token (separate from the IMAP/SMTP token, which has a different audience, sooruns both flows). Sends as/me/sendMail.
macOS only: Homebrew's mbsync is linked against Apple's system libsasl2, which breaks XOAUTH2 with a callback-18948 error. When you have an OAuth account, an OAuth plugin item appears in the main menu. Run it once to build and install the cyrus-sasl-xoauth2 plugin into ~/.config/weremutt/sasl2/. Requires Homebrew and Xcode command-line tools.
Encryption
Ciphertext lives in $XDG_DATA_HOME/weremutt/mail.enc; the plaintext view is FUSE-mounted at $XDG_DATA_HOME/weremutt/mail only while mail is open. Set Up Encryption initialises the store and moves any existing plaintext mail aside to mail.plain.bak.*. Open Mail prompts for your passphrase inside the app (never echoed, never written to disk), syncs, opens NeoMutt, and unmounts on exit. When encryption is on, the local vCard store (and the header/body caches) live inside the mountpoint too, so they're encrypted at rest and only readable while mail is unlocked.
Changing an account's PGP key
An account's stored IMAP password (secrets/<slug>.gpg), OAuth token
(oauth2/<slug>.token) and any Graph credentials (oauth2/<slug>.graph-token,
secrets/<slug>.graph-secret.gpg) are GPG-encrypted to the account's encrypt to
recipient (the signing key field, or the account email when left blank). If you
later rotate that PGP key — generate a new one, or point the account at a
different key — those files are still sealed to the old key. Decryption then
fails, and sending/syncing reports the misleading Difficulty decrypting token file / gpg: ... No secret key.
To rotate the key cleanly:
-
Trust the new key as your own. GPG refuses to encrypt to an untrusted key (
gpg: <id>: There is no assurance/Unusable public key). Since it's your key, mark it ultimately trusted once:gpg --edit-key <new-key-id> # then: trust → 5 (ultimate) → quit # or non-interactively: echo "<FULL_FINGERPRINT>:6:" | gpg --import-ownertrust -
Edit the account in Weremutt (Mail Accounts →
e) and set the new signing key / "encrypt to" recipient, then Save. On save, Weremutt re-seals the stored credential to the new key:- If the old key is still in your keyring, the secret is decrypted and re-encrypted to the new key automatically — nothing else to do.
- If the old key is gone, the credential can't be recovered. An OAuth
token is moved aside (
<slug>.token.stale-*) so you can pressoto re-authorize; an IMAP password must be re-entered (edit the account and type it in the password field).
If you see "Open mail to prime gpg-agent, then re-save", your old key's passphrase wasn't cached — open mail once (or unlock) to prime
gpg-agent, then re-save the account.
Files managed
The layout is XDG-compliant. Config lives under $XDG_CONFIG_HOME/weremutt (default ~/.config/weremutt):
| File | Ownership |
|---|---|
accounts/<slug>.muttrc, <slug>.sig |
per-account identity, folders, signature |
mbsyncrc, msmtprc |
fully generated from your accounts |
neomuttrc |
only the # >>> weremutt managed >>> blocks (hooks, settings, theme) |
macros.muttrc |
account-switch and action macros |
bindings.muttrc |
the managed keybindings block |
mailcap |
HTML-to-text viewer and image/PDF attachment openers |
themes/<name>.muttrc |
the selected colour scheme |
contacts/sources/<slug>.source |
contact-source definitions |
contacts/khard/*.conf, contacts/vdirsyncer/config |
generated khard + vdirsyncer config |
sasl2/xoauth2.so |
macOS XOAUTH2 SASL plugin (built via the OAuth plugin menu item) |
secrets/<slug>.gpg, oauth2/<slug>.token |
encrypted credentials (git-ignored) |
secrets/<slug>.carddav.gpg |
encrypted CardDAV password (git-ignored) |
oauth2/<slug>.graph-token, secrets/<slug>.graph-secret.gpg |
encrypted Graph send-mode credentials (git-ignored) |
App-managed data lives under $XDG_DATA_HOME/weremutt (default ~/.local/share/weremutt):
| Path | Contents |
|---|---|
mail/ |
the plaintext Maildir view / gocryptfs mountpoint (was ~/Mail) |
mail.enc/ |
gocryptfs ciphertext at rest (was ~/.mail.enc) |
mail/.cache/ |
header + body caches (inside the encrypted store) |
contacts/ |
local vCard store, when encryption is off (when on, it lives in mail/.contacts/) |
Anything outside the managed marker blocks is left untouched, so your own neomuttrc tweaks survive. Moving from an older (~/.config/neomutt + ~/Mail + ~/.mail.enc) install? Run the maintainer migration script, then start weremutt once so the managed files regenerate at the new locations.
Environment overrides
| Variable | Purpose | Default |
|---|---|---|
WEREMUTT_ROOT |
config root | $XDG_CONFIG_HOME/weremutt (~/.config/weremutt) |
WEREMUTT_DATA |
app-managed data root | $XDG_DATA_HOME/weremutt (~/.local/share/weremutt) |
WEREMUTT_MAIL |
Maildir root / mountpoint | <data root>/mail |
WEREMUTT_CIPHER |
gocryptfs ciphertext store | <data root>/mail.enc |
WEREMUTT_CACHE |
header + body cache root | <mail root>/.cache |
WEREMUTT_CONTACTS |
plaintext vCard store (encryption off) | <data root>/contacts |
WEREMUTT_GCFS_PW |
gocryptfs passphrase for non-interactive sync-contacts (cron) |
unset |
WEREMUTT_DEBUG |
show mount/sync/teardown output | unset (quiet) |
WEREMUTT_MS_CLIENT_ID |
Microsoft OAuth2 client ID | Thunderbird's public ID |
WEREMUTT_NO_IMAGE |
disable the Kitty inline logo | unset |
WEREMUTT_ROOT / WEREMUTT_DATA / WEREMUTT_MAIL / WEREMUTT_CIPHER take precedence over the XDG resolution.
Security notes
- IMAP passwords are stored only as
gpg-encrypted files; decryption happens in memory viagpg-agentat sync time. - OAuth tokens are GPG-encrypted.
- CardDAV contact-source passwords are GPG-encrypted (
secrets/<slug>.carddav.gpg); vdirsyncer decrypts them on the fly at sync time. - Graph send-mode credentials (delegated token and app-only client secret) are GPG-encrypted too.
msmtprcis written0600.secrets/andoauth2/are git-ignored.
Built with
Bubble Tea, Bubbles and Lip Gloss. OAuth2 (IMAP/SMTP XOAUTH2 and Microsoft Graph) is handled natively in Go — no external helper. Theme palettes are from their respective upstream projects.
License
MIT. See LICENSE.