Skip to main content

Modern sync & async SMTP and IMAP for Python: TLS/SSL, OAuth2 XOAUTH2, Jinja2 templates, attachments, and high-volume bulk sending.

Project description

MailToolsBox

PyPI version Python versions CI License: MIT

MailToolsBox is a modern, pragmatic email toolkit for Python. It gives you clean, production‑grade SMTP sending and a capable IMAP client in one package. The design favors explicit security controls, sane defaults, and simple APIs that scale from quick scripts to services.


What you get

  • SMTP sender with sync and async APIs
  • IMAP client with search, fetch, flags, move, delete, export
  • Security modes: auto, starttls, ssl, none
  • Optional OAuth2 XOAUTH2 for both SMTP and IMAP
  • Gmail and Exchange Online presets
  • Jinja2 templates with auto plain text fallback
  • MIME smart attachment handling
  • High-volume bulk sending: connection reuse, retry with exponential backoff, rate limiting, and bounded async concurrency
  • Structured exception hierarchy (MailToolsBoxError and subclasses)
  • Fully typed (py.typed) for editor and mypy support
  • Optional email validation (opt-in extra)
  • Environment variable configuration
  • Backward compatibility shims SendAgent / ImapAgent

Install

pip install MailToolsBox

# Optional: include address validation support
pip install "MailToolsBox[validation]"

Quick start

Send a basic email

from MailToolsBox import EmailSender

sender = EmailSender(
    user_email="you@example.com",
    server_smtp_address="smtp.example.com",
    user_email_password="password",
    port=587,                        # typical for STARTTLS
    security_mode="starttls"         # or "auto"
    # validation is off by default to keep dependencies light;
    # enable with validate_emails=True after installing the [validation] extra
)

sender.send(
    recipients=["to@example.com"],
    subject="Hello",
    message_body="Plain text body"
)

Read emails

from MailToolsBox import ImapClient

with ImapClient(
    email_account="you@example.com",
    password="password",
    server_address="imap.example.com",
    port=993,
    security_mode="ssl"
) as imap:
    imap.select("INBOX")
    uids = imap.search("UNSEEN")
    messages = imap.fetch_many(uids[:10])
    for m in messages:
        print(m.subject, m.from_[0].email if m.from_ else None)

All public classes are importable directly from the top-level package: from MailToolsBox import EmailSender, ImapClient, SecurityMode, RetryPolicy.


SMTP in depth

Security modes

  • auto:
    • If port is 465 use implicit SSL.
    • Otherwise attempt STARTTLS if the server advertises it. If not available, stay plain.
  • starttls: force STARTTLS upgrade.
  • ssl: implicit SSL on connect, typical for port 465.
  • none: no TLS. Use only inside trusted networks.

You can also pass use_tls=True to force STARTTLS regardless of the configured security_mode (or use_tls=False to force plaintext for trusted relays).

Gmail and Exchange recipes

# Gmail with app password
sender = EmailSender.for_gmail_app_password("you@gmail.com", "abcd abcd abcd abcd")
sender.send(["to@example.com"], "Hi", "Body")

# Exchange Online with SMTP AUTH
exchange = EmailSender.for_exchange_smtp_auth("you@company.com", "password")
exchange.send(["person@company.com"], "Status", "Body")

# Exchange on-prem with self-signed certs (only on trusted networks)
exchange_on_prem = EmailSender(
    user_email="you@corp.local",
    server_smtp_address="mail.corp.local",
    user_email_password="password",
    port=587,
    security_mode="starttls",
    allow_invalid_certs=True,  # accept self-signed certs on trusted networks
)
exchange_on_prem.send(["admin@corp.local"], "Status", "Body")

OAuth2 XOAUTH2

oauth_sender = EmailSender(
    user_email="you@gmail.com",
    server_smtp_address="smtp.gmail.com",
    port=587,
    security_mode="starttls",
    oauth2_access_token="ya29.a0Af..."  # obtain via your OAuth flow
)
oauth_sender.send(["to@example.com"], "OAuth2", "Sent with XOAUTH2")

HTML with plain fallback and attachments

html = "<h1>Report</h1><p>See attachment.</p>"
sender.send(
    recipients=["to@example.com"],
    subject="Monthly report",
    message_body=html,
    html=True,
    attachments=["/path/report.pdf", "/path/chart.png"]
)

Async sending

import asyncio

async def main():
    await sender.send_async(
        recipients=["to@example.com"],
        subject="Async",
        message_body="Non blocking send"
    )

asyncio.run(main())

Bulk helpers

send_bulk sends to each recipient individually (so recipients never see each other) while reusing a single connection — it skips the TLS handshake and AUTH round-trip that a naive per-message loop would repeat. It returns a report of what succeeded and what failed.

report = sender.send_bulk(
    recipients=["a@example.com", "b@example.com"],
    subject="Announcement",
    message_body="Sent individually to protect privacy",
)
print(report["sent"])    # ["a@example.com", "b@example.com"]
print(report["failed"])  # {address: exception, ...}

High-volume sending: retries, rate limiting, concurrency

For large organizations, configure a retry policy and a send rate. Retries use exponential backoff with jitter and automatically reconnect on a dropped connection; the rate limiter is a token bucket measured in messages/second.

from MailToolsBox import EmailSender, RetryPolicy

sender = EmailSender(
    user_email="you@example.com",
    server_smtp_address="smtp.example.com",
    user_email_password="pw",
    retry_policy=RetryPolicy(max_attempts=3, base_delay=0.5),
    rate_limit=50,  # cap at 50 messages/second
)

sender.send_bulk(recipients=large_list, subject="News", message_body="...")

Async bulk sending bounds how many connections are open at once:

await sender.send_bulk_async(
    recipients=large_list,
    subject="News",
    message_body="...",
    max_concurrency=20,   # at most 20 concurrent sends
)

When you control the loop yourself, hold one connection open with a session:

with sender.open_session() as session:
    for user in users:
        session.send([user.email], "Welcome", render(user))

Error handling

All errors derive from MailToolsBoxError, so you can catch broadly or narrowly:

from MailToolsBox import AuthenticationError, SendError, MailToolsBoxError

try:
    sender.send(["to@example.com"], "Hi", "Body")
except AuthenticationError:
    ...  # bad credentials / token
except SendError:
    ...  # message rejected or transport failure
except MailToolsBoxError:
    ...  # anything else from the library

Environment variables

export EMAIL=you@example.com
export EMAIL_PASSWORD=apppass
export SMTP_SERVER=smtp.gmail.com
export SMTP_PORT=465
export EMAIL_SECURITY=ssl
export EMAIL_REPLY_TO=noreply@example.com
sender = EmailSender.from_env()

IMAP in depth

The ImapClient provides safe defaults with flexible control when you need it.

Connect and select

imap = ImapClient(
    email_account="you@example.com",
    password="password",
    server_address="imap.example.com",
    port=993,
    security_mode="ssl",
    allow_invalid_certs=False,  # set True to accept self-signed certs
)
imap.login()
imap.select("INBOX")

Or use context manager:

with ImapClient.from_env() as imap:
    imap.select("INBOX")
    print(imap.list_mailboxes())

Environment variables:

export IMAP_EMAIL=you@example.com
export IMAP_PASSWORD=apppass
export IMAP_SERVER=imap.gmail.com
export IMAP_PORT=993
export IMAP_SECURITY=ssl
# Optional OAuth token
export IMAP_OAUTH2_TOKEN=ya29.a0Af...
# Accept self-signed certs for dev/on-prem only
export IMAP_ALLOW_INVALID_CERTS=1

Search and fetch

uids = imap.search("UNSEEN", "SINCE", "01-Jan-2025")
item = imap.fetch(uids[0])
print(item.subject, item.date, item.flags)
print(item.text or item.html)

Attachments and export

paths = imap.save_attachments(item, "./attachments")
eml_path = imap.save_eml(item, "./message.eml")

Flags, move, delete

imap.mark_seen(item.uid)
imap.add_flags(item.uid, "\\Flagged")
imap.move([item.uid], "Archive")
imap.delete(item.uid)
imap.expunge()

Legacy style exports

# Dump mailbox to one text file
imap.download_mail_text(path="./dumps", mailbox="INBOX")

# Export selected emails as JSON
imap.download_mail_json(lookup="UNSEEN", save=True, path="./dumps", file_name="mail.json")

# Save each message to .eml
imap.download_mail_eml(directory="./eml", lookup="ALL", mailbox="INBOX")

OAuth2 XOAUTH2

imap = ImapClient(
    email_account="you@gmail.com",
    password=None,
    server_address="imap.gmail.com",
    port=993,
    security_mode="ssl",
    oauth2_access_token="ya29.a0Af..."
)
with imap:
    imap.select("INBOX")
    uids = imap.search("ALL")

Validation and templates

  • Addresses are normalized with email-validator only when you opt in via validate_emails=True and install the optional extra: pip install "MailToolsBox[validation]".
  • Templates use Jinja2 with autoescape for HTML and XML.
  • HTML sending includes a plain text alternative for better deliverability.

Template example templates/welcome.html:

<h1>Welcome, {{ user }}</h1>
<p>Activate your account: <a href="{{ link }}">activate</a></p>

Send with template:

sender = EmailSender(
    user_email="you@example.com",
    server_smtp_address="smtp.example.com",
    user_email_password="pw",
    template_dir="./templates"
)

sender.send_template(
    recipient="to@example.com",
    subject="Welcome",
    template_name="welcome.html",
    context={"user": "Alex", "link": "https://example.com/activate"}
)

Backward compatibility

SendAgent stays available for older codebases. It is thin and delegates to EmailSender. Prefer EmailSender in new code.

from MailToolsBox import SendAgent
legacy = SendAgent("you@example.com", "smtp.example.com", "pw", port=587)
legacy.send_mail(["to@example.com"], "Subject", "Body", tls=True)

ImapAgent remains as a thin adapter over ImapClient for projects that still import the legacy name.


Security notes

  • Prefer ssl on 465 or starttls on 587.
  • To accept self-signed certificates for SMTP/IMAP on trusted networks, pass allow_invalid_certs=True.
  • To intentionally skip TLS (inside trusted networks only), use security_mode="none".
  • For on-prem Exchange or legacy servers with self-signed certificates, pass allow_invalid_certs=True (SMTP/IMAP) only on trusted networks.
  • Use app passwords when your provider offers them.
  • Prefer OAuth2 tokens for long term services.
  • Use none only on trusted networks.

Testing

pip install -e ".[dev]"        # or: pip install -r requirements-dev.txt
pytest                         # unit + end-to-end tests
ruff check MailToolsBox tests  # lint
black --check MailToolsBox tests
mypy MailToolsBox              # type check

Unit tests are network-free. The suite also includes end-to-end tests that spin up a real in-process SMTP server (via aiosmtpd) to exercise the actual sync and async transport code — no external network needed.


Releasing

Releases publish to PyPI automatically via GitHub Actions (release.yml) when a version tag is pushed. The workflow authenticates with a PyPI API token stored as the repository secret PYPI_API_TOKEN (GitHub repo → Settings → Secrets and variables → Actions). Prefer OIDC? See Trusted Publishing.

  1. Bump the version in MailToolsBox/_version.py (single source of truth) and update CHANGELOG.md.
  2. Commit, then tag and push: git tag v3.0.0 && git push origin v3.0.0.
  3. The workflow verifies the tag matches the package version, builds the sdist and wheel, runs twine check, and publishes (idempotent — re-runs skip an already-published version).

To build locally without publishing:

python -m build
twine check dist/*

Troubleshooting

  • Authentication errors on Gmail usually mean you need an app password or OAuth2.
  • If a STARTTLS upgrade fails in auto, set security_mode="ssl" on 465 or security_mode="starttls" on 587.
  • For corporate relays that do not support TLS, set security_mode="none" and ensure the network is trusted.
  • Enable logging in your application to capture SMTP or IMAP server responses.
import logging
logging.basicConfig(level=logging.INFO)

Contributing

PRs are welcome. Keep changes focused and covered with tests. Add docs for new behavior. Use ruff and black for formatting.


License

MIT. See LICENSE for details.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mailtoolsbox-3.0.0.tar.gz (28.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mailtoolsbox-3.0.0-py3-none-any.whl (24.7 kB view details)

Uploaded Python 3

File details

Details for the file mailtoolsbox-3.0.0.tar.gz.

File metadata

  • Download URL: mailtoolsbox-3.0.0.tar.gz
  • Upload date:
  • Size: 28.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.6

File hashes

Hashes for mailtoolsbox-3.0.0.tar.gz
Algorithm Hash digest
SHA256 d15d77cf9ea288ed0de6d4e90fdc790db04f90726db82d57ea0db3537e2c91a4
MD5 8ec89ac6e5846f7b0e2fdd89dfacc892
BLAKE2b-256 0351eaac8dcd042bc050c49d5fc63aa6d6e32f81f2537bd80e088b2e17d2f0ac

See more details on using hashes here.

File details

Details for the file mailtoolsbox-3.0.0-py3-none-any.whl.

File metadata

  • Download URL: mailtoolsbox-3.0.0-py3-none-any.whl
  • Upload date:
  • Size: 24.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.6

File hashes

Hashes for mailtoolsbox-3.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bc62387e3f829c3fd2252fa3571f7dc6e75223038d353f9cf1f860881690ac73
MD5 af27c926f667dc010d1241c4479ec251
BLAKE2b-256 ac6613f136b46b2b5cdae34d5c6adc1059c0b47dfdcd18f7235386890f8af959

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page