The imaplib module implements an IMAP client in Python’s standard library. It can list mailboxes, search messages, fetch headers and bodies, copy messages, change flags, and monitor new activity. It is useful for support automation, attachment processing, archiving, and enterprise email integrations.
IMAP operates directly on a real mailbox. A careless command can mark messages as read, add the \Deleted flag, or permanently remove messages after EXPUNGE. Begin with a dedicated test account, select mailboxes in read-only mode, and use stable UIDs instead of changing sequence numbers.
Secure connection with IMAP4_SSL
import imaplib
import ssl
context = ssl.create_default_context()
with imaplib.IMAP4_SSL(
"imap.example.com",
port=993,
ssl_context=context,
timeout=15,
) as client:
client.login("user@example.com", "password")
print(client.noop())
The Python documentation notes that the internal default SSL context encrypts the connection but does not necessarily verify the certificate and hostname. Pass a context created by ssl.create_default_context() explicitly. Do not send a password over plain IMAP on port 143 before STARTTLS.
For private certificate authorities and minimum protocol versions, read Python ssl.
Credentials and authentication
Do not hard-code passwords. Load credentials from environment variables, a secret manager, or an OAuth flow when required by the provider. The authenticate() method supports SASL mechanisms advertised by the server.
Many providers disable ordinary password login. Inspect capabilities and follow the service documentation. Never print tokens, passwords, authentication challenges, or complete protocol transcripts.
Inspecting capabilities
print(client.capabilities)
Capabilities may include IMAP4REV1, IDLE, UIDPLUS, MOVE, or authentication mechanisms. Do not assume that every server implements the same extensions. Add an explicit fallback or refuse an operation when a required capability is absent.
Listing mailboxes
status, lines = client.list()
if status != "OK":
raise RuntimeError("unable to list mailboxes")
for line in lines or []:
print(line.decode("utf-8", errors="replace"))
Mailbox names may use modified UTF-7 or provider-specific delimiters. Avoid parsing every LIST response with a naive split(). A higher-level IMAP library can be worthwhile when broad international mailbox-name compatibility is a requirement.
Selecting INBOX as read-only
status, data = client.select("INBOX", readonly=True)
if status != "OK":
raise RuntimeError("failed to open INBOX")
message_count = int(data[0])
print("messages:", message_count)
readonly=True reduces the chance of changing flags or deleting content. Re-open the mailbox deliberately in writable mode only when the business operation truly requires mutation.
Use UIDs instead of sequence numbers
Message sequence numbers change whenever the mailbox changes, especially after an EXPUNGE. UIDs are more stable inside the same mailbox.
status, data = client.uid("search", None, "ALL")
if status != "OK":
raise RuntimeError("search failed")
uids = data[0].split()
print(uids[-10:])
UIDs are not globally permanent identifiers. If a mailbox is recreated, its UIDVALIDITY changes. A synchronization database should store the mailbox identity, UIDVALIDITY, and UID together.
Searching messages
status, data = client.uid(
"search",
None,
"UNSEEN",
"SINCE",
"01-Jul-2026",
)
Search criteria are interpreted by the server. IMAP dates contain no time-of-day component. Sender, subject, and text searches also involve quoting and charset rules. Do not concatenate arbitrary user input into a raw search expression without validation.
Fetching headers without marking messages as read
status, data = client.uid(
"fetch",
uid,
"(BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE MESSAGE-ID)])",
)
BODY.PEEK requests message data without adding \Seen on compliant servers. Fetching BODY[] may mark the message as read. Test the behavior against the actual provider and keep read-only selection enabled for passive workflows.
Parsing FETCH responses correctly
A FETCH response may include additional or unsolicited data. The documentation warns against assuming that the message bytes are always located at data[0][1]. Iterate through tuples and verify the literal type.
def fetch_literals(items):
for item in items or []:
if isinstance(item, tuple) and len(item) == 2:
metadata, literal = item
if isinstance(literal, bytes):
yield metadata, literal
Request only the sections you need and enforce maximum sizes. For very large messages, fetch headers first, inspect metadata, and retrieve selected parts or bounded chunks.
Parsing an email message
from email import policy
from email.parser import BytesParser
message = BytesParser(policy=policy.default).parsebytes(raw_email)
print(message.get("Subject"))
print(message.get("From"))
Headers and bodies are untrusted data. Do not render HTML email without sanitization. Attachment filenames must never become local filesystem paths directly.
Extracting text with limits
def text_parts(message, limit=1_000_000):
total = 0
for part in message.walk():
if part.get_content_maintype() == "multipart":
continue
if part.get_content_disposition() == "attachment":
continue
content = part.get_payload(decode=True) or b""
total += len(content)
if total > limit:
raise ValueError("message exceeds limit")
yield part.get_content_type(), content
Decode text using the declared charset and a safe fallback. The guide to Python codecs covers incremental decoding and error strategies.
Saving attachments safely
Generate an internal filename, enforce per-file and total size limits, inspect the actual format, and store files outside executable directories. A MIME filename can contain traversal sequences, control characters, reserved names, or duplicate names.
Use Python tempfile during validation and Python hashlib for integrity and deduplication.
Flags
Common flags include \Seen, \Answered, \Flagged, \Deleted, and \Draft. Use UID STORE and silent commands when you do not need the expanded response.
client.uid(
"store",
uid,
"+FLAGS.SILENT",
r"(\Seen)",
)
Before changing flags, confirm that the UID and mailbox still identify the intended message. Record the domain decision in logs without copying sensitive message content.
Deletion happens in two stages
Traditional IMAP deletion adds \Deleted and later runs EXPUNGE. An EXPUNGE can permanently remove every message already marked for deletion in the selected mailbox, including messages marked by another client.
client.uid("store", uid, "+FLAGS.SILENT", r"(\Deleted)")
# Do not call expunge automatically without an explicit policy.
When available, UIDPLUS or MOVE extensions provide more predictable operations. Use unselect() to release a mailbox without expunging. Be aware that close() on a writable mailbox can remove messages marked as deleted.
Copying and moving
copy() copies messages. A legacy move often means COPY, add \Deleted, and EXPUNGE, which is risky under concurrency. If the server advertises MOVE, use the extension through uid("MOVE", ...) and verify the result.
IDLE in Python 3.14
Python 3.14 adds idle(), an iterable context manager that yields notifications such as EXISTS. Set a duration to avoid server inactivity limits.
with client.idle(duration=29 * 60) as idler:
for response_type, response_data in idler:
if response_type == "EXISTS":
print("mailbox changed", response_data)
A notification is not a complete synchronization event. When a change arrives, perform a bounded UID-based search or sync. Reconnect with backoff when the IDLE connection closes.
Total deadlines and reconnecting
The constructor’s timeout covers connection establishment, but long workflows need a total deadline and a reconnect policy. An IMAP4.abort usually requires closing the object and opening a new connection.
Do not blindly retry write commands because a disconnect can occur after the server applied the change but before the client received confirmation.
Checking command results
Most methods return (status, data), where status is commonly OK, NO, or BAD. The absence of an exception does not always mean success.
status, data = client.uid("search", None, "ALL")
if status != "OK":
detail = data[0] if data else b"no detail"
raise RuntimeError(f"IMAP returned {status}: {detail!r}")
Logging and privacy
Do not enable imaplib.Debug in production. Protocol traces can reveal mailbox names, addresses, subjects, message identifiers, and authentication details. Log only the logical mailbox, operation, duration, item count, status, and a correlation identifier.
Recommended tests
Test invalid certificates, rejected login, missing mailbox, read-only behavior, UIDs and UIDVALIDITY, multipart messages, invalid charsets, oversized attachments, flags, reconnection, IDLE, unsolicited FETCH data, and protection against accidental EXPUNGE.
Common mistakes
Frequent mistakes include omitting certificate verification, using sequence numbers, selecting a mailbox writable by default, fetching BODY[] and marking messages read, reading only the first FETCH tuple, trusting attachment filenames, calling EXPUNGE automatically, logging message contents, and retrying uncertain write operations after a timeout.
Conclusion
imaplib provides detailed control over IMAP mailboxes, but that control requires discipline. Use IMAP4_SSL with an explicitly verified context, select read-only, prefer UIDs, use BODY.PEEK, limit message and attachment sizes, inspect every status response, and make deletion a separately authorized workflow.
Read the official imaplib documentation and IMAP4rev2 RFC 9051. For critical automation, combine an isolated account, idempotent state tracking, observability, and backups.







