Skip to main content

mailpeek

A lightweight Python library for reading unread emails via IMAP.

Supported Python versions

Tested on CPython 3.9, 3.10, 3.11, 3.12, 3.13 and 3.14 — the test suite runs against every one of them on each push.

Installation

poetry add mailpeek

Or with pip:

pip install mailpeek

mailpeek has a single runtime dependency: imapclient. MIME parsing uses the Python standard library.

Upgrading to 0.2.0? 0.2.0 drops the unmaintained pyzmail36 dependency and parses MIME with the standard library instead. The object handed to your IMAPIdleListener callback is now a mailpeek.Message rather than a pyzmail.PyzMessage — but its API is identical, so almost all code needs no changes. Run grep -rn "pyzmail" your_project/: no hits means the upgrade is a no-op for you. If there are hits, the migration guide covers both cases in full.

Coming from 0.1.0? The attachment pipeline could not succeed at all in 0.1.0; it was repaired in 0.1.1. attachments[].part_id is now an int, and limit=0 now means zero rather than unlimited.

Basic Usage

from mailpeek.reader import EmailReader

reader = EmailReader(
    host="imap.gmail.com",
    email="your-email@gmail.com",
    password="your-app-password"
)

emails = reader.fetch_unread()
for mail in emails:
    print(mail["subject"], mail["from"])

Fetch All Emails with Limit

emails = reader.fetch_emails(unread_only=False, limit=10)

Filter Attachments

These filters narrow the attachments list within each email. They do not filter which emails are returned — an email with no matching attachment still comes back, with an empty attachments list.

Only PDFs:

emails = reader.fetch_unread(attachment_filename_contains=".pdf")

Only images:

emails = reader.fetch_unread(attachment_mime_startswith="image/")

To keep only the emails that actually have a match:

emails = [m for m in reader.fetch_unread(attachment_mime_startswith="image/") if m["attachments"]]

Fetch Attachments On-Demand

part_id is an integer index into the message's parts. Pass it straight back to get_attachment_stream():

for mail in emails:
    for att in mail["attachments"]:
        stream = reader.get_attachment_stream(mail["uid"], att["part_id"])
        with open(att["filename"], "wb") as f:
            f.write(stream.read())

Each call opens its own IMAP connection. To download several attachments over a single connection, use the reader as a context manager:

with reader:
    for mail in reader.fetch_unread():
        for att in mail["attachments"]:
            stream = reader.get_attachment_stream(mail["uid"], att["part_id"])
            with open(att["filename"], "wb") as f:
                f.write(stream.read())

Use with IMAP IDLE (Real-Time Mail Listener)

from mailpeek.imap_idle_listener import IMAPIdleListener

def on_new_mail(msg):
    print("\n📥 New email:", msg.get_subject())

def on_disconnect(error):
    print(f"🔌 Disconnected: {error}")

listener = IMAPIdleListener(
    host="imap.gmail.com",
    email="your-email@gmail.com",
    password="your-app-password",
    callback=on_new_mail,
    on_disconnect=on_disconnect,
)

listener.start()

If the connection drops, the listener calls on_disconnect(error), waits reconnect_delay seconds (default 10), and rebuilds the connection. Each message is handed to the callback exactly once. An exception raised inside your callback is logged and skipped — it won't kill the listener.

To stop listening:

listener.stop()

stop() blocks until the background thread has exited. Because idle_check() can be mid-wait, this may take up to idle_timeout seconds (default 300); lower idle_timeout if you need faster shutdown.

The Message object

Your IDLE callback receives a mailpeek.Message. It subclasses email.message.Message, so the whole standard-library API works, plus these conveniences:

from mailpeek import Message

msg = Message.factory(raw_bytes)      # bytes, str, file, or email.message.Message

msg.get_subject()                     # decoded subject, RFC 2047 handled
msg.get_addresses("from")             # [(display_name, address), ...]
msg.get_address("from")               # just the first, or ('', '')
msg.text_part                         # MailPart or None
msg.html_part                         # MailPart or None
msg.mailparts                         # every part: bodies, inline images, attachments

msg["Date"]                           # stdlib API still available
for part in msg.walk(): ...

Each MailPart exposes:

part.filename            # decoded filename, or None
part.sanitized_filename  # safe to write to disk (illegal chars stripped)
part.type                # 'application/pdf'
part.charset             # declared charset, or None
part.is_body             # 'text/plain', 'text/html', or False
part.disposition         # 'inline', 'attachment', or None
part.content_id          # for cid: references, or None
part.get_payload()       # transfer-decoded bytes -- takes NO arguments

For a text part, decode with its charset:

text = part.get_payload().decode(part.charset or "utf-8", errors="replace")

Django Integration

  • Create a management/commands/read_emails.py command that calls fetch_unread()
  • Use get_attachment_stream() to save files into FileField
  • Run via cron or Celery

CLI Usage

Install with:

poetry add mailpeek

Run with:

poetry run mailpeek --email your-email@gmail.com

You'll be prompted for the password. To avoid the prompt in scripts, use the env var:

export MAILPEEK_PASSWORD='your-app-password'
poetry run mailpeek --email your-email@gmail.com

--password still works, but avoid it: command-line arguments are visible to other users on the machine via ps, and land in your shell history.

Optional:

--all              # Fetch read + unread
--limit 20         # Only get 20 emails
--filename .pdf    # Only attachments with .pdf in name
--mime image/      # Only attachments starting with MIME image/
--timeout 30       # Socket timeout in seconds

Development

poetry install
poetry run pytest

To run the suite against every supported interpreter locally:

for v in 3.9 3.10 3.11 3.12 3.13 3.14; do
  uv venv --python $v ".venv-$v" && \
  uv pip install --python ".venv-$v/bin/python" imapclient pyzmail36 pytest && \
  ".venv-$v/bin/python" -m pytest -q
done

The parity suite (tests/test_message_parity.py) checks the MIME parser against pyzmail36, the dependency it replaced. pyzmail36 is a dev-only dependency — it is never installed for end users — and the suite skips itself if it is absent.

Releasing to PyPI

Authenticate once. Mint a token at pypi.org/manage/account/token — scope it to the mailpeek project rather than the whole account — then store it:

poetry config pypi-token.pypi pypi-AgEIcHlwaS5vcmc...

Poetry keeps this in ~/.config/pypoetry/auth.toml (or your OS keyring), so you never pass the token on the command line, where it would land in shell history. If you'd rather not persist it, export POETRY_PYPI_TOKEN_PYPI instead.

To cut a release:

# 1. bump the version in pyproject.toml and src/mailpeek/__init__.py
# 2. add the release notes to CHANGELOG.md
poetry run pytest          # must be green
poetry build               # writes dist/*.whl and dist/*.tar.gz
poetry publish

A version can never be reused on PyPI once uploaded. To rehearse the upload against a throwaway index first:

poetry config repositories.testpypi https://test.pypi.org/legacy/
poetry config pypi-token.testpypi pypi-...
poetry publish --repository testpypi

Changelog

See CHANGELOG.md.

License

MIT — see LICENSE.

Metadata

Release files for mailpeek 0.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 mailpeek 0.2.0
File Size Uploaded Provenance Actions
mailpeek-0.2.0.tar.gz 23.1 kB Without provenance Details for mailpeek-0.2.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for mailpeek 0.2.0
File Interpreter ABI Platform Actions
mailpeek-0.2.0-py3-none-any.whl Python 3 none any Details for mailpeek-0.2.0-py3-none-any.whl

Total release size: 37.8 kB

Release files / mailpeek-0.2.0.tar.gz

Download URL mailpeek-0.2.0.tar.gz
Size 23.1 kB
Tags Source
SHA-256 checksum
How to use checksums
a334867065c879534463d756a85fc269b0ddf7de44fdeb94603feb69fe29216a
BLAKE2b-256 checksum
How to use checksums
3039a8fa6a25f989b81f8c2a1b93face9f0ce585c4508cfc324717ee67fb45ec
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via poetry/2.4.1 CPython/3.14.4 Darwin/25.5.0

Release files / mailpeek-0.2.0-py3-none-any.whl

Download URL mailpeek-0.2.0-py3-none-any.whl
Size 14.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2f360171ca701248365ed46bac2f7a8aa36616ad46f0b818463a33ddaa863ca5
BLAKE2b-256 checksum
How to use checksums
a4a1077462bea4b49475976e39a47563a640f84143ef4bf282a9da4ca94300aa
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via poetry/2.4.1 CPython/3.14.4 Darwin/25.5.0

Security for mailpeek 0.2.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.

No provenance found

None of the files in this release were uploaded with provenance metadata.

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