scrippy_mail
SMTP, IMAP and POP3 clients for the 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
pip install scrippy-mail
From source:
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 |
The SMTP client also accepts helo, the local host name sent with HELO/EHLO (default: the local host FQDN).
Notes:
sslandstarttlsare mutually exclusive: setting both raises aScrippyMailError.- 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
withblock. 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).
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
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,ccandbccare lists of RFC 5322 email addresses. At least one of them is required.bccrecipients receive the email but are not visible to the other recipients.attachmentsis a list of file paths. Their MIME type is guessed from their extension.- If some recipients are rejected by the server, a
ScrippyMailErrorlisting them is raised.
IMAP
Every method accepts a mailbox argument (default: "INBOX"). Mailbox names may contain spaces and non-ASCII characters.
Reading emails
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:
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 |
- 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: usemark_message_as_read()ormark_messages_as_read().
Tags
Tags are IMAP flags: system flags (\Seen, \Flagged, \Answered, \Deleted, \Draft) or keywords (e.g. $Processed).
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
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 thandelete_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.
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
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 |
Notes:
- POP3 has no mailbox, tag nor read/unread state: use IMAP for these features.
- Deletions are applied by the server when leaving the
withblock. 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:
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
.emlfiles, 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
python -m pip install -e .[dev]
make lint # pylint
Tests need the test mail server (Postfix and Dovecot in Docker):
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
Metadata
Release files for scrippy-mail 3.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| scrippy_mail-3.0.0.tar.gz | 26.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| scrippy_mail-3.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 45.5 kB
Release files / scrippy_mail-3.0.0.tar.gz
| Download URL | scrippy_mail-3.0.0.tar.gz |
|---|---|
| Size | 26.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c7a64ac03d8fdcaeaa9e9e722c1f81513949f0af40522f94ab893e75d1c4b946
|
|
BLAKE2b-256 checksum How to use checksums |
e71e8d211c9980814d6e133df8c286234b947cf9b1423e572c705e243ed33138
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.17
|
Release files / scrippy_mail-3.0.0-py3-none-any.whl
| Download URL | scrippy_mail-3.0.0-py3-none-any.whl |
|---|---|
| Size | 19.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8a132c32e1583652b47e0d25c61c6f71617666f7300ab6ab1f75d9d10f913546
|
|
BLAKE2b-256 checksum How to use checksums |
daf9ee63dfa52021a9c43583a1999aae10ca3c2966ee9329b0216830c0fc89c7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.17
|