Skip to main content

muttlike-imap

CI PyPI License: MIT

Search IMAP mailboxes from the command line using mutt-style patterns.

$ muttlike-imap "~f alice ~U" --summary
1 result(s):

UID:1264 | From:Alice <alice@example.com> | Date:Tue, 21 Apr 2026 15:02:35 +0000
Subject:Re: project update
Preview:Hi, here is the latest version of the document…

Why

Mutt has a great pattern language for finding messages (~f, ~s, ~d <7d, !~U, (A | B) C, …). IMAP servers can do most of the same searches, but the wire protocol is verbose and the bindings in imaplib are awkward to compose. muttlike-imap is a small CLI that translates mutt patterns into IMAP SEARCH criteria and prints the matches as JSON or a human-readable summary.

It's intentionally small and scriptable – useful as a building block for notification scripts, AI assistants, cron jobs, or one-off lookups.

Install

pip install muttlike-imap

Requires Python 3.9+. No third-party dependencies.

Shell completion

muttlike-imap ships completion scripts for zsh, bash, and fish. Add one of the following to your shell rc file:

# zsh (~/.zshrc)
eval "$(muttlike-imap --completion zsh)"

# bash (~/.bashrc)
eval "$(muttlike-imap --completion bash)"

# fish (~/.config/fish/config.fish, or just save the output)
muttlike-imap --completion fish | source

The zsh script offers per-flag tailoring (file paths for --config, (true false) for --imap-tls, env-var names for --imap-password-env, live mailbox completion via --list-mailboxes). Bash and fish provide flag-name completion plus the same enumerations where applicable.

Configure

muttlike-imap looks for connection settings in this order (highest priority first):

  1. CLI flags: --imap-host, --imap-port, --imap-user, --imap-password-cmd, --imap-password-env, --imap-tls, --search-cmd.
  2. Environment variables: IMAPQUERY_HOST, IMAPQUERY_PORT, IMAPQUERY_USER, IMAPQUERY_PASS, IMAPQUERY_TLS.
  3. The file pointed to by $IMAPQUERY_CONFIG, if set.
  4. $XDG_CONFIG_HOME/muttlike-imap/config (defaults to ~/.config/muttlike-imap/config).
  5. ~/.config/imap-smtp-email/.env: kept as a fallback for users who already have this file from the openclaw imap-smtp-email skill. See docs/openclaw.md for wiring muttlike-imap into an openclaw workspace.

A minimal config file:

IMAP_HOST=imap.example.com
IMAP_PORT=993
IMAP_USER=you@example.com
IMAP_PASS_CMD=pass email/imap.example.com
IMAP_TLS=true

The keys can be written with the IMAP_ prefix (shown above, matches mutt and offlineimap conventions), with the IMAPQUERY_ prefix, or with no prefix at all (HOST=…); pick whichever you prefer.

Passwords

There are three ways to give muttlike-imap your password, in increasing order of how much they leak:

  • IMAP_PASS_CMD=<shell command> (recommended). The command is run on every invocation; the first line of stdout is used as the password. Pair with pass, secret-tool, macOS Keychain (security find-generic-password -w), or anything else that prints a secret to stdout. The password lives only in the muttlike-imap process memory and is never written to disk or to the environment.

    Equivalent CLI flag: --imap-password-cmd "pass email/imap.example.com".

  • --imap-password-env MY_VAR. Reads the password from a named environment variable. Useful when you already have a secret in a variable (e.g. via direnv or a parent process) and don't want to re-export it. Caveat: env vars leak through /proc/<pid>/environ, child processes, ps eww, and crash dumps.

  • IMAP_PASS=<plaintext> in the config file. Simplest, least secure; fine for throwaway accounts and local-only setups. Make sure the file is chmod 600.

See docs/secrets.md for worked examples with pass, raw gpg, and OS keyrings.

Examples

By default the search runs against INBOX and returns the 10 most recent matches as JSON. Pass --mailbox <name> to search elsewhere, --limit N to widen or narrow the result count, and --summary for human-readable output.

# Unread mail, default INBOX, summary view
muttlike-imap "~U" --summary

# All mail from Alice in the last week
muttlike-imap "~f alice ~d <7d" --summary

# Archived correspondence with Bob about a specific topic
muttlike-imap '~L bob ~s "project x"' --mailbox Archive --summary

# Anything addressed to me, last 30 days, that I haven't replied to
muttlike-imap '~p ~d <30d !~Q' --summary

# Date range, ISO format
muttlike-imap '~d 2025-09-01-2025-12-31 ~f committee' --summary

# JSON for piping into jq
muttlike-imap "~U" | jq '.[] | {uid, subject, from}'

# Discover available folders
muttlike-imap --list-mailboxes

Searching with notmuch or another engine

IMAP SEARCH looks at one folder at a time, with substring matching only. If your mail is also indexed by a search engine such as notmuch, --search queries it across all folders, then finds each match over IMAP so that the results are ordinary records (real UIDs, plus the mailbox they are in), usable with --uid, --move-to and --save-attachments. A mutt pattern, if given, further filters the matches on the IMAP side.

muttlike-imap --search 'from:alice and attachment:pdf and date:1y..' --summary
muttlike-imap --search 'subject:"project x"' '~U' --limit 20

The engine must index the maildirs that the IMAP server serves (for instance Dovecot's), whether it runs on the server or on a synced copy: the folder of each match is read from the path of its file, and the message is then located in that folder by its Message-ID. Configure it with these keys, in the config file or as IMAPQUERY_* environment variables:

Key Meaning
SEARCH_CMD Command running the engine (default notmuch), e.g. ssh -q mailhost notmuch; --search-cmd overrides it. Arguments are appended, quoted, and quoted a second time for the remote shell when the command starts with ssh.
SEARCH_ENGINE notmuch (default) or lines. With lines, SEARCH_CMD receives the query as its only argument and prints one <message-id><TAB><path> line per matching file, newest first: a few lines of script adapt mairix or any other engine to it.
SEARCH_LAYOUT How folders are stored on disk: maildir++ (default: .Sub.Folder directories next to the INBOX's cur and new, as in Dovecot's default) or fs (one directory per folder, nested).
SEARCH_ROOT With fs: the directory holding the INBOX, as the engine sees it.

For example, with notmuch running on the mail server:

SEARCH_CMD=ssh -q mail.example.com notmuch

With notmuch, each batch of results costs two engine calls (notmuch search for the Message-IDs, newest first, and notmuch show for their files); with ssh, a ControlMaster connection makes these cheap. A message with copies in several folders gives one record per copy. If the engine also indexes files that the IMAP server does not serve, such as a folder of search results made of symbolic links, exclude them from the index or expect them among the results.

Moving messages

--move-to FOLDER moves the messages given by --uid out of --mailbox (default INBOX). Take the UIDs from a search, and pass the uidvalidity reported with them so that the move is refused if the server has renumbered the folder in the meantime:

muttlike-imap '~f alice ~s report' | jq '.[] | {uid, uidvalidity, subject}'
muttlike-imap --uid 4520 --uidvalidity 1328350824 --move-to Archive --dry-run --summary
muttlike-imap --uid 4520 --uidvalidity 1328350824 --move-to Archive --summary

A message is never deleted before its copy is known to exist:

  • The destination must already exist; it is never created.
  • If the server supports MOVE (RFC 6851), the move is a single atomic command.
  • Otherwise the message is copied, the copy is located in the destination (same Message-ID, same size), and only then is the original flagged \Deleted and removed with UID EXPUNGE (RFC 4315), which expunges that one UID: other messages already flagged \Deleted are left alone. Without UIDPLUS, or if the copy cannot be confirmed, the original stays where it was and the result says copied or skipped.

Each message gets a record with a status (moved, would-move, copied or skipped), the method used, the new_uid in the destination when known, and a reason when something was held back. The exit status is 0 when every message was moved, 2 when some were not, 1 on error.

Attachments

Each record lists the message's attachments, numbered from 1:

"attachments": [
  {"index": 1, "filename": "report.pdf", "content_type": "application/pdf", "size": 120412}
]

Every part except the displayable text counts as an attachment: a text/plain or text/html part is one only if it has a file name or Content-Disposition: attachment. An attached email is one attachment (saved as .eml).

--save-attachments DIR saves the attachments of the messages given by --uid into DIR; --attachment restricts to some of them, by number or by file name. --uidvalidity can be passed as for --move-to.

muttlike-imap --uid 4520 --save-attachments ~/Downloads
muttlike-imap --uid 4520 --save-attachments ~/Downloads --attachment 2 notes.txt
muttlike-imap --uid 4520 --save-attachments - --attachment report.pdf | pdftotext - -
  • An existing file is never overwritten: report.pdf becomes report-1.pdf, report-2.pdf…
  • File names are reduced to their last path component, without leading dots or control characters, so an attachment cannot be written outside DIR. A part without a name is saved as attachment-N with an extension guessed from its type.
  • DIR - writes the attachment to standard output; exactly one must then be selected.

Each message gets a record with saved (the index, filename and path of each file written), unmatched (selectors that designate no attachment) and a status: saved, partial or skipped. The exit status is 0 when everything requested was saved, 2 otherwise, 1 on error.

Pattern syntax

A B is AND (juxtaposition), A | B is OR, !A is NOT, and (...) groups.

Modifier Meaning
~f <text> From contains
~t <text> To contains
~s <text> Subject contains
~b <text> Body contains
~B <text> Body or any header contains
~c <text> Cc contains
~C <text> To, Cc, or Bcc contains
~L <text> Any participant (From/To/Cc)
~e <text> Sender header
~i <text> Message-ID
~y <text> X-Label
~h "Name: text" Arbitrary header
~x <text> References / In-Reply-To
~A All messages
~U / ~N Unread / new
~R / ~O Read / old
~F Flagged
~D Deleted
~Q Replied (answered)
~p Addressed to you
~P From you
~d <Nu Date: header newer than N units (units y m w d H M S)
~d >Nu Date: header older than N units
~d =Nu Exactly N units old
~d DATE On a specific date (YYYY-MM-DD or D/M/Y)
~d -DATE / ~d DATE- Half-open range (before / since)
~d DATE-DATE Range
~d DATE*Nu Range of ±N units around DATE
~r DATERANGE Same grammar but on received date (INTERNALDATE)
~z <N / ~z >N / ~z N-M Size in bytes (suffix K/M)

See docs/pattern-syntax.md for the full reference.

Limitations vs mutt

  • No regex. IMAP SEARCH is substring-only, so all text matches are literal. Anchors and character classes are not honored.
  • No mutt-runtime modifiers. ~T (tagged), ~v (collapsed thread), ~m (message-number), ~n (score), ~$, ~#, ~(...) (thread patterns), PGP modifiers: all error out instead of silently misbehaving.

H/M/S offsets are exact despite IMAP's day granularity: the server narrows to the smallest whole-day window containing the precise range, then a client-side post-filter trims the fetched candidates by their Date: header (or INTERNALDATE for ~r). ~d <30M really does mean "last 30 minutes". The post-filter is suppressed when the sub-day modifier sits inside an OR, !, or paren-grouped disjunction, since lifting its predicate to a top-level filter would change the pattern's meaning.

Library use

from muttlike_imap.parser import parse_pattern
from muttlike_imap.client import search
from muttlike_imap.config import load_config

config = load_config()  # or pass in your own dict
results = search(config, "~f alice ~U", limit=10, mailbox="INBOX")
for r in results:
    print(r["subject"])

# The other operations of the command line:
from muttlike_imap import move_messages, save_attachments, search_engine

search_engine(config, "from:alice and attachment:pdf", pattern="~U", limit=10)
save_attachments(config, ["4520"], "/tmp", mailbox="INBOX", selectors=["report.pdf"])
move_messages(config, ["4520"], "Archive", mailbox="INBOX", dry_run=True)

# Or just use the parser:
parse_pattern("(~f a | ~f b) ~U")
# → 'OR (FROM "a") (FROM "b") UNSEEN'

Authorship

muttlike-imap was written by Pierre Senellart with the assistance of generative models from Anthropic (Claude). The pattern language, the command-line interface, and the behavior of the tool were specified and reviewed by the author, who is responsible for the whole. The test suite runs in continuous integration.

License

MIT: see LICENSE.

Metadata

Release files for muttlike-imap 1.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for muttlike-imap 1.2.0
File Size Uploaded
muttlike_imap-1.2.0.tar.gz 69.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for muttlike-imap 1.2.0
File Interpreter ABI Platform
muttlike_imap-1.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 108.0 kB

Release files / muttlike_imap-1.2.0.tar.gz

Download URL muttlike_imap-1.2.0.tar.gz
Size 69.1 kB
Tags Source
SHA-256 checksum
How to use checksums
61c9a1e32c9424e9ad2caae5e1e139c25aa0dc64dc4eeb3703576cefecc9abe7
BLAKE2b-256 checksum
How to use checksums
159730530f4435667c4e094b72569128818f2c883afb2b224d21d4f371bb76d9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.

Transparency log

Release files / muttlike_imap-1.2.0-py3-none-any.whl

Download URL muttlike_imap-1.2.0-py3-none-any.whl
Size 38.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e5fe0ee9c1cfa89d44bbb773b030e80a483642f9593893712c27f2ba1915db27
BLAKE2b-256 checksum
How to use checksums
bd1452e4b657069583c613dfdae095f6fe5bd672605293e0ad7d0aec35dbfd17
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.2.0 This release

2 release files

1.1.3

2 release files

1.1.2

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.2

2 release files

1.0.1

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page