Python poplib: Read Email with POP3

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 poplib module implements a POP3 client in Python’s standard library. It can inspect a mailbox, list messages, retrieve content, and mark messages for deletion. POP3 still appears in older email accounts, embedded devices, and simple integrations that download mail from a server.

Python’s own documentation describes POP3 as obsolescent and recommends IMAP when available. POP3 usually provides a simple mailbox view without folders, rich server-side search, synchronized flags, or reliable partial access. Use it only when a provider or legacy system requires the protocol.

Secure connection with POP3_SSL

import poplib
import ssl

context = ssl.create_default_context()

client = poplib.POP3_SSL(
    "pop.example.com",
    port=995,
    timeout=15,
    context=context,
)

try:
    client.user("user@example.com")
    client.pass_("password")
    print(client.stat())
finally:
    client.quit()

Use POP3_SSL or STARTTLS before authentication. Plain POP3 can expose usernames, passwords, and message contents. The context created by ssl.create_default_context() verifies the certificate and hostname.

Do not disable verification to work around expired certificates or a hostname mismatch. Read Python ssl for trust-chain and hostname guidance.

STARTTLS on port 110

When a server uses explicit TLS upgrade, connect without authenticating, inspect capabilities, and call stls() with a verified context.

client = poplib.POP3("pop.example.com", timeout=15)
client.stls(context=context)
client.user(USER)
client.pass_(PASSWORD)

STARTTLS must happen before USER and PASS. Reject the connection if the expected capability is missing; never silently downgrade to plaintext.

Credentials

Do not hard-code passwords. Use environment variables, a secret manager, or provider-issued application credentials. Many modern services require OAuth and may reject ordinary POP3 passwords.

Do not run set_debuglevel(2) in production. Protocol debug output can reveal metadata, identifiers, server responses, and authentication details.

Inspecting capabilities

capabilities = client.capa()
for name, parameters in capabilities.items():
    print(name, parameters)

Capabilities can advertise STLS, UIDL, TOP, UTF8, and authentication mechanisms. POP3 implementations vary widely. A method existing in Python does not guarantee that a server supports or correctly implements the related command.

Mailbox status

message_count, total_bytes = client.stat()
print(message_count, total_bytes)

stat() returns a count and the server-reported total size. Treat both as estimates and enforce independent limits. The mailbox can change between the status call and retrieval.

Listing messages

response, lines, octets = client.list()

messages = []
for line in lines:
    number_text, size_text = line.split(maxsplit=1)
    messages.append((int(number_text), int(size_text)))

Validate every line because the remote server can send unexpected data. Skip messages above the application limit and cap the number processed in one run.

Use UIDL to identify messages

POP3 message numbers are temporary positions for the current session. Use uidl() to get server-issued identifiers and avoid processing the same message repeatedly.

response, lines, octets = client.uidl()

uids = {}
for line in lines:
    number, uid = line.split(maxsplit=1)
    uids[int(number)] = uid.decode("ascii", errors="strict")

Persist completed UIDs in a transactional database or state file. The identifier is server-specific and does not replace the email’s Message-ID; both values can help deduplication.

Retrieving a complete message

response, lines, octets = client.retr(number)

if octets > MESSAGE_LIMIT:
    raise ValueError("message is too large")

raw_email = b"\r\n".join(lines) + b"\r\n"

retr() returns lines without the final CRLF sequence. Reconstruct a valid byte stream carefully. The response may already occupy memory, so inspect LIST size first and impose per-run byte budgets.

Parsing with the email package

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"))

Subjects, senders, HTML bodies, and attachment names are untrusted. Escape headers before display and sanitize HTML if rendering is truly required.

TOP for headers and a body preview

top(number, lines) attempts to fetch headers and a number of body lines without setting a seen flag. The official documentation warns that TOP is poorly specified and often broken on non-mainstream servers.

response, lines, octets = client.top(number, 0)
headers = b"\r\n".join(lines) + b"\r\n\r\n"

Test TOP manually against every provider before depending on it. When behavior is inconsistent, retrieve the full bounded message or choose IMAP.

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
        data = part.get_payload(decode=True) or b""
        total += len(data)
        if total > limit:
            raise ValueError("content exceeds limit")
        yield part.get_content_type(), data

Decode using the declared charset with a safe fallback. The guide to Python codecs covers decoding errors and incremental processing.

Safe attachments

Never use a MIME filename as a direct filesystem path. Generate an internal identifier, enforce per-file and total limits, inspect the actual format, and store outside executable directories.

Use Python tempfile during validation and Python hashlib for integrity and deduplication.

Deletion is committed by QUIT

dele(number) marks a message during the current session. On compliant servers, deletion becomes permanent when quit() completes.

client.dele(number)
# Other checks and persistence
client.quit()  # commits changes

This creates an operational risk: code can mark the wrong messages and confirm all marks while closing. Separate download from deletion, confirm UID and domain state, and record a durable decision before calling DELE.

Undoing marks with RSET

client.rset()

rset() clears deletion marks for the session before they are committed. Call it when processing fails after one or more DELE commands.

Unexpected disconnects

Most compliant servers cancel pending deletion if the connection closes without QUIT, but Python’s documentation mentions historical implementations that violate this behavior. Do not treat a disconnect as a guaranteed rollback. The safest design delays DELE until the final stage.

A safer processing pipeline

A robust POP3 workflow can follow these steps:

  1. Connect using verified TLS.
  2. List UIDL identifiers and sizes.
  3. Skip UIDs already completed.
  4. Download at most N messages and M total bytes.
  5. Parse and validate content.
  6. Persist the result and commit local state.
  7. Only then, optionally mark messages for deletion.
  8. Call QUIT and record completion.

If local persistence fails, leave the server message untouched.

UTF-8 mode

The utf8() method requests the RFC 6856 mode when the server supports it. Check capabilities and handle error_proto. Even with UTF-8 at the POP3 layer, MIME parts may declare many different charsets.

Keep-alive and deadlines

noop() can keep a session active, but it does not replace a total deadline. Long processing should bound its duration and reconnect safely when needed.

Handling errors

poplib.error_proto represents POP3 protocol replies. Socket and TLS failures may arrive as OSError, TimeoutError, or ssl.SSLError.

try:
    client = poplib.POP3_SSL(HOST, context=context, timeout=15)
    client.user(USER)
    client.pass_(PASSWORD)
except poplib.error_proto as error:
    raise RuntimeError("POP3 server rejected the operation") from error
except OSError as error:
    raise RuntimeError("POP3 transport failure") from error

Never include the password or full authentication reply in logs.

POP3 versus IMAP

POP3 focuses on downloading from a simple mailbox. IMAP supports folders, server-side search, stable UID tracking with UIDVALIDITY, flags, and synchronization. For workflows that preserve server state or use multiple folders, read Python imaplib.

Test invalid certificates, missing STARTTLS, rejected login, unsupported UIDL, broken TOP, oversized messages, malformed MIME, dangerous attachments, DELE followed by RSET, failure before QUIT, timeout, empty mailboxes, and deduplication.

Common mistakes

Frequent mistakes include using plaintext POP3 with passwords, disabling TLS verification, trusting TOP, identifying messages only by session number, downloading an entire mailbox, keeping everything in memory, trusting attachment names, marking deletion before persistence, committing DELE automatically during cleanup, and logging sensitive content.

Conclusion

poplib provides direct POP3 access, but the protocol is limited and aging. Use POP3_SSL or STARTTLS with a verified context, identify messages through UIDL, impose strict limits, parse MIME as untrusted data, and make deletion a final explicitly authorized action.

Read the official poplib documentation and POP3 RFC 1939. When possible, prefer IMAP for more predictable synchronization and mailbox control.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Close-up of hands typing on a laptop keyboard, Python book in sight, coding in progress.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python imaplib: Read Email with IMAP

    Learn Python imaplib to access IMAP mailboxes with TLS, search by UID, read without marking messages, manage flags, and avoid

    Ler mais

    Tempo de leitura: 5 minutos
    22/08/2026
    Extreme close-up of computer code displaying various programming terms and elements.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python ftplib: Secure FTP and FTPS

    Learn Python ftplib to list, download, and upload files over FTP or FTPS with TLS, timeouts, limits, resume, and clear

    Ler mais

    Tempo de leitura: 5 minutos
    21/08/2026
    Server rack representing an endpoint built with Python xmlrpc.server
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    xmlrpc.server: Build XML-RPC Servers

    Learn Python xmlrpc.server to build XML-RPC servers, register functions, restrict methods and paths, and avoid unsafe exposure.

    Ler mais

    Tempo de leitura: 5 minutos
    21/08/2026
    Server cables representing remote calls with Python xmlrpc.client
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python xmlrpc.client: Remote Calls

    Learn Python xmlrpc.client to call XML-RPC services, handle Fault and ProtocolError, use TLS, compatible types, and safe limits.

    Ler mais

    Tempo de leitura: 5 minutos
    21/08/2026
    Error code over binary data representing failures handled with Python urllib.error
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python urllib.error: Handle HTTP Failures

    Learn Python urllib.error to handle URLError, HTTPError, incomplete downloads, selective retries, and clearer network diagnostics.

    Ler mais

    Tempo de leitura: 5 minutos
    21/08/2026
    HTML keycaps representing HTML entities with Python html.entities
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    html.entities: Convert HTML Entities

    Learn Python html.entities to inspect HTML entities, convert names and code points, and avoid confusing decoding with sanitization.

    Ler mais

    Tempo de leitura: 6 minutos
    21/08/2026