Skip to main content

Build Status License Language

Scrippy, my scrangourou friend

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:

  • 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).

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, cc and bcc are lists of RFC 5322 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

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: 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).

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 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.

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

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

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.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for scrippy-mail 3.0.1
File Size Uploaded
scrippy_mail-3.0.1.tar.gz 26.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for scrippy-mail 3.0.1
File Interpreter ABI Platform
scrippy_mail-3.0.1-py3-none-any.whl Python 3 none any Details

Total release size: 46.0 kB

Release files / scrippy_mail-3.0.1.tar.gz

Download URL scrippy_mail-3.0.1.tar.gz
Size 26.6 kB
Tags Source
SHA-256 checksum
How to use checksums
9630f9a79e17acb298492dc91b859b489a228b1f938f45baeb79686a9e97bce2
BLAKE2b-256 checksum
How to use checksums
dc3e0b9f9bff8fa35c2162ef73bca6dc46ea623dcdb36a3397d796cc036f5c31
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.1-py3-none-any.whl

Download URL scrippy_mail-3.0.1-py3-none-any.whl
Size 19.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6f261c672ac3fe29b6ba0d9e0ffe4817f69b3b2498a708c5af5dc2bc648b1c20
BLAKE2b-256 checksum
How to use checksums
48b23659c36d5a678e61144667efbea76a6beb39c01b5a21f72e28db9cf5760c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.17

Release history Release notifications | RSS feed

This release

3.0.1 This release

2 release files

3.0.0

2 release files

2.0.2

2 release files

2.0.1

2 release files

2.0.0

2 release files

1.1.76

2 release files

1.1.72

2 release files

1.1.71

2 release files

1.1.70

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page