Python imaplib: Read Email with IMAP

Published on: August 22, 2026
Reading time: 5 minutes
Close-up of hands typing on a laptop keyboard, Python book in sight, coding in progress.

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.

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.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Developer configuring a Python HTTPS server and TLS certificate
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    HTTPSServer: Build a Local HTTPS Server in Python

    Learn Python HTTPSServer for local HTTPS services, TLS certificates, threaded handling, testing, and practical security limits.

    Ler mais

    Tempo de leitura: 4 minutos
    08/10/2026
    Protected files representing secure TAR extraction with Python
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    tarfile extraction_filter: Extract TAR Safely

    Learn Python tarfile extraction_filter for safer TAR extraction, path validation, links, permissions, and resource limits.

    Ler mais

    Tempo de leitura: 5 minutos
    08/10/2026
    Python code showing warnings controlled with catch_warnings
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    catch_warnings: Capture Python Warnings in Tests

    Learn Python catch_warnings to capture, test, and control warnings with focused filters and safe temporary scopes.

    Ler mais

    Tempo de leitura: 5 minutos
    07/10/2026
    Python code representing persistent pickle references
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    pickle persistent_id: Serialize External References

    Learn Python pickle persistent_id for stable external references, validation, security, performance, and long-term compatibility.

    Ler mais

    Tempo de leitura: 5 minutos
    07/10/2026
    Binary code representing Python buffers and memory views
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    memoryview.count: Count Values Without Buffer Copies

    Learn Python memoryview.count to count bytes and values in buffers without copies, with formats, limits, and practical safety.

    Ler mais

    Tempo de leitura: 5 minutos
    06/10/2026
    Python code and type annotations
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    annotationlib: Avoid Circular Imports in Annotations

    Learn Python annotationlib to inspect deferred annotations, avoid circular imports, and build safer runtime tooling.

    Ler mais

    Tempo de leitura: 5 minutos
    06/10/2026