07/20/2026

Email in the Terminal: Neomutt on macOS

I wanted to change my email workflow, so I went down the rabbit hole of terminal email clients.

The first one I tried is NeoMutt.
There are two others on my list, Himalaya and the classic aerc, but this post is about Neomutt.
Because while setting it up I realized it isn’t nearly as easy as I expected, at least not for how I want to use it.

So this post is basically a reminder to future me: what I did, and why. Maybe it helps when I port this to my Linux setup someday.

What do I even want from a terminal email client?

  • It needs to be configurable.
  • I need to be able to back up the config.
  • And most importantly: no passwords or email addresses in the config, so nothing gets exposed when it lands in git somewhere.

That last point is what turned “install a mail client” into a whole evening.

Passwords out of the config

I don’t want my password sitting in plaintext inside neomuttrc. So instead of this:

imap_pass="my-password"

Neomutt can just ask another program for the password when it needs it. For that I use pass, the standard Unix password manager. The flow looks like this:

Neomutt
   |
   | asks for password
   v
pass
   |
   | decrypts password
   v
GnuPG

pass stores everything encrypted with GPG in ~/.password-store, so the secrets never exist unencrypted on disk.

First I needed a GPG key pair, public key, private key, the usual. Then initialize the store:

brew install neomutt pass gnupg
pass init <GPG-ID>

<GPG-ID> being the ID or email of that GPG key.

One thing I did learn from proper tutorials: use an app password, not the real account password. Limits the damage if anything leaks, and can be revoked independently. So:

pass insert mail/gmail

A quick sanity check that decryption works, without printing the secret into my terminal history:

pass show mail/gmail >/dev/null && echo "Gmail secret OK"

Telling Neomutt about pass

NeoMutt has an account_command option, a script it calls whenever it needs credentials. That’s the cleanest way I found to wire in pass, documented here.

I created the config directory and opened a new file in nvim:

mkdir -p ~/.config/neomutt
nvim ~/.config/neomutt/account-command

The script gets called with arguments like --hostname and --username, parses them, and prints the right credentials per server:

#!/bin/sh

set -eu

HOST=""

while [ "$#" -gt 0 ]; do
    case "$1" in
        --hostname)
            HOST="$2"
            shift 2
            ;;
        --username|--type)
            shift 2
            ;;
        *)
            shift
            ;;
    esac
done

case "$HOST" in
    imap.gmail.com|smtp.gmail.com)
        printf 'username: %s\n' '[email protected]'
        printf 'password: %s\n' "$(pass show mail/gmail | head -n 1)"
        ;;

    imap.mail.me.com|smtp.mail.me.com)
        printf 'username: %s\n' '[email protected]'
        printf 'password: %s\n' "$(pass show mail/icloud | head -n 1)"
        ;;

    *)
        printf 'Unknown mail server: %s\n' "$HOST" >&2
        exit 1
        ;;
esac

(With my actual addresses instead of the placeholders.)

And it has to be executable, otherwise Neomutt can’t run it:

chmod +x ~/.config/neomutt/account-command

An important detail here: this doesn’t magically eliminate passwords.
GPG still needs the private key to decrypt the store, and that key is protected by a passphrase.
All I really did was move the sensitive part out of the Neomutt config and put it behind GPG.

Which is still worth it, because now the config itself can be backed up and put into my dotfiles repo without exposing anything.

The base config

This config is AI generated

# ─────────────────────────────────────────────────────────────
# Identity
# ─────────────────────────────────────────────────────────────

set realname = "Karim Ould Mahieddine"

alternates \
    "^YOUR_EMAIL@gmail\.com$" \
    "^YOUR_EMAIL@icloud\.com$"


# ─────────────────────────────────────────────────────────────
# Editor
# ─────────────────────────────────────────────────────────────

set editor = "nvim"


# ─────────────────────────────────────────────────────────────
# Sidebar
# ─────────────────────────────────────────────────────────────

set sidebar_visible = yes
set sidebar_width = 24
set sidebar_sort = unsorted

bind index,pager \Cj sidebar-next
bind index,pager \Ck sidebar-prev
bind index,pager \Co sidebar-open


# ─────────────────────────────────────────────────────────────
# Index
# ─────────────────────────────────────────────────────────────

set sort = threads
set sort_aux = reverse-last-date-received

set date_format = "%d.%m.%Y"

set index_format = "%2C %Z %?X?A& ? %-25.25L %[%d.%m.%Y %H:%M] %-40.40s %> %5c"


# ─────────────────────────────────────────────────────────────
# Cache
# ─────────────────────────────────────────────────────────────
#
# Header caching makes reopening large IMAP folders much faster.
#

set header_cache = "~/.cache/neomutt/headers"
set message_cachedir = "~/.cache/neomutt/bodies"


# ─────────────────────────────────────────────────────────────
# Pager / reading
# ─────────────────────────────────────────────────────────────

set pager_context = 3
set pager_index_lines = 8
set pager_stop = yes

set smart_wrap = yes
set markers = no

set quote_regex = "^([ \t]*[|>:}#])+"

unset mark_old


# ─────────────────────────────────────────────────────────────
# Reply / compose
# ─────────────────────────────────────────────────────────────

set fast_reply = yes
set include = yes
set reply_to = yes
set edit_headers = yes


# ─────────────────────────────────────────────────────────────
# Behaviour
# ─────────────────────────────────────────────────────────────

set quit = ask-yes
set delete = ask-yes
set wait_key = no
set sleep_time = 0


# ─────────────────────────────────────────────────────────────
# IMAP performance
# ─────────────────────────────────────────────────────────────
#
# IMPORTANT:
#
# Don't automatically register every subscribed folder for
# polling. This is what can make large Gmail accounts slow.
#

unset imap_check_subscribed

#
# Show ALL folders when using the IMAP folder browser.
#

unset imap_list_subscribed

#
# Don't calculate counts for every remote mailbox.
#

unset mail_check_stats

#
# Check monitored mailboxes every 90 seconds instead of the
# default 5 seconds.
#

set mail_check = 90

#
# NeoMutt docs recommend ~15 seconds for interactive timeout
# with remote IMAP.
#

set timeout = 15

#
# Current NeoMutt option name contains underscores.
#

set imap_keep_alive = 300

#
# Manual refresh of current IMAP mailbox.
#

bind index G imap-fetch-mail


# ─────────────────────────────────────────────────────────────
# TLS
# ─────────────────────────────────────────────────────────────

set ssl_force_tls = yes


# ─────────────────────────────────────────────────────────────
# Credentials
# ─────────────────────────────────────────────────────────────
#
# account-command retrieves Gmail/iCloud credentials from pass.
#

set account_command = "~/.config/neomutt/account-command"


# ─────────────────────────────────────────────────────────────
# Gmail (default account)
# ─────────────────────────────────────────────────────────────

set folder = "imaps://imap.gmail.com/"
set spoolfile = "+INBOX"

set from = "[email protected]"

set smtp_url = "smtps://smtp.gmail.com:465/"

set postponed = "+[Gmail]/Drafts"
set record = "+[Gmail]/Sent Mail"
set trash = "+[Gmail]/Trash"


# ─────────────────────────────────────────────────────────────
# Gmail account switching
# ─────────────────────────────────────────────────────────────

folder-hook "imaps://imap.gmail.com/" \
    "set folder='imaps://imap.gmail.com/'; \
     set spoolfile='+INBOX'; \
     set from='[email protected]'; \
     set smtp_url='smtps://smtp.gmail.com:465/'; \
     set postponed='+[Gmail]/Drafts'; \
     set record='+[Gmail]/Sent Mail'; \
     set trash='+[Gmail]/Trash'"


# ─────────────────────────────────────────────────────────────
# iCloud account switching
# ─────────────────────────────────────────────────────────────

folder-hook "imaps://imap.mail.me.com/" \
    "set folder='imaps://imap.mail.me.com/'; \
     set spoolfile='+INBOX'; \
     set from='[email protected]'; \
     set smtp_url='smtp://smtp.mail.me.com:587/'; \
     set postponed='+Drafts'; \
     set record='+Sent'; \
     set trash='+Trash'"


# ─────────────────────────────────────────────────────────────
# Mailboxes
# ─────────────────────────────────────────────────────────────
#
# Only monitor the two inboxes.
#
# Other folders are still available through the IMAP browser.
#

named-mailboxes \
    "Gmail"  "imaps://imap.gmail.com/INBOX" \
    "iCloud" "imaps://imap.mail.me.com/INBOX"


# ─────────────────────────────────────────────────────────────
# Colours
# ─────────────────────────────────────────────────────────────

source ~/.config/neomutt/colors.dracula

This is just enough to make it work. The interesting part comes now.

Addresses are secrets too

Passwords were safe, but then I looked at the config again: my email addresses are all over it.
alternates, set from, account-command.

And my dotfiles are public on GitHub.

My solution: a local file that Neomutt sources but git ignores.

First a template that is committed, ~/.config/neomutt/accounts.local.example:

# Gmail
set my_gmail = "[email protected]"

# iCloud
set my_icloud = "[email protected]"

Then copy it and put the real addresses in:

cp ~/.config/neomutt/accounts.local.example ~/.config/neomutt/accounts.local
$EDITOR ~/.config/neomutt/accounts.local

And one line in .gitignore so it never gets picked up:

neomutt/accounts.local

In neomuttrc I source the file and only ever use the variables:

source ~/.config/neomutt/accounts.local

alternates "^$my_gmail$" "^$my_icloud$"

# ...

set from = "$my_icloud"

folder-hook "imaps://imap.gmail.com/" \
    "... \
     set from='$my_gmail'; ..."

Neomutt expands these my_* variables while parsing, so the tracked files never contain a real address.

account-command reads the same file.
One detail worth remembering: do NOT simply source it inside the shell script.
The set ... = ... lines overwrite the script’s positional parameters ($1, $2, …) and the --hostname argument silently disappears.
Parse it instead:

CONFIG="$HOME/.config/neomutt/accounts.local"

get() {
    sed -n "s/^set:space:*$1:space:*=:space:*\"\(.*\)\"/\1/p" "$CONFIG"
}

printf 'username: %s\n' "$(get my_gmail)"

Two consumers, one private file, nothing personal in git.

Splitting things up

Once the config grows, one big file gets messy. My final layout:

~/.config/neomutt/
├── neomuttrc        core settings: identity, accounts, IMAP tuning
├── keys             all key bindings
├── macros           all macros
├── colors.dracula   Dracula truecolor theme
├── account-command  credential helper (see above)
└── accounts.local   private email addresses (gitignored)

At the end of neomuttrc:

source ~/.config/neomutt/keys
source ~/.config/neomutt/macros
source ~/.config/neomutt/colors.dracula

Core config stays readable, each part can be changed independently.

Keybinds and macros

In Neomutt there are keybinds and macros. Macros run a sequence of keys/functions with a description, keybinds map a key to a single function.

Folder jumps instead of sidebar polling

First I tried putting every special folder (Drafts, Spam, Sent, Junk) into the sidebar with named-mailboxes.
Works fine, until you notice Neomutt opens and polls every single remote folder on startup, which slowed things down noticeably.

So back to just the two inboxes in the sidebar, plus jump macros:

macro index,pager ,gs "<change-folder>imaps://imap.gmail.com/[Gmail]/Spam<Enter>"   "Open Gmail Spam"
macro index,pager ,gd "<change-folder>imaps://imap.gmail.com/[Gmail]/Drafts<Enter>" "Open Gmail Drafts"
macro index,pager ,gi "<change-folder>imaps://imap.gmail.com/INBOX<Enter>"          "Open Gmail Inbox"
macro index,pager ,id "<change-folder>imaps://imap.mail.me.com/Drafts<Enter>"       "Open iCloud Drafts"
macro index,pager ,ij "<change-folder>imaps://imap.mail.me.com/Junk<Enter>"         "Open iCloud Junk"
macro index,pager ,ii "<change-folder>imaps://imap.mail.me.com/INBOX<Enter>"        "Open iCloud Inbox"
macro index,pager ,b  "<change-folder>!<Enter>"                                     "Back to current INBOX"

The , prefix is unbound by default, so no conflicts. Folders only get opened when I actually jump to them.

Vim-style navigation

I live in vim, so the muscle memory should carry over:

bind index gg first-entry      # top of mailbox
bind index GG bottom-page      # last mail of the current page
bind index,pager \Cd half-down # half page down, like vim
bind index,pager \Cu half-up   # half page up
bind pager gg top              # top of the open message
bind pager G bottom            # bottom of the open message

The catch: multi-key bindings steal their prefix

You cannot have a two-key sequence like gg AND keep a single-key binding on g.
Neomutt refuses to source the config entirely in that case.
So the original functions had to move:

KeyNowWas
g(prefix for gg)group-reply
G(prefix for GG)fetch mail
Esc+ggroup-reply
,ffetch mail

Worth knowing before you sit there wondering why group-reply suddenly does nothing. Ask me how I know.

Bonus: nicer flags

The N next to unread mails comes from $flag_chars. You can change it, but order matters, one character per state:

# tagged * | flagged ! | deleted D | replied r | old O | NEW ● | read -
set flag_chars = "*!DdrO●on- "

Troubleshooting: “No authenticators available or wrong credentials”

Sometimes Neomutt just wouldn’t log in. Then I ran pass manually first, and suddenly it worked. Very confusing.

The reason: account-command calls pass, which needs gpg-agent, and gpg-agent asks for the passphrase through a program called pinentry. By default that’s the terminal-based pinentry-curses, and that cannot draw its prompt while Neomutt owns the screen. So decryption fails silently, Neomutt gets an empty password, login fails.

Fix: the macOS GUI pinentry, plus a longer passphrase cache.

brew install pinentry-mac

~/.gnupg/gpg-agent.conf:

pinentry-program /opt/homebrew/bin/pinentry-mac
default-cache-ttl 28800
max-cache-ttl 28800

Restart the agent:

gpgconf --kill gpg-agent

And belt-and-braces: my shell launches Neomutt through a function that fills the passphrase cache first:

neomutt() {
    pass show mail/gmail >/dev/null 2>&1
    pass show mail/icloud >/dev/null 2>&1
    TERM=xterm-direct command neomutt "$@"
}

One passphrase popup per session instead of mysterious login errors. (TERM=xterm-direct enables the 24-bit colours for the Dracula theme.)

So, was it worth it?

The setup, in one picture:

Neomutt
   |
   v
pass
   |
   v
~/.password-store
   |
   | encrypted with GPG
   v
GnuPG private key

No plaintext passwords in the config, no addresses either.
The tracked dotfiles stay clean, vim muscle memory carries over, folders only load when I need them, and gpg-agent doesn’t fight the TUI anymore.

Is terminal email for everyone? Probably not.
But I already live in the terminal, and now my email lives there too: configured exactly the way I want, versioned with everything else, and one less app stealing my focus.

Himalaya and aerc are still on the list. We’ll see if Neomutt survives the comparison.

Further Reads