I wanted my email in local Maildir files — searchable, indexable, and accessible from the command line. The goal is a stack where mbsync handles IMAP sync, himalaya provides quick CLI access, and neomutt gives me a full TUI when I need it. Push notifications via IMAP IDLE keep it all near-instant.
I run two accounts through this setup — Fastmail (personal) and Google Workspace (work) — and adding more follows the same pattern.
Why?
I'm building a universal search setup (via qmd) that indexes markdown and text files, and I want email in there too. Local Maildir makes that possible — every email becomes a file on disk that can be converted to plain text and indexed alongside my notes, wiki, and docs. Beyond search, having email as files means I can grep it, pipe it, and script against it — no web UI or proprietary API required.
The stack
| Tool | Role |
|---|---|
| mbsync (isync) | Two-way IMAP sync to local Maildir |
| himalaya | Fast CLI for listing, reading, flagging |
| neomutt | Full TUI with sidebar, vim keys, colors |
| goimapnotify | IMAP IDLE → triggers sync on new mail |
| qmd | Full-text search across all indexed email |
1. Install everything
brew install isync himalaya neomutt w3m
go install github.com/chmouel/goimapnotify@latest
w3m is for rendering HTML emails in neomutt. goimapnotify isn't in Homebrew but installs cleanly via go install.
2. Store your app passwords
Both Fastmail and Google require app-specific passwords for IMAP/SMTP. Store them in the macOS Keychain so nothing touches disk in plaintext:
# Personal (Fastmail)
security add-generic-password -a 'you@fastmail.com' -s 'personal-email' \
-w 'your-app-password' ~/Library/Keychains/login.keychain-db
# Work (Google Workspace)
security add-generic-password -a 'you@work.com' -s 'work-email' \
-w 'your-app-password' ~/Library/Keychains/login.keychain-db
For Fastmail, create an app password at Settings > Privacy & Security > App Passwords. For Google, go to myaccount.google.com/apppasswords.
Verify they work:
security find-generic-password -a 'you@fastmail.com' -s 'personal-email' -w
security find-generic-password -a 'you@work.com' -s 'work-email' -w
3. Configure mbsync
Create ~/.mbsyncrc with a section per account. Each account gets its own IMAPAccount, stores, and channel:
# =============================================================================
# Personal (Fastmail)
# =============================================================================
IMAPAccount personal
Host imap.fastmail.com
Port 993
User you@fastmail.com
PassCmd "security find-generic-password -a 'you@fastmail.com' -s 'personal-email' -w"
AuthMechs LOGIN
TLSType IMAPS
CertificateFile /etc/ssl/cert.pem
IMAPStore personal-remote
Account personal
MaildirStore personal-local
Path ~/Mail/personal/
Inbox ~/Mail/personal/INBOX
SubFolders Verbatim
Channel personal
Far :personal-remote:
Near :personal-local:
Patterns *
Create Both
Expunge Both
SyncState *
CopyArrivalDate yes
# =============================================================================
# Work (Google Workspace)
# =============================================================================
IMAPAccount work
Host imap.gmail.com
Port 993
User you@work.com
PassCmd "security find-generic-password -a 'you@work.com' -s 'work-email' -w"
AuthMechs LOGIN
TLSType IMAPS
CertificateFile /etc/ssl/cert.pem
IMAPStore work-remote
Account work
MaildirStore work-local
Path ~/Mail/work/
Inbox ~/Mail/work/INBOX
SubFolders Verbatim
Channel work
Far :work-remote:
Near :work-local:
Patterns * !"[Gmail]/All Mail" !"[Gmail]/Important" !"[Gmail]/Starred"
Create Both
Expunge Both
SyncState *
CopyArrivalDate yes
A few things to note:
AuthMechs LOGIN— required on macOS. Without it, isync tries the system SASL library which fails with a cryptic "Unable to find a callback" error.1SubFolders Verbatim— preserves folder names as-is (Fastmail's custom folders, Gmail's[Gmail]/prefix).Create Both+Expunge Both— full two-way sync. Deleting locally deletes on the server.CopyArrivalDate yes— preserves original email dates when syncing.- Gmail
Patterns— excludesAll Mail,Important, andStarredwhich are virtual labels that duplicate messages. Without this exclusion you'd sync every message twice.
Create the directories and run the initial sync:
mkdir -p ~/Mail/personal ~/Mail/work
mbsync -a
First run pulls everything. My personal account synced 143K messages in a couple minutes; the work account had 188. Incremental syncs take about 4 seconds for both combined.
You can also sync a single account: mbsync personal or mbsync work.
4. Configure himalaya
Himalaya reads the local Maildir (fast, offline) and sends via SMTP. Create ~/.config/himalaya/config.toml:
[accounts.personal]
default = true
email = "you@fastmail.com"
display-name = "Your Name"
backend.type = "maildir"
backend.root-dir = "~/Mail/personal"
message.send.backend.type = "smtp"
message.send.backend.host = "smtp.fastmail.com"
message.send.backend.port = 465
message.send.backend.encryption.type = "tls"
message.send.backend.login = "you@fastmail.com"
message.send.backend.auth.type = "password"
message.send.backend.auth.cmd = "security find-generic-password -a 'you@fastmail.com' -s 'personal-email' -w"
[accounts.work]
email = "you@work.com"
display-name = "Your Name"
backend.type = "maildir"
backend.root-dir = "~/Mail/work"
message.send.backend.type = "smtp"
message.send.backend.host = "smtp.gmail.com"
message.send.backend.port = 465
message.send.backend.encryption.type = "tls"
message.send.backend.login = "you@work.com"
message.send.backend.auth.type = "password"
message.send.backend.auth.cmd = "security find-generic-password -a 'you@work.com' -s 'work-email' -w"
Now you've got quick CLI access to both accounts:
himalaya envelope list # Default account inbox
himalaya envelope list -a work # Work account inbox
himalaya message read 1 # Read first message
himalaya flag add 1 seen # Mark as read
5. Configure neomutt
Neomutt handles multiple accounts via folder-hook — it switches from/smtp settings automatically based on which folder you're viewing.
Create ~/.config/neomutt/neomuttrc:
# =============================================================================
# Accounts
# =============================================================================
set mbox_type = Maildir
set realname = "Your Name"
# Default account: Personal (Fastmail)
set folder = "~/Mail/personal"
set spoolfile = "+INBOX"
set record = "+Sent"
set postponed = "+Drafts"
set trash = "+Trash"
set from = "you@fastmail.com"
set smtp_url = "smtps://you@fastmail.com@smtp.fastmail.com:465"
set smtp_pass = "`security find-generic-password -a 'you@fastmail.com' -s 'personal-email' -w`"
# Personal mailboxes
named-mailboxes \
"INBOX" +INBOX \
" Sent" +Sent \
" Drafts" +Drafts \
" Archive" +Archive \
" Spam" +Spam \
" Trash" +Trash
# Work mailboxes
named-mailboxes \
"Work" "~/Mail/work/INBOX" \
" Sent" "~/Mail/work/[Gmail]/Sent Mail" \
" Drafts" "~/Mail/work/[Gmail]/Drafts" \
" Trash" "~/Mail/work/[Gmail]/Trash" \
" Spam" "~/Mail/work/[Gmail]/Spam"
# Auto-switch account settings based on current folder
folder-hook "~/Mail/personal" "\
set from = 'you@fastmail.com'; \
set smtp_url = 'smtps://you@fastmail.com@smtp.fastmail.com:465'; \
set smtp_pass = \"\`security find-generic-password -a 'you@fastmail.com' \
-s 'personal-email' -w\`\"; \
set record = '+Sent'; set postponed = '+Drafts'; set trash = '+Trash'; \
macro index,pager A '<save-message>+Archive<enter>' 'Archive message'"
# Gmail archive = delete from INBOX (removes Inbox label; stays in All Mail)
folder-hook "~/Mail/work" "\
set from = 'you@work.com'; \
set smtp_url = 'smtps://you@work.com@smtp.gmail.com:465'; \
set smtp_pass = \"\`security find-generic-password -a 'you@work.com' \
-s 'work-email' -w\`\"; \
set record = '~/Mail/work/[Gmail]/Sent Mail'; \
set postponed = '~/Mail/work/[Gmail]/Drafts'; \
set trash = '~/Mail/work/[Gmail]/Trash'; \
macro index,pager A '<delete-message>' 'Archive message (Gmail)'"
# =============================================================================
# Display
# =============================================================================
set index_format = "%4C %Z %{%b %d} %-20.20L %s"
set sort = reverse-date
set sort_aux = last-date-received
set date_format = "%Y-%m-%d %H:%M"
set pager_index_lines = 10
set pager_context = 3
set pager_stop = yes
set tilde = yes
set markers = no
set wrap = 0
alternative_order text/plain text/html
auto_view text/html
# =============================================================================
# Sidebar
# =============================================================================
set sidebar_visible = yes
set sidebar_width = 24
set sidebar_format = "%D%?F? [%F]?%* %?N?%N?"
set sidebar_short_path = no
set mail_check_stats = yes
bind index,pager \Cp sidebar-prev
bind index,pager \Cn sidebar-next
bind index,pager \Co sidebar-open
bind index,pager B sidebar-toggle-visible
# =============================================================================
# Vim keys
# =============================================================================
bind index j next-entry
bind index k previous-entry
bind index g noop
bind index gg first-entry
bind index G last-entry
bind pager j next-line
bind pager k previous-line
bind pager g noop
bind pager gg top
bind pager G bottom
# =============================================================================
# Macros
# =============================================================================
# Switch inboxes
macro index gp "<change-folder>+INBOX<enter>" "Go to personal inbox"
macro index gw "<change-folder>~/Mail/work/INBOX<enter>" "Go to work inbox"
# Manual sync
macro index S "<shell-escape>mail-sync -a<enter>" "Sync all mail"
# Archive (overridden per-account by folder-hooks above)
macro index,pager A "<save-message>+Archive<enter>" "Archive message"
# =============================================================================
# Compose
# =============================================================================
set editor = "nvim"
set edit_headers = yes
set fast_reply = yes
set include = yes
A few things going on here:
named-mailboxesgives each sidebar entry a clean display name. Without this,~/Mail/work/[Gmail]/Sent Mailwould show as a long ugly path.folder-hookauto-switches from address, SMTP, and folder paths when you navigate between accounts. Note the escaped quotes around the backtick —\"\...`\"` — this is needed when the password contains spaces.gp/gwmacros jump directly between inboxes usinggas a vim-like leader key.- Archive (
A) behaves differently per account: moves to+Archivefor Fastmail, deletes from INBOX for Gmail (which is how Gmail archiving works — the message stays in All Mail, it just loses the Inbox label). - No
new_mail_command— sync is handled entirely by background services (see step 6), not by neomutt. TheSmacro is there for manual sync when you want it.
And ~/.mailcap for HTML rendering:
text/html; w3m -I %{charset} -T text/html -dump; copiousoutput;
For colors, I'm using the Catppuccin Mocha theme, which uses terminal palette colors (color0-15). If your terminal already has Catppuccin set as its color scheme, just append the theme's neomuttrc to your config.
6. Push notifications with IMAP IDLE
Polling every 15 minutes is fine, but IMAP IDLE gives you near-instant sync. goimapnotify holds an IDLE connection open and runs mbsync the moment new mail arrives.
goimapnotify supports one IMAP connection per config file, so create one per account:
~/.config/goimapnotify/personal.json:
{
"host": "imap.fastmail.com",
"port": 993,
"tls": true,
"username": "you@fastmail.com",
"passwordCmd": "security find-generic-password -a 'you@fastmail.com' -s 'personal-email' -w",
"onNewMail": "/Users/you/.local/bin/mail-sync personal",
"boxes": ["INBOX"]
}
~/.config/goimapnotify/work.json:
{
"host": "imap.gmail.com",
"port": 993,
"tls": true,
"username": "you@work.com",
"passwordCmd": "security find-generic-password -a 'you@work.com' -s 'work-email' -w",
"onNewMail": "/Users/you/.local/bin/mail-sync work",
"boxes": ["INBOX"]
}
Note each config targets its specific mbsync channel (mail-sync personal / mail-sync work) rather than mail-sync -a, so a new email in one account doesn't trigger a full sync of both.
The sync wrapper
With both goimapnotify instances and a launchd timer all triggering syncs, you can get collisions. A wrapper script handles locking and chains together the full pipeline — IMAP sync, text extraction, and search indexing (see section 8):
~/.local/bin/mail-sync:
#!/bin/sh
# Full mail sync: pull from IMAP, convert to text, update search index
LOCKFILE="${HOME}/.mbsync.lock"
if ! mkdir "$LOCKFILE" 2>/dev/null; then
exit 0
fi
trap 'rmdir "$LOCKFILE"' EXIT
/opt/homebrew/bin/mbsync "$@"
/Users/you/.local/bin/maildir-to-text
qmd update -c email
chmod +x ~/.local/bin/mail-sync
Uses mkdir as an atomic lock — if another sync is already running, the second one silently exits. All the goimapnotify configs and launchd plists point at this wrapper instead of calling mbsync directly. The maildir-to-text and qmd update steps are explained in section 8.
Test them:
~/go/bin/goimapnotify -conf ~/.config/goimapnotify/personal.json
~/go/bin/goimapnotify -conf ~/.config/goimapnotify/work.json
You should see Watching mailbox INBOX for each.
7. Keep it running with launchd
Three launchd agents: one goimapnotify per account for push, plus mbsync on a 15-minute timer as a fallback (catches non-INBOX folder changes, flag syncs, etc.).
Create a plist for each goimapnotify instance. Here's the template — duplicate it for each account, changing the label, config path, and log path:
~/Library/LaunchAgents/com.example.goimapnotify-personal.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.goimapnotify-personal</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/go/bin/goimapnotify</string>
<string>-conf</string>
<string>/Users/you/.config/goimapnotify/personal.json</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/you/Library/Logs/goimapnotify-personal.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/Library/Logs/goimapnotify-personal.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
For the work account, duplicate the file as com.example.goimapnotify-work.plist and swap personal → work in the label, config path, and log path.
The sync timer plist (runs the full pipeline as a fallback):
~/Library/LaunchAgents/com.example.mbsync.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.mbsync</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/.local/bin/mail-sync</string>
<string>-a</string>
</array>
<key>StartInterval</key>
<integer>900</integer>
<key>StandardOutPath</key>
<string>/Users/you/Library/Logs/mbsync.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/Library/Logs/mbsync.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
Load them all:
launchctl load ~/Library/LaunchAgents/com.example.goimapnotify-personal.plist
launchctl load ~/Library/LaunchAgents/com.example.goimapnotify-work.plist
launchctl load ~/Library/LaunchAgents/com.example.mbsync.plist
8. Indexing email for search
With everything syncing to local Maildir, the last piece is making it all searchable. Raw MIME email files aren't great for full-text search — they're full of base64 attachments, MIME boundaries, and encoded headers. A small Ruby script extracts the useful bits into plain text files, and qmd indexes them.
The script uses only Ruby stdlib and runs on macOS system Ruby (2.6+) — no gems needed. It handles multipart MIME, base64/quoted-printable encoding, RFC 2047 encoded headers, and HTML-to-text conversion.
Converting MIME to plain text
~/.local/bin/maildir-to-text:
#!/usr/bin/ruby
# frozen_string_literal: true
# Convert Maildir messages to plain text files for search indexing.
#
# Walks ~/Mail/<account>/ directories, extracts headers + body from each
# message, and writes .txt files to ~/Mail/.index/<account>/<folder>/<uid>.txt.
#
# Skips Spam and Trash folders. Only processes new/changed messages
# (compares mtime). Safe to run repeatedly via cron or launchd.
#
# Ruby 2.6+ compatible (macOS system Ruby), stdlib only.
require 'base64'
require 'digest/md5'
require 'fileutils'
MAIL_ROOT = File.join(Dir.home, 'Mail')
INDEX_ROOT = File.join(MAIL_ROOT, '.index')
SKIP_FOLDERS = %w[Spam Trash [Gmail]/Spam [Gmail]/Trash].freeze
def strip_html(html)
text = html.gsub(/<(script|style)\b[^>]*>.*?<\/\1>/mi, '')
text.gsub!(/<\s*(br|p|div|tr|li|h[1-6])\b[^>]*\/?>/i, "\n")
text.gsub!(/<[^>]+>/, '')
text.gsub!('&', '&')
text.gsub!('<', '<')
text.gsub!('>', '>')
text.gsub!('"', '"')
text.gsub!(''', "'")
text.gsub!(' ', ' ')
text.gsub!(/&#(\d+);/) { [$1.to_i].pack('U') }
text.strip
end
def decode_header(value)
return '' if value.nil?
value.gsub(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/) do
charset, encoding, encoded = $1, $2.upcase, $3
decoded = if encoding == 'B'
Base64.decode64(encoded)
else
encoded.gsub('_', ' ')
.gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack('H*') }
end
decoded.force_encoding(charset).encode('UTF-8', invalid: :replace, undef: :replace)
rescue StandardError
value
end
end
def decode_body(body, transfer_encoding, charset)
decoded = case transfer_encoding.to_s.strip.downcase
when 'base64'
Base64.decode64(body)
when 'quoted-printable'
body.gsub(/=\r?\n/, '')
.gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack('H*') }
else
body
end
charset = 'UTF-8' if charset.nil? || charset.empty?
decoded.force_encoding(charset).encode('UTF-8', invalid: :replace, undef: :replace)
rescue Encoding::ConverterNotFoundError, ArgumentError
decoded.force_encoding('ASCII-8BIT').encode('UTF-8', invalid: :replace, undef: :replace)
end
def parse_headers(raw)
headers = {}
current_key = nil
raw.each_line do |line|
if line =~ /\A(\S+?):\s*(.*)/
current_key = $1
headers[current_key] = (headers[current_key] || '') + $2.rstrip
elsif current_key && line =~ /\A\s+(.*)/
headers[current_key] << ' ' << $1.rstrip
end
end
headers
end
def parse_content_type(headers)
ct = headers['Content-Type'] || 'text/plain'
type = ct.split(';').first.strip.downcase
charset = ct[/charset="?([^";]+)"?/i, 1]
boundary = ct[/boundary="?([^";]+)"?/i, 1]
[type, charset, boundary]
end
def parse_parts(body, boundary)
parts = []
sections = body.split("--#{boundary}")
sections[1..-1].each do |section|
break if section.start_with?('--')
section = section.sub(/\A\r?\n/, '')
header_body = section.split(/\r?\n\r?\n/, 2)
next unless header_body.length == 2
headers = parse_headers(header_body[0])
parts << { headers: headers, body: header_body[1] }
end
parts
end
def extract_text_body(parts)
plain = nil
html = nil
parts.each do |part|
type, charset, boundary = parse_content_type(part[:headers])
encoding = part[:headers]['Content-Transfer-Encoding']
if type == 'text/plain' && plain.nil?
plain = decode_body(part[:body], encoding, charset)
elsif type == 'text/html' && html.nil?
html = decode_body(part[:body], encoding, charset)
elsif type.start_with?('multipart/') && boundary
sub_parts = parse_parts(part[:body], boundary)
result = extract_text_body(sub_parts)
plain ||= result[:plain]
html ||= result[:html]
end
end
{ plain: plain, html: html }
end
def extract_text(msg_path)
raw = File.read(msg_path, encoding: 'ASCII-8BIT')
header_section, body = raw.split(/\r?\n\r?\n/, 2)
return '' unless header_section && body
headers = parse_headers(header_section)
from = decode_header(headers['From'])
to = decode_header(headers['To'])
subject = decode_header(headers['Subject'])
date = headers['Date'] || ''
type, charset, boundary = parse_content_type(headers)
encoding = headers['Content-Transfer-Encoding']
text_body = if type.start_with?('multipart/') && boundary
parts = parse_parts(body, boundary)
result = extract_text_body(parts)
result[:plain] || (result[:html] && strip_html(result[:html])) || ''
elsif type == 'text/plain'
decode_body(body, encoding, charset)
elsif type == 'text/html'
strip_html(decode_body(body, encoding, charset))
else
''
end
"From: #{from}\nTo: #{to}\nDate: #{date}\nSubject: #{subject}\n\n#{text_body}"
end
def stable_filename(msg_path)
Digest::MD5.hexdigest(File.basename(msg_path))[0, 12] + '.txt'
end
def skip_folder?(folder_name)
SKIP_FOLDERS.any? { |skip| folder_name.include?(skip) }
end
def process_account(account_path)
account_name = File.basename(account_path)
processed = 0
skipped = 0
Dir.glob(File.join(account_path, '**', '{cur,new}')).sort.each do |dir|
folder_path = File.dirname(dir)
folder_name = if folder_path == account_path
'.'
else
folder_path.sub("#{account_path}/", '')
end
next if skip_folder?(folder_name)
out_dir = File.join(INDEX_ROOT, account_name, folder_name)
FileUtils.mkdir_p(out_dir)
Dir.foreach(dir) do |filename|
next if filename.start_with?('.')
msg_path = File.join(dir, filename)
next unless File.file?(msg_path)
out_path = File.join(out_dir, stable_filename(msg_path))
if File.exist?(out_path) && File.mtime(out_path) >= File.mtime(msg_path)
skipped += 1
next
end
begin
text = extract_text(msg_path)
File.write(out_path, text, encoding: 'UTF-8')
processed += 1
rescue StandardError => e
warn " SKIP #{filename}: #{e.message}"
end
end
end
[processed, skipped]
end
accounts = Dir.children(MAIL_ROOT)
.map { |name| File.join(MAIL_ROOT, name) }
.select { |path| File.directory?(path) && File.basename(path) != '.index' }
.sort
total_processed = 0
total_skipped = 0
accounts.each do |account|
puts "Processing #{File.basename(account)}..."
processed, skipped = process_account(account)
total_processed += processed
total_skipped += skipped
puts " #{processed} new, #{skipped} unchanged"
end
puts "\nDone: #{total_processed} processed, #{total_skipped} unchanged"
chmod +x ~/.local/bin/maildir-to-text
Each email becomes a clean text file with headers and body:
From: Jane Doe <jane@example.com>
To: you@fastmail.com
Date: Wed, 26 Feb 2026 10:30:00 -0600
Subject: Meeting tomorrow
Hey, are we still on for tomorrow at 2pm?
The script is incremental — it compares mtimes and skips already-converted files, so it runs fast after the initial conversion. HTML-only emails get stripped down to readable text. Spam and Trash are excluded.
Creating the search index
With the text files in place, register them as a qmd collection:
qmd collection add ~/Mail/.index --name email --mask '**/*.txt'
qmd update -c email
The initial indexing takes a minute or two for 100K+ emails. After that, qmd update is called automatically by the mail-sync wrapper on every sync, so new emails are searchable within seconds of arriving.
Searching email
# Keyword search (fast, BM25)
qmd search "quarterly report" -c email
# Semantic search with reranking (slower, more relevant)
qmd query "emails about the server migration timeline" -c email -n 5
# Search across everything — email, wiki, notes
qmd query "meeting with Alex about deployment"
This is where the whole setup pays off. 140K+ emails, fully searchable from the command line, updated in near-real-time via IMAP IDLE. No cloud service, no Electron app, no API — just files and a local index.
Adding more accounts
The pattern is the same for any IMAP provider:
- Store the app password in Keychain (
security add-generic-password) - Add an IMAPAccount/IMAPStore/MaildirStore/Channel block to
~/.mbsyncrc - Add an
[accounts.name]section to himalaya'sconfig.toml - Add
named-mailboxesand afolder-hooktoneomuttrc - Create a goimapnotify JSON config and launchd plist
- Run
mbsync <name>thenmaildir-to-text— new account is automatically picked up by qmd
Each account syncs to its own ~/Mail/<name>/ directory, keeps its own IDLE connection, auto-switches in neomutt when you navigate between folders, and is immediately searchable via qmd.
The result
Four tools, each doing one thing well:
himalaya envelope listfor a quick inbox check from the terminalneomuttwhen I want to browse, compose, or archive in bulkmail-sync -ato sync on demand, plus push and polling in the backgroundqmd search "..." -c emailto search 140K+ emails instantly
All reading from local Maildir under ~/Mail/. No lock-in, no daemon, no Electron app. Just files and a local search index.
- This is a known macOS issue with isync's SASL integration. Apple's system SASL library defines callback IDs that upstream cyrus-sasl doesn't, causing the cryptic error code. The
AuthMechs LOGINdirective bypasses SASL negotiation entirely.↩