Skip to content

Instantly share code, notes, and snippets.

@steinerkelvin
Last active April 27, 2026 18:39
Show Gist options
  • Select an option

  • Save steinerkelvin/8d0cae04292616b25fb4775150f4bd3d to your computer and use it in GitHub Desktop.

Select an option

Save steinerkelvin/8d0cae04292616b25fb4775150f4bd3d to your computer and use it in GitHub Desktop.
My email setup: filters as code + local Gmail mirror + indexed search

My email setup: filters as code + local mirror + indexed search + TUI

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.

The stack

Five tools, each doing one thing:

  • gmailctl — declarative Gmail filter rules in Jsonnet/YAML. Source of truth lives in git; gmailctl diff shows what would change against Gmail; gmailctl apply pushes. 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, run gmi 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.

Layout

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.

The Nix module (public)

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.

Justfile recipes

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.

The accent-folding wrapper

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.

The sync script

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())

aerc accounts file

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-map
INBOX=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=*

Notes from setup

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 wants gmail.readonly + gmail.modify + gmail.labels). If a token gets invalidated, re-run gmailctl init for the filter side or gmi auth from 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 pull has 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 with notmuch search 'tag:inbox' | head after the first sync.
  • remove_local_messages: true in .gmailieer.json only 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.

Why this shape

I wanted three things:

  1. My filter rules in git. Gmail's web filter UI is where intentions go to be forgotten. Jsonnet config + gmailctl diff makes filter changes reviewable like any other code change.
  2. 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.
  3. No daemon, no IMAP, no surprises. Everything is on-demand. gmi pull when 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment