Metadata-Version: 2.4
Name: mailpeek
Version: 0.2.0
Summary: A lightweight IMAP-based email reader for Python/Django
License-Expression: MIT
License-File: LICENSE
Keywords: email,imap,inbox,django,cli,attachment,idle
Author: Anand R Nair
Author-email: anand547@outlook.com
Requires-Python: >=3.9
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Communications :: Email :: Post-Office :: IMAP
Classifier: Typing :: Typed
Requires-Dist: imapclient (>=3.0.1)
Project-URL: Changelog, https://github.com/anandrnair547/mailpeek/blob/main/CHANGELOG.md
Project-URL: Homepage, https://github.com/anandrnair547/mailpeek
Project-URL: Issues, https://github.com/anandrnair547/mailpeek/issues
Project-URL: Repository, https://github.com/anandrnair547/mailpeek
Description-Content-Type: text/markdown

# 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

```bash
poetry add mailpeek
```

Or with pip:

```bash
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](CHANGELOG.md#migration) 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

```python
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

```python
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:

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

### Only images:

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

To keep only the emails that actually have a match:

```python
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()`:

```python
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:

```python
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)

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```bash
poetry add mailpeek
```

Run with:

```bash
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:

```bash
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:

```bash
--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

```bash
poetry install
poetry run pytest
```

To run the suite against every supported interpreter locally:

```bash
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](https://pypi.org/manage/account/token/) — scope it
to the `mailpeek` project rather than the whole account — then store it:

```bash
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:

```bash
# 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:

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

## Changelog

See [CHANGELOG.md](CHANGELOG.md).

## License

MIT — see [LICENSE](LICENSE).

