imap-dispatcher
Rule-based IMAP mail dispatcher with optional AI classification.
Features
- Three-tier matching — substring rules first, then skip rules, then optional AI classification
- AI classification via Mistral — free-text rule descriptions, with graceful degradation if the API is unreachable
- Auto-learning — successful AI classifications are remembered so future mail from the same sender is matched instantly, without another AI call
- Feedback loop — adapts learned rules automatically when you manually move a mail the dispatcher sorted
- Dry-run mode — preview every move (and every learn) without changing anything
- Environment-variable secrets — override host/username/password/API key via env vars, 12-factor style
- Single YAML config — one file, no database, no external state beyond a small
state.json - Minimal dependencies —
imapclient,pyyaml,mistralai,httpx,html2text - Python 3.10+ — runs anywhere, no Raspberry Pi requirement
- UID-based state tracking — safe alongside other IMAP clients (e.g. Thunderbird) working the same mailbox in parallel
Quick Start
python3 -m venv .venv
source .venv/bin/activate
pip install imap-dispatcher
cp config.example.yaml config.yaml
chmod 600 config.yaml
# edit config.yaml: host, username, password, rules ...
imap-dispatcher --dry-run
Install from source
git clone <repo-url>
cd imap-dispatcher
python3 -m venv .venv
source .venv/bin/activate
pip install .
cp config.example.yaml config.yaml
chmod 600 config.yaml
# edit config.yaml: host, username, password, rules ...
imap-dispatcher --dry-run
Alternative install (venv copy)
If you prefer not to install the package (e.g. for a minimal deployment), copy the module directly and run it with python -m:
cp -r imap_dispatcher/ $INSTALL_DIR/
cp requirements.txt $INSTALL_DIR/
cd $INSTALL_DIR
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp config.example.yaml config.yaml
chmod 600 config.yaml
.venv/bin/python -m imap_dispatcher --config config.yaml --dry-run
Both invocation styles — imap-dispatcher ... (console script) and python -m imap_dispatcher ... (module) — work identically and are used interchangeably throughout this README.
Configuration
All options live in config.yaml (template: config.example.yaml, fully commented).
| Key | Description | Default |
|---|---|---|
host |
IMAP server, e.g. imap.example.com |
— (required) |
username |
Mail account username/address | — (required) |
password |
Password in plain text (see Security below) | — (required) |
inbox |
Source folder | INBOX |
state_file |
Path to the UID-tracking state file | state.json |
log_file |
Optional path to a log file (rotating, 1 MB, 3 backups); empty = stdout only | "" |
rules |
List of sorting rules (see Rule fields below) | — |
skip_rules |
List of skip rules that exclude senders from AI classification | — |
ai |
AI classification block (see below) | — |
ai_language |
Language of the AI classification prompt (en | de) |
en |
private_domains |
Domains never auto-classified by AI (see below) | common freemail providers |
feedback_ignore_folders |
Folders the feedback scan treats as neutral (see below) | empty list |
AI classification (ai: block)
ai:
provider: mistral # currently supported: mistral
api_key: "" # or set MISTRAL_API_KEY in the environment (env wins)
model: "mistral-small-latest" # model name, default is Mistral Small
provider— onlymistralis currently supported.api_key— your Mistral API key (create one at console.mistral.ai); can also be provided via theMISTRAL_API_KEYenvironment variable.model— the Mistral model name; defaults tomistral-small-latest.
ai_language controls the language of the AI classification prompt: en (default) or de.
Rule fields
Each rule under rules: supports:
| Field | Description |
|---|---|
name |
Label used in logs |
target_folder |
Destination folder for matches |
from_contains |
List of substrings to match in the From address |
from_name_contains |
List of substrings to match in the From display name |
to_contains |
List of substrings to match in the To header |
subject_contains |
List of substrings to match in the Subject header |
mark_seen |
Optional: true to set the \Seen flag on move |
ai_description |
Free-text description for AI classification (optional; only applies if no substring matcher already matched) |
Every rule needs at least one matcher field. The first matching rule wins. Fields within a single rule are AND-ed (all must match); entries within a field are OR-ed (any one is enough). skip is only valid inside skip_rules: — it is rejected inside rules:.
private_domains
Senders from these domains are treated as personal mail and are never auto-classified by AI. Defaults to common freemail providers (Gmail, Outlook, Yahoo, iCloud, Proton, and others); override the list in config.yaml if needed.
feedback_ignore_folders
Some folders don't carry any classification signal — for example a personal work-in-progress folder you park mail in temporarily, or Trash. Without this setting, moving mail into such a folder is indistinguishable from a genuine correction and triggers the third feedback outcome: the sender's learned rule is un-learned. That's not what you want for a WIP folder — it would silently and repeatedly un-learn a correct rule every time you park mail there.
feedback_ignore_folders lists folder names that the feedback scan treats as neutral: a tracked mail found there is simply discarded from tracking, with no change to learned.yaml at all — the same outcome as the mail not being found anywhere. This check takes precedence over all three correction outcomes, even when the ignored folder is itself a configured rule's target_folder or equals inbox.
Default is an empty list — no install changes behavior without an explicit edit. Recommended values: Trash, Junk/Spam, and any personal work-in-progress folder.
Environment variables
12-factor style overrides (e.g. via a systemd EnvironmentFile= or a shell export): IMAP_HOST, IMAP_USERNAME, IMAP_PASSWORD, and MISTRAL_API_KEY override the corresponding values in config.yaml when set. Environment values always take precedence over YAML (env wins); an empty environment variable falls through to the YAML value.
Security
This config file contains your mail password in plain text.
- Restrict permissions:
chmod 600 config.yaml - Even better: use a dedicated app password (if your provider offers one) instead of your main password
- Never commit
config.yamlto a Git repo
CLI Reference
Both invocation styles are shown below and behave identically: imap-dispatcher ... (after pip install .) and python -m imap_dispatcher ... (venv-copy path).
imap-dispatcher --config config.yaml [OPTIONS]
python -m imap_dispatcher --config config.yaml [OPTIONS]
| Flag | Description |
|---|---|
--version |
Print the version and exit |
--config PATH |
Path to config.yaml (default: config.yaml) |
--dry-run |
Only log moves, do not execute them |
--sort-existing |
Check all existing INBOX mail against the rules and sort it |
--sort-existing-ai |
Like --sort-existing, but including AI classification for mail without a substring match (opt-in, slower due to ~2.6s per mail) |
--limit N |
Process only the last N mails (for --sort-existing / --sort-existing-ai) |
--review-learned |
Show learned sender rules (audit) |
--review-learned reads learned.yaml from the config file's parent directory and prints an audit report, then exits — it does not connect to the mailbox.
Advanced Topics
Three-Tier Matching
Every incoming mail runs through up to three tiers until a target folder is found:
- Tier 1 — substring rules — first matching rule wins. Fast, deterministic, no network call.
- Tier 2 — skip rules — prevent the LLM call for known senders (e.g. known advertising without a matching rule). The mail stays in the INBOX.
- Tier 3 — AI classification — only if Mistral was reachable at startup. On outage the dispatcher keeps running with substring rules only (graceful degradation).
incoming mail
│
▼
Tier 1: substring rules ──match──► move + learn sender
│ no match
▼
Tier 2: skip rules ──match──► stays in INBOX (no AI call)
│ no match
▼
Tier 3: AI classification ──match──► move + learn sender
│ no match / AI unreachable
▼
stays in INBOX
(later) feedback scan detects manual corrections to AI-classified moves
UID Tracking & State
- First run — the dispatcher reads the current highest UID in the mailbox and stores it in
state.json. No existing mail is sorted; only a baseline is set. From the next run on, only mail with a higher UID (newly arrived) is processed. - UIDVALIDITY reset — if the IMAP server reports a new UIDVALIDITY (e.g. after a mailbox migration), the dispatcher discards the old state and sets a new baseline. Existing mail is again not sorted — only new mail.
- Sorting existing mail —
--sort-existingchecks all INBOX mail against the rules and moves matches. Combinable with--dry-runfor a preview. Does not modifystate.json. - Resetting state — deleting
state.jsonhas the same effect as a first run.state_fileis a config key (defaultstate.json, resolved relative to the working directory).
Auto-Learning
Every successful AI classification automatically learns the sender's full email address as a from_contains entry in a separate file learned.yaml (next to config.yaml, which is itself never modified). On the next run, tier 1 (substring matching) matches this sender directly — no more LLM call needed.
learned.yaml can be edited by hand: deleting a line removes the learned association again ("un-learn"). Use --review-learned to review all learned entries at a glance; output format is one line per entry:
from_addr -> rule_name (target_folder) [date]
Skip entries are shown as INBOX (Skip).
Guards: mails without a sender address are never learned (poisoning protection); already-learned addresses are never written twice; --dry-run suppresses every write.
Feedback Loop
When you manually move a mail that the dispatcher had sorted, it notices and adapts. Moves are tracked for 7 days; on each run the dispatcher checks whether tracked mails are still where it put them. If you moved one to a different rule's folder, the learned sender is re-learned for that rule; moving it back to INBOX means the sender is skipped from now on; moving it to any other (unmapped) folder un-learns the sender — unless that folder is listed in feedback_ignore_folders, in which case no learned-rule change is made at all.
Technical details
- Each AI-classified move is recorded as a
TrackedMove(message ID, rule name, sender address, target folder, moved-at timestamp) infeedback_tracking.json, stored next toconfig.yamland written atomically with mode0600. - Tracking entries expire after 7 days; corrections made later than that are silently ignored.
- Folders listed in
feedback_ignore_foldersare checked before the three outcomes below and produce nolearned.yamlchange — the tracking entry is simply discarded. - Three correction outcomes:
- Re-learn — the mail is found in another rule-mapped folder: the learned entry's rule is rewritten to the new rule.
- Skip — the mail is moved back to INBOX: the learned entry becomes a skip entry, and the sender stays in INBOX from now on.
- Un-learn — the mail is found in an unmapped folder: the learned entry is removed entirely.
- Corrections are logged with the
[CORRECTION]tag. - Guards: mails without a Message-ID are never tracked; a tracked mail found nowhere is silently discarded without touching
learned.yaml;--dry-runlogs the would-be correction but never writes.
Scheduling
Two ways to run imap-dispatcher on a schedule, Linux only.
systemd timer (recommended)
# 1. Create a dedicated, non-root system user
sudo useradd --system --no-create-home --shell /usr/sbin/nologin imap-dispatcher
sudo chown -R imap-dispatcher:imap-dispatcher $INSTALL_DIR
# 2. Copy the unit files
sudo cp imap-dispatcher.service /etc/systemd/system/
sudo cp imap-dispatcher.timer /etc/systemd/system/
The shipped imap-dispatcher.service hardcodes an install prefix (/opt/imap-dispatcher) in three lines — WorkingDirectory=, ExecStart=, and ReadWritePaths=. Edit those three paths to your $INSTALL_DIR before enabling the unit, e.g.:
sudo sed -i 's|/opt/imap-dispatcher|'"$INSTALL_DIR"'|g' /etc/systemd/system/imap-dispatcher.service
The unit also has a commented EnvironmentFile= hook for providing secrets via environment variables instead of config.yaml (see Environment variables above) — chmod 600 the referenced env file if you use it.
sudo systemctl daemon-reload
sudo systemctl enable --now imap-dispatcher.timer
# Check status and logs
systemctl status imap-dispatcher.timer
journalctl -u imap-dispatcher.service
The timer settings, quoted verbatim from imap-dispatcher.timer:
OnBootSec=2min
OnUnitActiveSec=1min
Persistent=true
OnBootSec=2min gives the network time to come up after boot. Because the service is Type=oneshot triggered by a timer, overlapping runs are impossible — the timer will not start a new run while the previous one is still active.
The unit ships a hardening block (NoNewPrivileges, ProtectSystem=strict, ReadWritePaths, and more). Check the applied score with:
systemd-analyze security imap-dispatcher.service
TimeoutStartSec=600 covers the worst-case retry runtime (IMAP retries at 5/15/45s, plus buffer).
cron (alternative)
A shorter alternative if you don't want to manage systemd units. Cron does not prevent overlapping runs by itself, so wrap the command in flock -n:
* * * * * /usr/bin/flock -n /var/lock/imap-dispatcher.lock $INSTALL_DIR/.venv/bin/imap-dispatcher --config $INSTALL_DIR/config.yaml
flock -nskips a run while the previous one still holds the lock. systemd's oneshot+timer combination gives this for free; cron does not — and since the worst-case retry runtime (~10 minutes) can exceed the 1-minute interval, the lock matters. The kernel releases the lock automatically on process exit; no stale-lockfile cleanup needed.- Use absolute paths — cron's
PATHis minimal. - Set
log_fileinconfig.yaml, since cron output has no journald integration. - Env-var secrets can go in the crontab itself or a sourced env file.
Troubleshooting
- Config validation failures print to stderr with the prefix
Config error:followed by indented- <detail>lines, and exit with code 1. Example details:'host' missing (required),'target_folder' missing (required),'state_file' directory not writable,unsupported ai provider '...' (supported: mistral). no such mailbox— the IMAP folder-hierarchy separator depends on your server (Posteo uses., e.g.INBOX.Newsletter). Verify the exact folder name with--dry-runor your mail client before trusting a rule'starget_folder.- AI unreachable at startup — logs a warning and the dispatcher continues running with substring rules only (graceful degradation); no crash.
- Retry behavior — IMAP run-level retries use delays of 5/15/45 seconds (4 attempts total); Mistral 429 rate limits are retried with 2/5/15 second backoff.
- File locations —
learned.yamlandfeedback_tracking.jsonare stored next toconfig.yaml(derived from--config's parent directory);state_fileis a config key (defaultstate.json, resolved relative to the working directory). learned.yamlandfeedback_tracking.jsonare written with mode0600because they contain sender addresses.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file imap_dispatcher-2.1.0.tar.gz.
File metadata
- Download URL: imap_dispatcher-2.1.0.tar.gz
- Upload date:
- Size: 48.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bfcc23ac623150ff3cc128a48c864c53aabd57f8d7f8a06f6a27ceaeee27c30a
|
|
| MD5 |
ad6466ca6c193288470251d0b211e938
|
|
| BLAKE2b-256 |
bd11e640a25f669e9ec2c57e54e33e63a8ec729f0bf31390a7ddeee6c4a9576b
|
File details
Details for the file imap_dispatcher-2.1.0-py3-none-any.whl.
File metadata
- Download URL: imap_dispatcher-2.1.0-py3-none-any.whl
- Upload date:
- Size: 32.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aceeae1d7474bfc4cf88dd87fe17490c3b647b756dea0d5f034900920b407597
|
|
| MD5 |
759a79ed6ddf1a3b08ac1bf629d30d16
|
|
| BLAKE2b-256 |
20c870462b8324a952792315043101c2f81d48a3c9e48d870ff45fdfa46614a3
|