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.0drops the unmaintainedpyzmail36dependency and parses MIME with the standard library instead. The object handed to yourIMAPIdleListenercallback is now amailpeek.Messagerather than apyzmail.PyzMessage— but its API is identical, so almost all code needs no changes. Rungrep -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 in0.1.1.attachments[].part_idis now anint, andlimit=0now 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.pycommand that callsfetch_unread() - Use
get_attachment_stream()to save files intoFileField - 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.
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 mailpeek-0.2.0.tar.gz.
File metadata
- Download URL: mailpeek-0.2.0.tar.gz
- Upload date:
- Size: 23.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
poetry/2.4.1 CPython/3.14.4 Darwin/25.5.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a334867065c879534463d756a85fc269b0ddf7de44fdeb94603feb69fe29216a
|
|
| MD5 |
0c3cd0f9c25e7443df101b158c2450ad
|
|
| BLAKE2b-256 |
3039a8fa6a25f989b81f8c2a1b93face9f0ce585c4508cfc324717ee67fb45ec
|
File details
Details for the file mailpeek-0.2.0-py3-none-any.whl.
File metadata
- Download URL: mailpeek-0.2.0-py3-none-any.whl
- Upload date:
- Size: 14.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
poetry/2.4.1 CPython/3.14.4 Darwin/25.5.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2f360171ca701248365ed46bac2f7a8aa36616ad46f0b818463a33ddaa863ca5
|
|
| MD5 |
750d37c92cdccf83a0e091e68a031b28
|
|
| BLAKE2b-256 |
a4a1077462bea4b49475976e39a47563a640f84143ef4bf282a9da4ca94300aa
|