Metadata-Version: 2.5
Name: muttlike-imap
Version: 1.2.0
Summary: Search IMAP mailboxes from the command line using mutt-style patterns.
Project-URL: Homepage, https://github.com/PierreSenellart/muttlike-imap
Project-URL: Issues, https://github.com/PierreSenellart/muttlike-imap/issues
Project-URL: Changelog, https://github.com/PierreSenellart/muttlike-imap/blob/main/CHANGELOG.md
Author-email: Pierre Senellart <pierre@senellart.com>
License: MIT
License-File: LICENSE
Keywords: cli,email,imap,mutt,search
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Utilities
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Description-Content-Type: text/markdown

# muttlike-imap

[![CI](https://github.com/PierreSenellart/muttlike-imap/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/PierreSenellart/muttlike-imap/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/muttlike-imap?style=flat)](https://pypi.org/project/muttlike-imap/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

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

```console
$ 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

```sh
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:

```sh
# 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](https://github.com/openclaw/imap-smtp-email).
   See [`docs/openclaw.md`](docs/openclaw.md) for wiring `muttlike-imap`
   into an openclaw workspace.

A minimal config file:

```ini
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`](https://www.passwordstore.org/),
  [`secret-tool`](https://wiki.gnome.org/Projects/Libsecret),
  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`](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.

```sh
# 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](https://notmuchmail.org/), `--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.

```sh
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:

```ini
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:

```sh
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:

```json
"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`.

```sh
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`](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

```python
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](LICENSE).
