Metadata-Version: 2.4
Name: firstmail-py
Version: 0.1.0
Summary: Async email client for firstmail.ltd — powered by aioimaplib & aiosmtplib.
Author: Yuchan Han
License-Expression: MIT
Project-URL: Homepage, https://github.com/h053698/firstmail-py
Project-URL: Repository, https://github.com/h053698/firstmail-py
Project-URL: Issues, https://github.com/h053698/firstmail-py/issues
Keywords: firstmail,email,imap,smtp,async,asyncio,aioimaplib,aiosmtplib
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
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: Topic :: Communications :: Email
Classifier: Topic :: Communications :: Email :: Post-Office :: IMAP
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: aioimaplib>=1.0.0
Requires-Dist: aiosmtplib>=3.0.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Dynamic: license-file

# firstmail-py

[![PyPI](https://img.shields.io/pypi/v/firstmail-py)](https://pypi.org/project/firstmail-py/)
[![Python](https://img.shields.io/pypi/pyversions/firstmail-py)](https://pypi.org/project/firstmail-py/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

Async email client for [firstmail.ltd](https://firstmail.ltd) — powered by `aioimaplib` & `aiosmtplib`.

Full `async/await` rewrite of [nichind/firstmail](https://github.com/nichind/firstmail) with IMAP IDLE support, OTP extraction, typed exceptions, and a clean Pythonic API.

## Installation

```shell
pip install firstmail-py
```

## Quick Start

```python
import asyncio
from firstmail import FirstMail

async def main():
    async with FirstMail("user@firstmail.ltd", "password") as client:
        # Latest email
        last = await client.get_last_mail()
        if last:
            print(last.subject, last.sender)

        # Inbox count
        print(await client.get_message_count())

        # Fetch newest 10
        emails = await client.get_all_mail(limit=10)

        # Extract OTP code from latest email
        otp = await client.get_otp_code()
        print(f"OTP: {otp}")

asyncio.run(main())
```

## Usage

### Context Manager (recommended)

```python
async with FirstMail("user@firstmail.ltd", "password") as client:
    count = await client.get_message_count()
```

### Manual Resource Management

```python
client = FirstMail("user@firstmail.ltd", "password")
try:
    count = await client.get_message_count()
finally:
    await client.close()
```

### Credential String

```python
client = FirstMail("user@firstmail.ltd:password")
```

### Send Email

```python
async with FirstMail("user@firstmail.ltd", "password") as client:
    await client.send_mail(
        to="recipient@example.com",
        subject="Hello",
        body="World!",
        cc="cc@example.com",
        bcc="bcc@example.com",
    )
```

### Watch for New Emails

Uses IMAP IDLE for real-time push (falls back to polling automatically):

```python
async with FirstMail("user@firstmail.ltd", "password") as client:
    async for email in client.watch_for_new_emails(check_interval=30):
        print(f"New: {email.subject} from {email.sender}")
```

### Wait for Specific Emails

```python
async with FirstMail("user@firstmail.ltd", "password") as client:
    # Wait for any new email (max 60s)
    msg = await client.wait_for_new_mail(timeout=60)

    # Wait for email from a specific sender (substring, case-insensitive)
    msg = await client.wait_for_sender("openai.com", timeout=60)

    # Wait for email with specific subject text
    msg = await client.wait_for_email("verification code", timeout=60)
```

### OTP Code Extraction

```python
async with FirstMail("user@firstmail.ltd", "password") as client:
    # Immediate — parse OTP from current inbox
    code = await client.get_otp_code()                        # "719680"
    code = await client.get_otp_code(sender="openai.com")     # filter by sender

    # Wait mode — wait for new OTP email, then extract
    code = await client.get_otp_code(sender="openai.com", timeout=60)
```

OTP extraction priority:
1. HTML heading tags (`<h1>123456</h1>`)
2. Subject line digits (4–8 digits)
3. Plain-text body digits

### EmailMessage

Every email is returned as an `EmailMessage` dataclass:

```python
msg.subject      # str
msg.sender       # str
msg.recipient    # str
msg.body         # str  (plain text)
msg.html_body    # str | None  (HTML body if present)
msg.date         # str
msg.message_id   # str
msg.raw_message  # bytes | None

msg.to_dict()    # serialize to dict (excludes raw_message)
```

### Exception Handling

All exceptions inherit from `FirstMailError`:

```python
from firstmail import (
    FirstMailError,            # base — catch-all
    FirstMailConnectionError,  # server unreachable
    FirstMailAuthError,        # wrong credentials
    FirstMailSendError,        # send failed
    FirstMailFetchError,       # fetch failed
    FirstMailTimeoutError,     # operation timed out
    FirstMailParseError,       # email parsing failed
)

try:
    async with FirstMail("user@firstmail.ltd", "wrong") as client:
        await client.get_last_mail()
except FirstMailAuthError:
    print("Bad credentials")
except FirstMailTimeoutError:
    print("Timed out")
except FirstMailError as e:
    print(f"Error: {e}")
```

## CLI

```shell
firstmail -e <email> -p <password> read-last
firstmail -e <email> -p <password> read-last --json
firstmail -e <email> -p <password> read-all --limit 10 --full
firstmail -e <email> -p <password> send --to user@example.com --subject "Hi" --body "Hello!"
firstmail -e <email> -p <password> watch --interval 60 --show-body
firstmail -e <email> -p <password> count

# Credential string shortcut
firstmail "user@firstmail.ltd:password" read-last

# Module invocation
python -m firstmail <command>
```

## API Reference

### Constructor

```python
FirstMail(
    address: str,                          # email or "email:password"
    password: str | None = None,
    *,
    use_ssl: bool = True,                  # SSL/TLS (port 993/465)
    imap_host: str = "imap.firstmail.ltd",
    smtp_host: str = "imap.firstmail.ltd",
    imap_port: int | None = None,          # auto: 993 (SSL) / 143
    smtp_port: int | None = None,          # auto: 465 (SSL) / 587
    timeout: float = 30.0,                 # connection timeout (seconds)
)
```

### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `await get_last_mail()` | `EmailMessage \| None` | Most recent email |
| `await get_all_mail(limit=None)` | `list[EmailMessage]` | All emails, newest first |
| `await send_mail(to, subject, body, *, cc, bcc)` | `bool` | Send plain-text email |
| `await get_message_count()` | `int` | Inbox message count |
| `async for msg in watch_for_new_emails(check_interval, *, use_idle)` | `EmailMessage` | Yield new emails as they arrive |
| `await wait_for_new_mail(*, timeout, check_interval)` | `EmailMessage` | Block until a new email arrives |
| `await wait_for_sender(sender, *, timeout, check_interval)` | `EmailMessage` | Block until email from sender arrives |
| `await wait_for_email(title_contains, *, timeout, check_interval)` | `EmailMessage` | Block until email with matching subject arrives |
| `await get_otp_code(*, sender, timeout, check_interval)` | `str \| None` | Extract OTP code (4–8 digits) from latest or next email |
| `await close()` | `None` | Close all connections |

### Exceptions

| Exception | Parent | Raised when |
|-----------|--------|-------------|
| `FirstMailError` | `Exception` | Base for all errors |
| `FirstMailConnectionError` | `FirstMailError` | Cannot connect to IMAP/SMTP server |
| `FirstMailAuthError` | `FirstMailError` | Login credentials rejected |
| `FirstMailSendError` | `FirstMailError` | Email send operation failed |
| `FirstMailFetchError` | `FirstMailError` | Email fetch operation failed |
| `FirstMailTimeoutError` | `FirstMailError` | Operation exceeded timeout |
| `FirstMailParseError` | `FirstMailError` | Raw email bytes could not be parsed |

## Server Info

| Protocol | Host | SSL Port | Non-SSL Port |
|----------|------|----------|--------------|
| IMAP | imap.firstmail.ltd | 993 | 143 |
| POP3 | imap.firstmail.ltd | 995 | 110 |
| SMTP | imap.firstmail.ltd | 465 | 587 |

## License

MIT
