Gmail with my data on disk and my filter rules in git. No vendor lock on the routing logic, full-text search that doesn't depend on an internet round-trip, and a TUI for triage. Built as five small CLI tools wired together with a justfile.
This is the shape of the system, not the secrets. The Nix module that installs it is reproduced inline below; my account configs and OAuth credentials live in private stores. Friends can lift the structure into their own dotfiles in an afternoon.
Five tools, each doing one thing:
- gmailctl — declarative Gmail filter rules in Jsonnet/YAML. Source of truth lives in git;
gmailctl diffshows what would change against Gmail;gmailctl applypushes. No more clicking through the web UI to remember why a label exists. - lieer (
gmi) — Gmail API <-> maildir sync, label-preserving. Pulls every message and label down into a regular maildir tree on disk. Bidirectional: re-tag locally, rungmi push, the labels move on Gmail too. - notmuch — Xapian-indexed tag search over the maildir. Sub-100ms full-text queries across years of mail. Tags are first-class; folders barely matter.
- aerc — TUI client with a notmuch backend. Currently read-only here (sending not wired yet), used for triage and reading.
- himalaya — scriptable email CLI. Reserved for cron jobs and vault bridges (e.g. "star a thread, get a task in my notes").
You don't need all five. The minimum useful slice is gmailctl alone (filter management) or lieer + notmuch (offline search). Everything above that is ergonomics.
| Where | What |
|---|---|
~/data/mail/<account>/ |
lieer maildir + per-account state |
~/data/mail/.notmuch/ |
shared notmuch xapian DB across all accounts |
<private>/email/<account>/config.jsonnet |
gmailctl filter rules (source of truth, git-tracked) |
<private>/email/notmuch-config |
notmuch config (source of truth) |
~/.config/gmailctl/<account>/ |
gmailctl runtime dir (creds + token, symlinks back to source-of-truth config) |
~/.local/share/lieer/credentials.json |
OAuth client credentials |
~/.notmuch-config |
symlink to source-of-truth config |
Layering rule I follow: public dotfiles install the tools; private store owns the configs and credentials. A machine with both deployed can run the raw stack — gmi pull && notmuch new, gmailctl --config ~/.config/gmailctl/personal apply, aerc -a personal — after re-doing OAuth once to recreate local token files.
This is all that lives in my dotfiles repo to install the stack. Five package references and a comment block. From modules/features/email.nix:
# Email stack: filter management as code, local Gmail mirror, indexed search.
#
# - gmailctl : declarative Gmail filter rules (Jsonnet/YAML)
# - lieer : Gmail API <-> maildir sync, label-preserving (provides `gmi`)
# - notmuch : Xapian-indexed tag-based search over maildir
# - aerc : TUI client with notmuch backend (read/triage)
# - himalaya : scriptable email CLI, for cron jobs / vault bridges
#
# Per-account configuration (credentials, account specifics, gmailctl YAML)
# lives outside this module.
#
# Sending (msmtp + OAuth) is deferred. Drafts handled via Gmail MCP for now.
_: {
flake.homeModules.email = { pkgs, ... }: {
home.packages = [
pkgs.gmailctl
pkgs.lieer
pkgs.notmuch
pkgs.aerc
pkgs.himalaya
];
};
}It's a flake-parts home-manager module. If you're not on Nix, the equivalent is brew install gmailctl lieer notmuch aerc himalaya or your distro's package manager. All five are packaged in nixpkgs and most major distros.
The kspace justfile has thin wrappers so I don't have to remember the per-tool argument shapes:
# Sync mail for an account: gmi pull + notmuch new (default: personal)
mail-sync account="personal":
@scripts/mail-sync.py {{ account }}
# Push local notmuch tag changes back to Gmail labels (run after retagging)
mail-push account="personal":
cd ~/data/mail/{{ account }} && gmi push
# Re-index notmuch (no fetch)
mail-index account="personal":
@scripts/mail-index.py {{ account }}
# gmailctl filter ops on the source of truth (verb: diff | apply | download | edit)
mail-filters verb="diff" account="personal":
gmailctl --config ~/.config/gmailctl/{{ account }} {{ verb }}
# Open aerc on an account (read-only -- no msmtp wired yet)
mail account="personal":
aerc -a {{ account }}
# Quick notmuch search across all indexed accounts (auto accent-folds queries)
mail-search +query:
@scripts/mail-search.py {{ query }}Account-parameterised so adding a second account is one symlink and one extra OAuth flow.
Notmuch/Xapian don't fold accents — título and titulo are different tokens, so a search for the unaccented form misses Portuguese mail entirely. This 30-line wrapper detects accents in the query and OR's the stripped variant alongside:
#!/usr/bin/env python3
"""notmuch search with automatic accent folding."""
import subprocess
import sys
import unicodedata
def strip_accents(s: str) -> str:
return "".join(
c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn"
)
def main() -> int:
args = sys.argv[1:]
flags = [a for a in args if a.startswith("-")]
terms = [a for a in args if not a.startswith("-")]
query = " ".join(terms)
if not query:
return subprocess.run(["notmuch", "search", *flags]).returncode
folded = strip_accents(query)
if folded != query:
query = f"({query}) or ({folded})"
return subprocess.run(["notmuch", "search", *flags, "--", query]).returncode
if __name__ == "__main__":
sys.exit(main())Phrase quoting passes through. Could be done with notmuch's index-time CJK options or a custom stemmer, but a query-time wrapper is one file and works.
Equally small. The only non-obvious thing is that gmi pull is non-destructive of local tag changes — lieer tracks divergence and won't blow away unpushed local tags on a subsequent pull. So pull then notmuch new is always safe:
#!/usr/bin/env python3
"""Sync mail for one account via lieer."""
import argparse
import subprocess
import sys
from pathlib import Path
MAIL_ROOT = Path.home() / "data" / "mail"
def sync(account: str) -> int:
account_dir = MAIL_ROOT / account
if not (account_dir / ".gmailieer.json").exists():
print(f"error: {account_dir} is not a lieer maildir", file=sys.stderr)
return 2
print(f"==> gmi pull ({account})")
pull = subprocess.run(["gmi", "pull"], cwd=account_dir)
if pull.returncode != 0:
return pull.returncode
print("==> notmuch new")
new = subprocess.run(["notmuch", "new"])
return new.returncode
def main() -> int:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("account", nargs="?", default="personal")
args = p.parse_args()
return sync(args.account)
if __name__ == "__main__":
sys.exit(main())For reference, the aerc bit is a notmuch source pointed at the shared maildir, with a query-map that translates Gmail-flavoured folder names to notmuch tag queries:
[personal]
source = notmuch:///path/to/data/mail
maildir-store = /path/to/data/mail
default = INBOX
from = Your Name <you@gmail.com>
copy-to = Sent
folders-sort = INBOX
query-map = /path/to/aerc-personal.query-mapINBOX=tag:inbox and not tag:archived
Unread=tag:unread
Starred=tag:starred
Sent=tag:sent
Drafts=tag:draft
Trash=tag:trash
Spam=tag:spam
All=*
A few things that tripped me up or are worth knowing:
- OAuth client: gmailctl and lieer share one Desktop OAuth client JSON but each holds its own token (different scopes — gmailctl wants
gmail.settings.basic+gmail.labels; lieer wantsgmail.readonly+gmail.modify+gmail.labels). If a token gets invalidated, re-rungmailctl initfor the filter side orgmi authfrom the maildir for the lieer side. The JSON file itself is reusable across machines. - Publish your GCP OAuth app to "production" to avoid the 7-day refresh-token expiry. Test-mode tokens die weekly. There's no review for in-house apps using only Gmail scopes.
- Initial backfill:
gmi pullhas no flag for date scoping, it pulls everything. A 30k-message inbox takes a while — run it in the background. Re-running after a partial sync resumes from saved state, so interruptions are fine. - Label -> tag mapping is automatic and lowercased:
INBOX->inbox,UNREAD->unread. Verify withnotmuch search 'tag:inbox' | headafter the first sync. remove_local_messages: truein.gmailieer.jsononly governs deletions on the Gmail side propagating locally. It doesn't touch local tag state.- Sending is unwired here. Reading and triage only. msmtp + OAuth-via-XOAUTH2 is the standard path for sending; I haven't bothered yet because I draft via the Gmail web UI or the official Gmail MCP server in Claude Code. If you wire msmtp, you'll want a tiny token-fetcher script — there are several gists floating around.
I wanted three things:
- My filter rules in git. Gmail's web filter UI is where intentions go to be forgotten. Jsonnet config +
gmailctl diffmakes filter changes reviewable like any other code change. - Local search that's faster than Gmail's. Notmuch over a maildir gives me sub-100ms tag and content search across years of mail, offline, with no web latency. The Xapian DB is shared across accounts, so I can search "all inboxes" trivially.
- No daemon, no IMAP, no surprises. Everything is on-demand.
gmi pullwhen I want fresh mail. Nothing runs in the background. The maildir is just files; if any tool breaks I can still grep.
The stack is composable: drop aerc and use mutt or alot instead, drop himalaya if you don't script email, drop gmailctl if you like the Gmail UI for filters. Each piece is independently useful.
Repo for the (very small) Nix bit: steinerkelvin/dotfiles, file modules/features/email.nix.