Metadata-Version: 2.4
Name: imapt
Version: 0.2.0
Summary: Bulk IMAP credential validation and mailbox search from the command line.
License-Expression: MIT
Project-URL: Homepage, https://git.homberger-it.de/homberger/imaptester
Project-URL: Repository, https://git.homberger-it.de/homberger/imaptester.git
Keywords: imap,cli,credential,security,audit
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: click>=8.1
Dynamic: license-file

# imapt

English | [Deutsch](docs/README.de.md)

Bulk IMAP credential validation and mailbox search from the command line.

`imapt` reads a list of `email:password` credentials, works out the right IMAP
server for each account's domain, then either checks whether each credential can
log in or searches mailboxes for a pattern (FROM / SUBJECT / BODY). Output is
plain, greppable lines: stdout carries only the result data, details go to
stderr.

- Pure standard library for the protocol work (`imaplib`, `sqlite3`, `ssl`).
- One runtime dependency: [`click`].
- Per-domain IMAP endpoints are cached in a small SQLite database.

> This tool is intended for auditing **your own** accounts or authorized
> security testing. Only point it at credentials you are permitted to use.

## Requirements

- Python **3.10+**
- [uv] (recommended) for installation

## Installation

### With uv (recommended)

Install the `imapt` command globally from a checkout of this repository:

```bash
git clone https://github.com/your-org/imapt.git
cd imapt
uv tool install .
```

This puts an `imapt` executable on your PATH (in uv's tool environment):

```bash
imapt --version
```

Install in editable mode if you plan to hack on it and want changes to apply
immediately:

```bash
uv tool install -e .
```

### Run without installing

From the repository root, `uv run` creates a project virtualenv, installs the
dependencies, and runs the CLI in one step:

```bash
cd imapt
uv run imapt --help
```

### With pip (alternative)

```bash
pip install .        # or: pip install -e .
python -m imapt --help
```

## Quick start

Prepare a credential file (see [Input format](#input-format)), then validate
logins:

```bash
imapt validate-login -i creds.txt
```

Search each account's INBOX for a subject line:

```bash
imapt search --subject "invoice" -i creds.txt
```

Combine filters — any of them may match (logical OR, in a single login). Every
filter option can also be repeated to list several alternatives:

```bash
imapt search --from alice@example.com --subject "invoice" -i creds.txt
imapt search --subject "invoice" --subject "receipt" --body "payment due" -i creds.txt
```

Search another folder, or add a raw IMAP expression (`--raw` becomes one more
OR alternative and lets you use `NOT`, custom headers, …):

```bash
imapt search --subject "invoice" --mailbox Archive -i creds.txt
imapt search --from alice@example.com --header 'X-Mailer=Gmail' --raw 'NOT SEEN' -i creds.txt
```

Reuse saved filter combinations instead of retyping them every time — store a
combination as a **rule**, group rules into **rulesets**, then run them:

```bash
imapt rule add invoice-hunt --from billing@example.com --subject invoice --description "all invoices"
imapt rule add unread-only --raw 'NOT SEEN' --mailbox Archive
imapt ruleset create audit2024 --description "yearly audit filter"
imapt ruleset add-rule audit2024 invoice-hunt
imapt ruleset add-rule audit2024 unread-only
imapt search --ruleset audit2024 -i creds.txt
```

The criteria *within* one rule stay AND (all must match together), but rules,
rulesets and inline filters are OR-combined with each other. With
`--all-rulesets` `imapt` runs one separate labelled search per stored ruleset —
one login per credential, one result row per ruleset. See
[Saved rules and rulesets](#saved-rules-and-rulesets).

Read credentials from stdin instead of a file with `-`:

```bash
cat creds.txt | imapt validate-login -i -
```

## Input format

Each non-empty, non-comment (`#`) line is one credential. The email and password
may be separated by `:`, `,`, tab, or space — whichever appears first after the
domain:

```text
user@example.com:password
user2@example.com,password2
user3@company.org p4ss word with spaces
# lines starting with # are ignored
```

JSON-lines are also accepted (one JSON object per line). Recognized keys
(case-insensitive): email via `email`/`user`/`login`/`username`, password via
`password`/`pass`/`secret`:

```json
{"email": "user@example.com", "password": "secret"}
```

Entries are deduplicated on `(email lowercased, exact password)`; the first
casing seen is preserved. Blank lines and malformed lines are skipped silently.

## Commands

| Command | Purpose |
| --- | --- |
| `validate-login` | Check whether each credential can log in. |
| `search` | Search mailboxes; combine header/body filters (`--from`, `--to`, `--subject`, `--body`, …) with OR. |
| `set-imap-server` | Store an explicit IMAP server for a domain. |
| `get-imap-server` | Show stored IMAP server configuration. |
| `rule` | Manage stored search rules (`add`/`list`/`show`/`delete`). |
| `ruleset` | Manage collections of rules (`create`/`add-rule`/`remove-rule`/`list`/`show`/`delete`). |
| `export-rules` | Export all stored rules and rulesets as portable JSON. |
| `import-rules` | Import rules/rulesets from an `export-rules` JSON file. |

### Run options

The two run commands (`validate-login`, `search`) share these options:

| Option | Default | Description |
| --- | --- | --- |
| `-i, --input PATH` | *(required)* | Credential file, or `-` for stdin. |
| `-w, --workers N` | `16` | Concurrent connections. |
| `-t, --timeout SEC` | `10.0` | Per-connection timeout. |

The `search` command additionally takes one or more filter options and/or saved
rules (`--rule`/`--ruleset`) — at least one filter in total is required. Filters
are combined with **OR**: a message counts when it matches *at least one*
filter, and every filter option may be repeated to add more alternatives. Each
filter runs as its own IMAP `SEARCH` over a single login per credential and the
matches are unioned:

| Option | Description |
| --- | --- |
| `--from TEXT` | Match the FROM header; repeatable (OR). |
| `--to TEXT` | Match the TO header; repeatable (OR). |
| `--cc TEXT` | Match the CC header; repeatable (OR). |
| `--bcc TEXT` | Match the BCC header; repeatable (OR). |
| `--subject TEXT` | Match the Subject header; repeatable (OR). |
| `--body TEXT` | Match the message body; repeatable (OR). |
| `--text TEXT` | Match headers or body (IMAP `TEXT`); repeatable (OR). |
| `--header NAME=VALUE` | Match any header field; repeatable (OR). The value may contain `=`. |
| `--raw IMAPSEARCH` | Free-form IMAP search expression as one more OR alternative; repeatable (advanced). |
| `--rule NAME` | Also apply a stored rule; repeatable (see [Saved rules and rulesets](#saved-rules-and-rulesets)). |
| `--ruleset NAME` | Apply all rules of a stored ruleset; repeatable. |
| `--all-rulesets` | Run one separate search per stored ruleset (mutually exclusive with the filters above). |
| `--mailbox NAME` | Folder to search; overrides any mailbox stored with rules (default `INBOX`). |

All text filters are **case-insensitive substring** matches — IMAP has no `*`/`?`
wildcards, so `--subject invoice` also matches *"Re: INVOICE #123"*. For non-ASCII
folder names pass the mailbox in IMAP modified UTF-7 (e.g. `Entw&APw-rfe`).

> **Note on `--raw`:** its value is sent to the server verbatim, so you are
> responsible for correct quoting/escaping. Use it for expressions the structured
> filters cannot express (`NOT`, `OR`, …), e.g. `--raw 'NOT SEEN'`.

### Global options

These go **before** the subcommand:

| Option | Env var | Default | Description |
| --- | --- | --- | --- |
| `--db PATH` | `IMAPT_DB` | `imapt.db` | SQLite database for stored endpoints. |
| `--ca-bundle FILE` | `IMAPT_CA_BUNDLE` | — | CA bundle for TLS verification. |
| `--no-verify` | — | off | Disable TLS certificate verification. |
| `--allow-plaintext` | — | off | Allow no-TLS on non-loopback hosts. |
| `--window SECONDS` | `IMAPT_WINDOW` | `600` | Time window for failure limits and the success cache. |
| `--account-limit N` | — | `3` | Max auth failures per address in the window; `0` disables. |
| `--server-limit N` | — | `5` | Max auth failures per target host in the window; `0` disables. |
| `--force-recheck` | — | off | Ignore cached successful logins and re-test everything. |

Example: keep state in a named database and trust a corporate CA:

```bash
imapt --db ~/imap-audit.db --ca-bundle /etc/ssl/corp-ca.pem search --subject "receipt" -i creds.txt
```

## Saved rules and rulesets

A **rule** is a named, reusable combination of search filters (the same options
`search` takes: `--from`, `--subject`, `--header`, `--raw`, …), optionally with a
description and its own mailbox. All criteria *within one rule* are AND-combined
— they must match together. A **ruleset** groups several rules; the member
rules are OR-combined with each other (and with any inline filters).

```bash
imapt rule add invoice-hunt --from billing@example.com --subject invoice --description "all invoices"
imapt rule add unread-only --raw 'NOT SEEN' --mailbox Archive
imapt rule list
imapt rule show invoice-hunt
imapt rule delete invoice-hunt

imapt ruleset create audit2024 --description "yearly audit filter"
imapt ruleset add-rule audit2024 invoice-hunt
imapt ruleset remove-rule audit2024 unread-only
imapt ruleset list
imapt ruleset show audit2024
imapt ruleset delete audit2024      # removes the set, keeps the member rules
```

Run them with `search` (repeatable, freely mixable with inline filters — a rule
given directly *and* via a ruleset is still applied only once):

```bash
imapt search --ruleset audit2024 -i creds.txt
imapt search --ruleset audit2024 --rule urgent --body deadline -i creds.txt
```

Combination semantics:

- Every filter group (one inline option value, or one whole rule) becomes its
  own IMAP `SEARCH`; a message matching any group is counted once. Criteria
  inside one rule stay AND-ed together.
- A rule may store a `--mailbox`; if all selected rules agree on one mailbox it
  is used automatically, `--mailbox` always wins, and conflicting mailboxes abort
  with an error telling you to pick one.
- `--all-rulesets` takes no filters at all: instead of merging, `imapt` runs one
  separate search per stored ruleset — each ruleset resolves its own mailbox
  (conflicts *within* a ruleset still abort), the output labels every row with
  the ruleset name. It cannot be combined with `--rule`, `--ruleset` or any
  inline filter, and fails if no rulesets are defined.
- Rules and rulesets live in the same SQLite database as endpoints and attempt
  logs (`--db`). Editing a rule means deleting and re-adding it — or simply
  importing a changed export with `import-rules --overwrite` (see below).

### Export and import

`export-rules` writes all stored rules and rulesets as a portable JSON document
(`-` = stdout by default), so you can move them to another instance or keep them
under version control:

```bash
imapt export-rules -o rules.json          # ... or just `imapt export-rules` to stdout
imapt --db other.db import-rules rules.json
imapt import-rules - < rules.json         # read from stdin
```

Import matches by name: existing rules/rulesets are **skipped** unless you pass
`--overwrite`, which updates them in place (rule id and ruleset memberships are
kept). Anything new is added; ruleset members are resolved by rule name, so an
import fails if a referenced rule does not exist. The whole import is atomic —
if any part is invalid, nothing is written:

```bash
imapt export-rules -o rules.json
# ...edit rules.json (change criteria, add a rule)...
imapt import-rules --overwrite rules.json
# rules: 1 added, 2 updated, 0 skipped; rulesets: 0 added, 1 updated, 0 skipped
```

The JSON is versioned (`"tool": "imapt", "kind": "rules", "version": 1`); files
that do not look like an `export-rules` document are rejected. Credentials and
attempt logs are never exported — only rules and rulesets.

A complete importable file looks like this (hand-writing one works just as well;
`criteria` are `[FIELD, "pattern"]` pairs ANDed within the rule, `raw` is an
optional free-form IMAP expression, `mailbox`/`description` may be `null`, and
ruleset members are referenced by rule name):

```json
{
  "tool": "imapt",
  "kind": "rules",
  "version": 1,
  "exported_at": "2026-08-31T06:35:33Z",
  "rules": [
    {
      "name": "invoice-hunt",
      "description": "all invoices",
      "mailbox": "Archive",
      "raw": null,
      "criteria": [
        ["FROM", "billing@example.com"],
        ["SUBJECT", "invoice"]
      ]
    },
    {
      "name": "unread-only",
      "description": "plain raw example",
      "mailbox": null,
      "raw": "NOT SEEN",
      "criteria": []
    }
  ],
  "rulesets": [
    {
      "name": "audit2024",
      "description": "yearly audit filter",
      "rules": ["invoice-hunt", "unread-only"]
    }
  ]
}
```

## Endpoint resolution

For each credential, `imapt` determines the IMAP server for its email domain in
this order:

1. **Explicit database entry** — a row you added with `set-imap-server`. Used as-is, never re-probed.
2. **Thunderbird autoconfig** — fetched from the provider's well-known config URL.
3. **Candidate probing** — tries `imap.<domain>`, `mail.<domain>`, and `<domain>` on port `993` (implicit TLS), then port `143` (STARTTLS), using the first that responds.

Endpoints discovered via autoconfig or probing are saved back to the database
with source `auto`, so later runs reuse them without re-probing. Failures are
cached too, avoiding repeated timeouts for dead domains.

### Pinning a server manually

When auto-detection is wrong (or you want to force a specific host), store it
explicitly:

```bash
imapt set-imap-server --domain example.com --host imap.mail.example.com --port 993
```

Use `--no-ssl` for a STARTTLS server on port 143:

```bash
imapt set-imap-server --domain example.org --host mail.example.org --port 143 --no-ssl
```

Inspect what is configured (omit `--domain` to list everything):

```bash
imapt get-imap-server --domain example.com
example.com   imaps://imap.mail.example.com:993   manual
```

The third column shows the source: `manual` or `auto`.

## Output

Every result is one plain line, printed to **stderr**:

```text
ok         user@example.com [imaps://imap.example.com:993] matches=2 | Subject: invoice hello
auth_fail  bad@example.com [imaps://imap.example.com:993] | LOGIN failed
conn_fail  other@example.net [imaps://imap.example.net:993] | timed out
no_server  ghost@nowhere.invalid [-]
skipped    cached@example.com [-] | cached ok
```

stdout stays free of noise and carries only the result data, so you can pipe
or redirect it directly: `validate-login` prints just a summary line at the
end, `search` prints only the rows that actually matched (its summary goes to
stderr too):

```text
total=5 ok=1 auth_fail=1 conn_fail=1 no_server=1 skipped=1 matches=2
```

Every attempt is also recorded in the database, independently of the output.

Status meanings: `ok` (login succeeded), `auth_fail` (rejected credentials),
`conn_fail` (connection/TLS error), `no_server` (no reachable IMAP host for the
domain), `skipped` (not attempted — already validated, or a failure limit was
reached; see below).

## Result caching & Fail2Ban-aware throttling

Every attempt is recorded in the database (`login_attempts`) with its email,
password, domain, endpoint, mode, pattern, status, match count, sample, error,
duration and an UTC timestamp. Two features build on this:

- **Success cache.** A credential (matched on email **and** password) that
  previously logged in `ok` is skipped on later `validate-login` runs instead of
  hitting the server again. Pass `--force-recheck` to ignore the cache and test
  everything live. Searches (`search`) always connect, since results need a
  live mailbox query.
- **Failure throttling.** To avoid tripping a server's Fail2Ban jail, `imapt`
  caps auth failures **per address** (`--account-limit`) and **per target host**
  (`--server-limit`) within `--window` seconds, counting both history and the
  current run. Once a limit is hit, further credentials are marked `skipped`
  rather than attempted. Limits are enforced per host (not per domain), because
  one mail server fronting many domains shares a single jail keyed on your
  source IP.

The limits are a defensive guard. For large bulk runs against **your own**
servers, the clean approach is to whitelist your test IP instead — e.g. in
`/etc/fail2ban/jail.local`:

```ini
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 203.0.113.10
```

then `fail2ban-client reload`. That lets you test freely without bans; the
throttling then simply never engages. Set `--server-limit 0` /
`--account-limit 0` to disable the guard entirely.

## Exit codes

- `0` — run completed.
- `2` — no credentials could be parsed from the input (or a usage error).

## TLS notes

Connections use implicit TLS on port 993 or STARTTLS on port 143 by default,
with certificate verification. Use `--ca-bundle` for private CAs, or
`--no-verify` to skip verification (not recommended). Plaintext connections are
only permitted automatically against loopback; use `--allow-plaintext` to permit
them elsewhere.

## Development

See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture and conventions. Run the
test suite with:

```bash
uv run python -m unittest discover -s tests
```

[click]: https://click.palletsprojects.com/
[uv]: https://docs.astral.sh/uv/
