Skip to main content

IMAP Thingy

An IMAP scripting library, primarily to make email filters.

Ever complained about receiving the same emails on multiple inboxes? Or wanted to auto-archive receipts that you've already opened? Or wanted your emails to get sorted automatically in some other way?

Well, complain no more! (or at least not for this reason)

IMAP Thingy is a library that lets its users create advanced IMAP filters. Possibly on multiple emails, possibly even on multiple accounts.

Installation

pip install imap-thingy

or, for the unreleased state of main:

pip install git+https://github.com/sgorblex/imap-thingy.git

Usage and examples

Here is a simple example which loads credentials from an accounts.json file that looks something like this:

[
  {
    "name": "beautiful gmail account",
    "type": "gmail",
    "username": "myaddress@gmail.com",
    "password": "ana ppp asw ord"
  },
  {
    "name": "beautiful custom account",
    "host": "mydomain.com",
    "port": 993,
    "username": "its.a.me@mydomain.com",
    "password": "my_super_secret_pw"
  }
]

At the moment we do not support OAUTH (PRs welcome :), so if you're using something like GMail you will have to generate an app-password.

As for the main script, which simply applies some filters (probably the most useful):

# my_beautiful_script.py

from imap_thingy.accounts import Path, accounts_from_json
from imap_thingy.filters import Filter, If, FromIs, SubjectMatches, ToIs, MoveTo, MarkAsRead

from dmarc import dmarc_pairs

import argparse

def main():
    parser = argparse.ArgumentParser(description="imap_filters: my personal IMAP filters")
    parser.add_argument("--dry-run", action="store_true", help="Print actions instead of executing them")
    args = parser.parse_args()

    accounts = accounts_from_json("accounts.json")
    gmail = accounts["beautiful gmail account"]
    custom = accounts["beautiful custom account"]

    pairs = [
        # In account "beautiful gmail account", move all mail directed to "members@boringassociation.org" to "Boring Association" folder (marking as read first)
        (gmail / "INBOX", Filter(ToIs("members@boringassociation.org"), MarkAsRead() + MoveTo(Path("Boring Association")))),
        # In account "beautiful custom account", move all mail from "googledev-noreply@google.com" to "Dev Stuff.Google Developer Program" folder. Note that folder delimiter may differ between servers
        (custom / "INBOX", Filter(FromIs("googledev-noreply@google.com"), MarkAsRead() + MoveTo(Path("Dev Stuff.Google Developer Program")))),
        # A filter based on a complex criterion and a series of actions
        (gmail / "INBOX", If(FromIs("list4nerds@nerduniversity.edu") & SubjectMatches(r"List Digest, Vol \d+")).then(MarkAsRead() + MoveTo(Path("List For Nerds")))),
    ]
    pairs.extend(dmarc_pairs(custom, "dmarcreport@microsoft.com", "Postmaster.DMARC Reports"))

    for folder, f in pairs:
        folder.run(f, dry_run=args.dry_run)


if __name__ == "__main__":
    main()

The important parts are accounts_from_json, pairs (folder + filter), and looping over pairs and calling folder.run(f, ...). Folders are obtained from an account (e.g. account / "INBOX", account.inbox). For type hints or direct use, import Folder from imap_thingy.accounts. Account(..., ssl_context=ctx) connects with your own ssl.SSLContext (e.g. one trusting a private CA), Account(..., timeout=30) (or "timeout": 30 in accounts.json) bounds the connection and each socket read or write to that many seconds instead of waiting forever, and account.list_folders() returns the Path of every folder that can hold mail. Criteria that can be expressed purely in IMAP (e.g. FromIs, SubjectContains, BodyContains) use server-side search only; criteria that need body parsing (e.g. SubjectMatches with regex, or BodyMatches which searches decoded plain/HTML body text anywhere, with . matching newlines) use a per-run fetch cache to minimize redundant fetches within a run(), but messages that have had actions applied may be evicted from the cache and fetched again if they are re-selected later in the same run().

Regex criteria (*Matches) take Python re syntax, but are compiled when the criterion is built (an invalid pattern raises ValueError) and matched with the regex package under a time limit, so a pathological pattern cannot hang a run: a single match taking longer than DEFAULT_MATCH_TIMEOUT (1 s) raises MatchTimeoutError (a TimeoutError), which fails that filter's run like any other error. Change the limit process-wide with imap_thingy.filters.set_match_timeout(seconds) (None for no limit).

Note: The library no longer configures logging automatically. The previously documented imap_thingy.logging.setup_logging() helper is kept only as a deprecated compatibility shim and should not be used in new code. Instead, configure logging explicitly in your application near the entry point (for example with logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")) so that loggers under imap_thingy emit to your handlers.

Arbitrarily complex filters can be implemented in Python, likely via imapclient and/or mailparser, if not directly via our bindings. For example, here is a custom helper that returns (folder, filter) pairs to automatically move DMARC reports, while first trashing the previews:

# dmarc.py

from imap_thingy.accounts import Account, Path
from imap_thingy.filters import If, FromIs, SubjectMatches, MoveTo, Trash

def dmarc_pairs(account: Account, sender: str, folder: str, delete_preview: bool = True, base_folder: str = "INBOX"):
    f = account / base_folder
    pairs = []
    if delete_preview:
        pairs.append((f, If(FromIs(sender) & SubjectMatches("[Preview] .*")).then(Trash())))
    pairs.append((f, If(FromIs(sender)).then(MoveTo(Path(folder)))))
    return pairs

Running rules

imap_thingy.runner runs filters grouped by folder (Rules, a list of (folder, [filters])), either once or continuously through IMAP IDLE:

from imap_thingy.runner import monitor, run_rules, starred_only

rules = [(gmail / "INBOX", [f1, f2]), (custom / "INBOX", [f3])]
run_rules(rules, dry_run=False)                 # one folder after the other
run_rules(rules, dry_run=False, parallel=True)  # one thread per folder; failures raised together at the end
monitor(rules, dry_run=False)                   # run each folder's filters on new mail, until SIGTERM/SIGINT

monitor installs SIGTERM/SIGINT handlers, so call it from the main thread; to run it in another thread (e.g. inside a worker), pass install_signal_handlers=False and a threading.Event to stop it: monitor(rules, dry_run=False, stop=stop, install_signal_handlers=False) returns about a second after stop.set(), once any filters already running finish. Handlers it installs are restored on return. starred_only(rules) restricts every filter to starred messages and unstars them before the action, so you can sort mail by starring it.

folder.run(...) and run_rules(...) return reports (imap_thingy.report), plain JSON-compatible dicts: per folder its account, folder (path segments), dry_run and error and, per filter, its spec (null for filters with no data form), action, the matched messages (uid, from, subject, date in UTC) and whether the action was executed. A dry run selects the folder read-only and executes nothing:

report = (gmail / "INBOX").run([f1, f2], dry_run=True)
for f in report["filters"]:
    print(f["spec"], [m["subject"] for m in f["matched"]])

With parallel=True, a failure raises RunRulesError (a RuntimeError) once every folder has run; its reports hold every folder's report, the failed ones with error set and the filters that finished before it.

Rules as data

Filters built from the library's criteria and actions can be turned into JSON-compatible data and back, e.g. to keep rules in a file or edit them from another program:

import json
from imap_thingy.spec import load_filter

f = If(FromIs("list4nerds@nerduniversity.edu") & ~IsRead()).then(MarkAsRead() + MoveTo(Path(["Lists", "Nerds"])))
data = json.dumps(f.to_spec())
assert load_filter(json.loads(data)) == f
{
  "criterion": {"and": [{"type": "from_is", "args": {"addr": "list4nerds@nerduniversity.edu"}}, {"not": {"type": "is_read"}}]},
  "action": {"seq": [{"type": "mark_as_read"}, {"type": "move_to", "args": {"folder": ["Lists", "Nerds"]}}]}
}

A leaf is {"type": ..., "args": {...}}: the class name in snake_case and its constructor arguments by name. Folder paths are lists of segments, flags are Flag names ("SEEN"), dates are ISO 8601. OlderThan(date) takes a fixed date; OlderThanDays(n) ({"type": "older_than_days", "args": {"days": 30}}) counts back from the day the rule runs. &, |, ~ become and, or, not, and + becomes seq. load_criterion and load_action load the parts (a malformed spec, including an invalid regex, raises SpecError), and imap_thingy.spec.json_schema() returns the JSON Schema of the format (shipped as imap_thingy/spec/schema.json). Criteria and actions built from your own functions have no data form: to_spec() raises NotSerializableError. Equality and repr() follow the data form, so FromIs("a") == FromIs("a").

Development

Tooling is pinned with mise; run everything through mise run <task>:

Task What it does
setup Install Python dependencies into .venv
fix Ruff auto-fix and format, then mypy
check Ruff lint and format check, then mypy
test Run the test suite; extra args go to pytest (mise run test -- tests/test_criteria.py)
build Build sdist and wheel
clean / clean-all Remove caches and build outputs (clean-all also removes .venv)

Releases and CHANGELOG.md are automated by Release Please from Conventional Commit squash titles; dependency updates come from Renovate.

Contributing

Feel free to raise issues or open pull requests! Branch, commit and PR conventions are in AGENTS.md.

License

GPL v.3 or later

Metadata

Release files for imap-thingy 0.7.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 imap-thingy 0.7.0
File Size Uploaded Provenance
imap_thingy-0.7.0.tar.gz 69.8 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for imap-thingy 0.7.0
File Interpreter ABI Platform
imap_thingy-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 128.7 kB

Release files / imap_thingy-0.7.0.tar.gz

Download URL imap_thingy-0.7.0.tar.gz
Size 69.8 kB
Tags Source
SHA-256 checksum
How to use checksums
40a4a0fd2948d9440526417b15f0606c53ccfb8586a14b0c67a4d7dcee7638cc
BLAKE2b-256 checksum
How to use checksums
f2c7b6daefa61f8b425247ab4a365c888423b63d88bc5043454c0f3163f2875d
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 23, 2026.

Transparency log

Release files / imap_thingy-0.7.0-py3-none-any.whl

Download URL imap_thingy-0.7.0-py3-none-any.whl
Size 58.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ad0561f45e2702679149ff24c183da050a3e8aefc6ab696c8546780ea84916d2
BLAKE2b-256 checksum
How to use checksums
41e5d1e7402208ddb2051f32a43c34e0a33ea45abc176e1090afc11e228650cc
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 23, 2026.

Transparency log

Security for imap-thingy 0.7.0

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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for imap_thingy-0.7.0.tar.gz

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 23, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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