Metadata-Version: 2.4
Name: scrippy-mail
Version: 3.0.0
Summary: Mail client (POP, IMAP, SMTP) for the Scrippy Python scripting framework
Home-page: https://codeberg.org/scrippy/scrippy-mail
Author: Michael Costa, Florent Chevalier
Author-email: michael.costa@mcos.nc, florent.chevalier.nc@gmail.com
License: MIT
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Environment :: Console
Classifier: Development Status :: 5 - Production/Stable
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: twine; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: wheel>=0.37.0; extra == "dev"
Requires-Dist: flake8>=4.0.1; extra == "dev"
Requires-Dist: flake8-breakpoint>=1.1.0; extra == "dev"
Requires-Dist: flake8-builtins>=1.5.3; extra == "dev"
Requires-Dist: flake8-print>=4.0.0; extra == "dev"
Requires-Dist: flake8-return>=1.1.3; extra == "dev"
Requires-Dist: pep8-naming>=0.8.2; extra == "dev"
Requires-Dist: pytest>=6.2.2; extra == "dev"
Requires-Dist: pytest-cov>=3.0.0; extra == "dev"
Requires-Dist: bump2version>=1.0.1; extra == "dev"
Requires-Dist: setuptools>=60.9.2; extra == "dev"
Requires-Dist: pypirc-chappers>=1.0.8; extra == "dev"
Requires-Dist: pylint>=4.1; extra == "dev"
Dynamic: license-file


![Build Status](https://drone-ext.mcos.nc/api/badges/scrippy/scrippy-mail/status.svg) ![License](https://img.shields.io/static/v1?label=license&color=orange&message=MIT) ![Language](https://img.shields.io/static/v1?label=language&color=informational&message=Python)

![Scrippy, my scrangourou friend](./scrippy-mail.png "Scrippy, my scrangourou friend")

# `scrippy_mail`

SMTP, IMAP and POP3 clients for the [`Scrippy`](https://codeberg.org/scrippy) framework.

| Client | Purpose |
|---|---|
| `scrippy_mail.smtp.Client` | Send emails |
| `scrippy_mail.imap.Client` | Read, tag, delete and save emails, browse mailboxes |
| `scrippy_mail.pop.Client` | Read, delete and save emails |

## Requirements

- Python >= 3.11
- No third-party module

## Installation

```bash
pip install scrippy-mail
```

From source:

```bash
git clone https://codeberg.org/scrippy/scrippy-mail.git
cd scrippy-mail
make install
```

## Common parameters

All clients accept the following parameters:

| Parameter | Default | Description |
|---|---|---|
| `host` | *required* | Mail server name |
| `port` | `25` (SMTP), `143` (IMAP), `110` (POP3) | Mail server port |
| `username` | *required* (IMAP, POP3), `None` (SMTP) | Authentication user name. SMTP does not authenticate when `None` |
| `password` | *required* (IMAP, POP3), `None` (SMTP) | Authentication password |
| `ssl` | `False` | Use an encrypted connection from the start (usually ports `465`, `993`, `995`) |
| `starttls` | `False` | Upgrade the connection to an encrypted one with `STARTTLS` (usually ports `587`, `143`, `110`) |
| `ssl_verify` | `True` | Verify the server certificate and host name |
| `timeout` | `60` | Connection timeout in seconds |
| `exit_on_error` | `True` | See [Error handling](#error-handling) |

The SMTP client also accepts `helo`, the local host name sent with `HELO`/`EHLO` (default: the local host FQDN).

**Notes:**
- `ssl` and `starttls` are mutually exclusive: setting both raises a `ScrippyMailError`.
- Encrypted connections require TLS 1.2 or later and forward secrecy cipher suites. Servers only supporting older protocols or ciphers (SSLv3, TLS 1.0/1.1, 3DES, RSA key exchange) are refused.
- Clients are context managers: the connection is closed when leaving the `with` block. The SMTP client connects when entering the block, the IMAP and POP3 clients on their first operation.

## Error handling

Operations raise `scrippy_mail.ScrippyMailError` on failure. Connection errors (network, TLS, authentication) are raised as is (e.g. `ConnectionRefusedError`, `ssl.SSLError`, `smtplib.SMTPAuthenticationError`, `imaplib.IMAP4.error`).

When `exit_on_error` is `False`:
- Connection errors are logged as warnings instead of being raised. Any IMAP or POP3 operation then raises a `ScrippyMailError` (`[ClientConnectionError] Not connected to ...`).
- SMTP sending errors are logged as warnings instead of being raised.

Messages are logged with `scrippy_mail.logger` (the `scrippy.main` logger).

```python
from scrippy_mail import ScrippyMailError, logger
from scrippy_mail.imap import Client as ImapClient

try:
  with ImapClient(host="imap.flying.circus", port=993, ssl=True,
                  username="harry.fink@flying.circus", password="D3ADP4ARR0T") as imap:
    logger.info(f"{imap.get_message_count()} emails in INBOX")
except ScrippyMailError as err:   # Operation error
  logger.error(err)
  raise
except Exception as err:          # Connection error
  logger.critical(err)
  raise
```

## SMTP

```python
from scrippy_mail.smtp import Client as SmtpClient

with SmtpClient(host="smtp.flying.circus", port=465, ssl=True,
                username="luiggi.vercotti@flying.circus", password="D3ADP4ARR0T") as smtp:
  smtp.send(subject="Error report",
            body="The script ended with the following error:\n- This parrot is no more!",
            sender="luiggi.vercotti@flying.circus",
            recipients=["harry.fink@flying.circus"],
            cc=["arthur.pewty@flying.circus"],
            bcc=["audit@flying.circus"],
            attachments=["/tmp/report.csv"])
```

- `recipients`, `cc` and `bcc` are lists of [RFC 5322](https://tools.ietf.org/html/rfc5322.html) email addresses. At least one of them is required.
- `bcc` recipients receive the email but are not visible to the other recipients.
- `attachments` is a list of file paths. Their MIME type is guessed from their extension.
- If some recipients are rejected by the server, a `ScrippyMailError` listing them is raised.

## IMAP

Every method accepts a `mailbox` argument (default: `"INBOX"`). Mailbox names may contain spaces and non-ASCII characters.

### Reading emails

```python
from scrippy_mail.imap import Client as ImapClient

with ImapClient(host="imap.flying.circus", port=993, ssl=True,
                username="harry.fink@flying.circus", password="D3ADP4ARR0T") as imap:
  for number in imap.get_unread_messages_numbers():
    message = imap.get_message(number=number)   # Email as a string
    imap.save_message(number=number, filename=f"/tmp/message-{int(number)}.eml")
    imap.mark_message_as_read(number=number)   # Reading does not mark as read
```

All emails at once:

```python
with ImapClient(host="imap.flying.circus", port=993, ssl=True,
                username="harry.fink@flying.circus", password="D3ADP4ARR0T") as imap:
  messages = imap.get_messages()   # Emails as strings
  imap.mark_messages_as_read()     # Reading does not mark as read
```

| Method | Returns |
|---|---|
| `get_message_count()` | Number of emails |
| `get_messages_numbers(tags=None, inverse=False)` | Numbers of the emails having any of `tags` (none of them if `inverse` is `True`), all emails if `tags` is `None` |
| `get_unread_messages_numbers()` / `get_read_messages_numbers()` | Numbers of the unread / read emails |
| `get_message(number)` | Email as a string |
| `get_messages(tags=None, inverse=False)` | Emails as strings, filtered like `get_messages_numbers()` |
| `save_message(number, filename)` | See [Saving emails](#saving-emails) |

- Emails are decoded as UTF-8, or using their declared charset otherwise.
- Reading an email (`get_message()`, `get_messages()`, `save_message()`) does not mark it as read: use `mark_message_as_read()` or `mark_messages_as_read()`.

### Tags

Tags are IMAP flags: system flags (`\Seen`, `\Flagged`, `\Answered`, `\Deleted`, `\Draft`) or keywords (e.g. `$Processed`).

```python
with ImapClient(host="imap.flying.circus", port=993, ssl=True,
                username="harry.fink@flying.circus", password="D3ADP4ARR0T") as imap:
  # Emails not processed yet
  for number in imap.get_messages_numbers(tags=["$Processed"], inverse=True):
    imap.tag_message(number=number, tags=["$Processed", "\\Flagged"])
  imap.untag_messages(tags=["\\Flagged"])   # On every email of the mailbox
```

| Method | Action |
|---|---|
| `tag_message(number, tags)` / `untag_message(number, tags)` | Add / remove tags on an email |
| `tag_messages(tags)` / `untag_messages(tags)` | Add / remove tags on every email |
| `mark_message_as_read(number)` / `mark_message_as_unread(number)` | Add / remove `\Seen` on an email |
| `mark_messages_as_read()` / `mark_messages_as_unread()` | Add / remove `\Seen` on every email |

**Note:** the former `untag_messages(mailbox, tags)` arguments order still works but is deprecated.

### Deleting emails

```python
with ImapClient(host="imap.flying.circus", port=993, ssl=True,
                username="harry.fink@flying.circus", password="D3ADP4ARR0T") as imap:
  imap.delete_message(number=1)
  imap.delete_messages(tags=["$Processed"])           # Emails having the tag
  imap.delete_messages(mailbox="Trash")               # Every email of a mailbox
```

- Deletion is immediate and only affects the specified emails: emails flagged as deleted by another client are left untouched.
- Deleting an email renumbers the following ones. To delete several emails, use `delete_messages()` rather than `delete_message()` in a loop.

### Mailboxes

`list_mailbox(mailbox)` returns the full names of all the sub-mailboxes of a mailbox, subscribed or not, at any depth. The hierarchy delimiter depends on the server.

```python
with ImapClient(host="imap.flying.circus", port=993, ssl=True,
                username="harry.fink@flying.circus", password="D3ADP4ARR0T") as imap:
  for name in imap.list_mailbox("INBOX"):          # e.g. ["INBOX.Projects", "INBOX.Projects.Été 2026"]
    print(name, imap.get_message_count(mailbox=name))
```

Using a mailbox that does not exist raises a `ScrippyMailError`.

## POP3

```python
from scrippy_mail.pop import Client as PopClient

with PopClient(host="pop.flying.circus", port=995, ssl=True,
               username="harry.fink@flying.circus", password="D3ADP4ARR0T") as pop:
  for number in pop.get_messages_numbers():
    message = pop.get_message(number=number)   # Email as a string
    pop.save_message(number=number, filename=f"/tmp/message-{number}.eml")
    pop.delete_message(number=number)
```

| Method | Returns / Action |
|---|---|
| `get_message_count()` | Number of emails |
| `get_messages_numbers()` | Numbers of the emails |
| `get_message(number)` / `get_all_messages()` | Email / all emails as strings |
| `delete_message(number)` / `delete_all_messages()` | Delete an email / all emails |
| `save_message(number, filename)` | See [Saving emails](#saving-emails) |

**Notes:**
- POP3 has no mailbox, tag nor read/unread state: use IMAP for these features.
- Deletions are applied by the server when leaving the `with` block. Email numbers do not change until then.

## Saving emails

`save_message(number, filename)` (IMAP and POP3) writes the email as is to `filename` and its attachments to the `<filename>.attachments/` directory:

```python
result = imap.save_message(number=1, filename="/tmp/message.eml")
# {"filename": "/tmp/message.eml",
#  "attachments": ["/tmp/message.eml.attachments/report.pdf",
#                  "/tmp/message.eml.attachments/report-1.pdf",
#                  "/tmp/message.eml.attachments/attachment-3.png",
#                  "/tmp/message.eml.attachments/forwarded.eml"]}
```

- Attachments with the same name are suffixed (`report-1.pdf`).
- Unnamed attachments are named `attachment-<n>` with an extension matching their type.
- Attached emails are saved as `.eml` files, their own attachments left inside.
- Email bodies are not saved as attachments, but named inline parts (e.g. embedded images) are.
- The attachments directory is only created if the email has attachments.

## Development

```bash
python -m pip install -e .[dev]
make lint     # pylint
```

Tests need the test mail server (Postfix and Dovecot in Docker):

```bash
cd tests/docker && make build && make run && cd ../..
MAIL_HOST=localhost python -m pytest tests/        # All tests
python -m pytest -m "not integration" tests/       # Unit tests only, no server needed
cd tests/docker && make stop
```
