A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design
Source: Dev.to
The Problem
If you’ve ever tried to read or send emails with Python, you know the pain:
The old way
import imaplib import email
mail = imaplib.IMAP4_SSL(“imap.gmail.com”) mail.login(“user@gmail.com”, “password”) mail.select(“inbox”) _, data = mail.search(None, “UNSEEN”) for num in data[0].split(): _, msg_data = mail.fetch(num, “(RFC822)”) msg = email.message_from_bytes(msg_data[0][1]) # now manually decode headers, parse body, handle attachments…
Low-level, verbose, and error-prone. You spend more time fighting the protocol than solving your actual problem. And this is just for reading. If you want to send an HTML email with attachments, you’re deep into MIMEMultipart, MIMEText, MIMEBase, encoders.encode_base64… pages of boilerplate before you even get to your actual logic. Python’s standard library gives you the building blocks, but it never gives you the house. I was working on a project that needed to automate email workflows — read incoming messages, extract attachments, reply programmatically, and back everything up. After writing the same IMAP boilerplate for the third time across different projects, I decided to build something better. The goals were simple: Zero configuration for common providers — just pass an email address and password A Django ORM-like query API — composable, readable, Pythonic Lazy evaluation — don’t fetch 10,000 emails when you only need a count Pydantic models everywhere — type-safe, serializable, predictable One library for everything — read, send, search, reply, forward, backup The result is email-profile. pip install email-profile
from email_profile import Email
with Email(“user@gmail.com”, “app-password”) as app: for msg in app.unread().messages(): print(f”{msg.from_}: {msg.subject}”)
That’s it. 3 lines to read your unread emails. No server configuration, no manual connection handling, no protocol-level code. No more googling “what’s the IMAP server for Outlook?”. Just pass your email address:
Gmail, Outlook, Yahoo, iCloud, ProtonMail, Zoho…
Server settings are auto-discovered
app = Email(“user@outlook.com”, “password”)
It uses a 4-tier resolution strategy: Hard-coded provider map — Gmail, Outlook, Yahoo, iCloud, and 30+ others are mapped directly RFC 6186 SRV DNS lookups — standard service discovery for domains that support it MX record inference — extracts the provider from MX records and maps to known IMAP hosts Convention fallback — tries imap. and smtp. as a last resort This means even custom domains hosted on known providers (like a company email on Google Workspace) are resolved automatically. If you’ve used Django’s ORM, this will feel familiar. The Q class provides static methods that return composable expressions: from email_profile import Q from datetime import date
Combine with & (AND), | (OR), ~ (NOT)
q = Q.from_(“boss@company.com”) & Q.subject(“urgent”) & Q.since(date(2025, 1, 1))
for msg in app.inbox.where(q).messages(): print(msg.subject)
Available Q methods include Q.from_(), Q.subject(), Q.unseen(), Q.seen(), Q.since(), Q.before(), Q.larger(), and more. They can be combined freely with &, |, and ~ operators. For simpler cases, use kwargs-style queries with Query: from email_profile import Query
results = app.inbox.where(Query(subject=“invoice”, unseen=True)) print(f”Found {results.count()} unread invoices”)
Query also supports chaining with .exclude() and .or_(): q = Query(subject=“report”).exclude(from_who=“spam@example.com”).or_(subject=“invoice”) messages = app.inbox.where(q).list()
Sending is just as clean. Plain text, HTML, attachments, CC, BCC — all in one call: app.send( to=“team@company.com”, subject=“Weekly Report”, html=“
Report
All systems operational. ”, attachments=[“report.pdf”, “metrics.csv”] )
You can also send both plain text and HTML as a multipart message: app.send( to=“recipient@example.com”, subject=“Update”, body=“Plain text fallback for clients that don’t render HTML.”, html=“
Update
Rich version of the same content. ” )
Note that html is a string parameter (the HTML content itself), not a boolean flag. This is a deliberate design choice — it keeps the API explicit and avoids ambiguity. Reply and forward are methods on the Email object, not on the message. This keeps Message as a pure data object (Pydantic model) with no side effects:
Reply to sender only
app.reply(msg, body=“Thanks, I’ll review it today!”)
Reply all
app.reply(msg, body=“Acknowledged.”, reply_all=True)
Forward
app.forward(msg, to=“colleague@company.com”, body=“FYI - please take a look”)
Access standard folders via properties, or reach any custom folder by path:
Built-in shortcuts
app.inbox app.sent app.spam app.trash app.drafts app.archive
Custom folders
reports = app.mailbox(“Projects/Reports”)
Flag operations live on the mailbox, not the message. They accept a Message, a UID string, or an integer: app.inbox.mark_seen(msg) app.inbox.mark_unseen(msg) app.inbox.flag(msg) app.inbox.unflag(msg) app.inbox.move(msg, “Archive”) app.inbox.copy(msg, “Backup”) app.inbox.delete(msg)
This design means you can operate on messages from any context — even messages loaded from a backup — without needing an active connection reference on the message object itself. Back up your entire mailbox to a local SQLite database with incremental sync: from email_profile import StorageSQLite
app = Email(“user@gmail.com”, “password”) app.storage = StorageSQLite(“backup.db”)
with app: result = app.sync() # incremental — only downloads new emails print(f”{result.inserted} new, {result.skipped} skipped”)
The SyncResult object gives you full visibility: inserted, updated, deleted, skipped, and errors. Restore is just as simple: with app: count = app.restore() # returns int (count of restored messages) print(f”Restored {count} messages”)
Queries return a Where object — nothing hits the server until you explicitly
ask for data. This is critical for performance when working with large mailboxes: query = app.inbox.where(Q.unseen())
No IMAP call yet — the query is just a description
query.count() # hits the server, returns int query.exists() # bool — cheaper than count for “any unread?” checks query.first() # Message or None — fetches only one query.last() # Message or None — fetches only one query.list() # list[Message] — materializes everything query.messages() # iterator — memory-efficient for large result sets
This means app.inbox.where(Q.unseen()).count() only asks the server “how many?”, without downloading a single message body. Control how much data you pull per message. This makes a huge difference when you’re scanning thousands of emails:
Full message + attachments (default)
app.inbox.where().messages(mode=“full”)
Headers + body, skip attachments (faster)
app.inbox.where().messages(mode=“text”)
Headers only (cheapest — great for building an index)
app.inbox.where().messages(mode=“headers”)
For example, if you’re building a dashboard that shows subject lines and dates, use mode=“headers” and save 90%+ of bandwidth. Every message is a Message object — a Pydantic BaseModel with typed fields: from email_profile import Message
for msg in app.inbox.where(unseen=True).messages(): msg.subject # str msg.from_ # str msg.to_ # str msg.date # datetime msg.uid # str msg.message_id # str msg.body_text_plain # Optional[str] msg.body_text_html # Optional[str] msg.cc # Optional[str] msg.bcc # Optional[str] msg.reply_to # Optional[str] msg.in_reply_to # Optional[str] msg.references # Optional[str] msg.content_type # str msg.attachments # list[Attachment] msg.headers # dict msg.list_id # Optional[str] msg.list_unsubscribe # Optional[str]
Because it’s Pydantic, you get .model_dump(), .model_dump_json(), validation, and serialization for free. This makes it trivial to pipe email data into APIs, databases, or AI workflows. with Email.from_env() as app: for msg in app.unread().messages(): print(f”From: {msg.from_} | Subject: {msg.subject}”) app.inbox.mark_seen(msg)
with Email(“user@gmail.com”, “password”) as app: for msg in app.search(“invoice”).messages(): for att in msg.attachments: if att.content_type == “application/pdf”: att.save(”./invoices/”)
from email_profile import StorageSQLite
app = Email(“user@gmail.com”, “password”) app.storage = StorageSQLite(“full_backup.db”)
with app: result = app.sync() print(f”Backed up {result.inserted} new emails”)
with Email.from_env() as app: for msg in app.spam.where().messages(): app.spam.delete(msg)
from datetime import date from email_profile import Q
with Email.from_env() as app: q = Q.since(date(2025, 1, 1)) & Q.before(date(2025, 4, 1)) & Q.from_(“client@company.com”) messages = app.inbox.where(q).list() print(f”Found {len(messages)} emails from client in Q1 2025”)
.env
EMAIL_SERVER=imap.gmail.com EMAIL_USERNAME=user@gmail.com EMAIL_PASSWORD=app-password
with Email.from_env() as app: print(f”Connected as {app.user}”)
You can also customize the variable names: app = Email.from_env( server_var=“MY_IMAP_HOST”, user_var=“MY_EMAIL”, password_var=“MY_EMAIL_PW” )
The library provides specific exception classes so you can handle failures gracefully: from email_profile import ConnectionFailure, NotConnected, QuotaExceeded, RateLimited
try: app.connect() except ConnectionFailure: print(“Could not connect to server”) except QuotaExceeded: print(“Mailbox quota exceeded”) except RateLimited: print(“Too many requests — back off and retry”)
For automated workflows, use the built-in retry decorator: from email_profile.retry import with_retry
@with_retry(attempts=3, initial_delay=1.0, max_delay=30.0) def fetch_unread(): with Email.from_env() as app: return app.unread().list()
It handles exponential backoff automatically, so your scripts survive transient network issues without custom retry logic.
Feature imaplib/smtplib email-profile
Auto-discovery No 35+ providers
Context manager No Yes
Search API Raw IMAP commands Q objects & Query kwargs
Send HTML Manual MIME building html=”…”
Attachments Manual encoding attachments=[“file.pdf”]
Reply/Forward Build from scratch app.reply(msg, body=”…”)
Backup/Sync DIY
StorageSQLite built-in
Lazy evaluation No Where objects
Retry logic DIY
@with_retry decorator
Pydantic models No Message DTO
Fetch modes Manual flags mode=“full/text/headers”
A few decisions that shape the library: Message is data, not behavior. Message is a pure Pydantic model. It has no methods that mutate state or require a connection. Operations like mark_seen(), move(), and delete() live on the mailbox. This keeps the model serializable and testable.
Lazy by default. .where() returns a Where object, not a list. You choose when and how to materialize: .count(), .first(), .list(), or .messages(). This prevents accidentally downloading thousands of emails.
One import, one object. For most use cases, from email_profile import Email is all you need. The Email class is the entry point for connecting, reading, sending, replying, forwarding, and syncing.
Fail explicitly. Instead of generic exceptions, the library provides ConnectionFailure, NotConnected, QuotaExceeded, and RateLimited — so you can handle each case differently.
Getting Started
pip install email-profile
from email_profile import Email
with Email(“your@email.com”, “app-password”) as app: # Read for msg in app.unread().messages(): print(msg.subject)
# Send
app.send(to="friend@email.com", subject="Hello!", body="Sent with emai
l-profile”)
GitHub: github.com/linux-profile/email-profile
Docs: linux-profile.github.io/email-profile
PyPI: pypi.org/project/email-profile
The library is in active development and we’d love your feedback. Star the repo, open issues, or contribute — every bit helps! What do you think? Would you use this in your projects? Drop a comment below!