Last time, Python learned to send email — notifications, reports, alerts. But an inbox that only sends is like a phone that only makes calls. Sooner or later, you want to pick up.

Reading your inbox with Python flips the direction of automation. Instead of your code producing email, your email triggers your code. That opens up a different set of projects:

  • Download invoice PDFs automatically the moment they arrive
  • Watch for mail from a specific sender (your boss, a client, your bank)
  • Turn messy newsletters into one tidy daily summary
  • Copy order confirmations into a spreadsheet

And the best part: it uses imaplib, which is built into Python's standard library — just like smtplib in our Send Emails with Python guide. No pip install required.

Setup: You Already Know This Part

If you worked through the sending guide, you have everything you need: a Gmail account with 2-Step Verification and an App Password. Skipping it? Here's the 2-minute version:

  1. Enable 2-Step Verification on your Google Account
  2. Generate an App Password (search "App Passwords" in your Google account settings)
  3. Save the 16-character password as an environment variable — never paste it into code:
export GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"

One warning the sending guide didn't need: reading scripts can change your inbox — mark emails as read, move them, even delete them. If that makes you nervous, create a separate Gmail account for testing. The code is identical either way.

Your First Inbox Read in 15 Lines

import imaplib
from email import message_from_bytes

USER = "yourname@gmail.com"
APP_PASSWORD = "xxxx xxxx xxxx xxxx"  # App Password, not your regular password

# Connect to Gmail's IMAP server
mailbox = imaplib.IMAP4_SSL("imap.gmail.com")
mailbox.login(USER, APP_PASSWORD)

# Open the inbox
mailbox.select("INBOX")

# Search for unread messages
status, results = mailbox.search(None, "UNSEEN")
ids = results[0].split()

# Fetch the newest unread email as a raw message
status, data = mailbox.fetch(ids[-1], "(RFC822)")
msg = message_from_bytes(data[0][1])

print("From:   ", msg["From"])
print("Subject:", msg["Subject"])
print("Date:   ", msg["Date"])

mailbox.logout()

Run it and you should see the newest unread email in your inbox. (If your inbox has no unread mail, ids is empty and ids[-1] crashes — add an if ids: check before the fetch.)

What each line does:

  1. IMAP4_SSL opens an encrypted connection to Gmail's IMAP server — the reading counterpart of SMTP_SSL
  2. select("INBOX") opens a folder
  3. search(None, "UNSEEN") asks the server: which messages are unread? The reply is a space-separated list of message IDs
  4. fetch(ids[-1], "(RFC822)") downloads the newest one as a raw RFC 822 message — the internet's plain-text email format
  5. message_from_bytes() parses those raw bytes into something you can work with

Two details worth knowing: search() returns (status, results) where status is "OK" on success. And fetch() returns (status, data) where data is a list of (header, bytes) pairs — data[0][1] is the actual message.

SMTP vs IMAP: The Filing Cabinet Analogy

Think of Gmail as a post office:

  • SMTP is the outgoing counter. You hand the clerk your letter and walk away. (The sending guide is all SMTP.)
  • IMAP is the filing cabinet where your mail lives. You open a drawer (folder), look at letters, put them back. The letters never leave the cabinet.

That's the difference from the older POP3 protocol, which downloads mail and typically deletes it from the server. IMAP reads from the server and leaves everything in place. Mark an email as read in Python, and Gmail shows it as read everywhere.

Searching Your Inbox Like a Database

search() is where IMAP gets powerful. The second argument is a search query, Gmail-style. The most useful keys:

Search key Matches Example
ALL every message mailbox.search(None, "ALL")
UNSEEN / SEEN unread / read messages mailbox.search(None, "UNSEEN")
FROM "sender" emails from an address FROM "billing@example.com"
SUBJECT "word" subject line contains text SUBJECT "receipt"
SINCE date / BEFORE date arrived after / before a date SINCE 01-Aug-2026
TEXT "word" anywhere in the body TEXT "invoice"

Combine keys with spaces — IMAP treats them as AND:

criteria = 'UNSEEN FROM "billing@example.com" SINCE 01-Aug-2026'
status, results = mailbox.search(None, criteria)

Two gotchas: dates use the format DD-Mon-YYYY — that's 01-Aug-2026, not 2026-08-01. And values with spaces or special characters go in double quotes.

Decoding Subjects That Aren't Plain ASCII

Run the first script and you might see something like:

Subject: =?UTF-8?B?8J+SgCBQcmljZSBEcm9wIEFsZXJ0?=

That's MIME encoding — subjects with emoji or non-English characters get wrapped this way so old mail servers can still handle them. decode_header unwraps it:

from email.header import decode_header

def clean_header(value):
    """Decode MIME-encoded headers into readable text."""
    parts = decode_header(value)
    text = ""
    for chunk, encoding in parts:
        if isinstance(chunk, bytes):
            text += chunk.decode(encoding or "utf-8", errors="replace")
        else:
            text += chunk
    return text

print(clean_header(msg["Subject"]))  # "🔔 Price Drop Alert"

Use clean_header for any header a human wrote — From, To, Subject, and attachment filenames.

Extracting the Body

Email bodies hide inside a multipart structure: one message can contain a plain-text part, an HTML part, and attachments all in the same blob. walk() flattens that structure so you can inspect each part:

def get_body(msg):
    """Return the plain-text body of a message, skipping attachments."""
    if msg.is_multipart():
        for part in msg.walk():
            content_type = part.get_content_type()
            is_attachment = part.get("Content-Disposition") is not None
            if content_type == "text/plain" and not is_attachment:
                return part.get_payload(decode=True).decode(errors="replace")
    return msg.get_payload(decode=True).decode(errors="replace")

Why decode=True? It makes get_payload() return raw bytes, which you decode to text yourself. Emails from the wild contain all kinds of broken encodings, and errors="replace" keeps one bad message from crashing your whole script.

Downloading Attachments

Same walk() trick, different filter — look for parts that have a filename:

from pathlib import Path

def save_attachments(msg, folder):
    """Save every attachment in the message to folder. Returns saved paths."""
    saved = []
    for part in msg.walk():
        filename = part.get_filename()
        if filename:
            filename = clean_header(filename)
            path = Path(folder) / filename
            path.write_bytes(part.get_payload(decode=True))
            saved.append(str(path))
    return saved

Real Project: The Invoice Grabber

Let's combine everything into something genuinely useful. Bills, invoices, receipts — they arrive from the same senders month after month, and nobody wants to deal with them until tax season. This script watches for unread emails from a billing address, saves every attachment to an Invoices folder, and marks the email as read:

"""invoice-grabber.py — save invoice attachments from a specific sender."""
import os
from pathlib import Path
import imaplib
from email import message_from_bytes
from email.header import decode_header

# ── Config ────────────────────────────────────────
USER = os.environ["GMAIL_ADDRESS"]
APP_PASSWORD = os.environ["GMAIL_APP_PASSWORD"]
INVOICE_SENDER = "billing@your-utility.com"
SAVE_DIR = Path.home() / "Documents" / "Invoices"

def clean_header(value):
    """Decode MIME-encoded headers into readable text."""
    parts = decode_header(value)
    text = ""
    for chunk, encoding in parts:
        if isinstance(chunk, bytes):
            text += chunk.decode(encoding or "utf-8", errors="replace")
        else:
            text += chunk
    return text

def save_attachments(msg, folder):
    """Save every attachment in the message to folder. Returns saved paths."""
    saved = []
    for part in msg.walk():
        filename = part.get_filename()
        if filename:
            filename = clean_header(filename)
            path = Path(folder) / filename
            path.write_bytes(part.get_payload(decode=True))
            saved.append(str(path))
    return saved

# ── Connect and search ────────────────────────────
mailbox = imaplib.IMAP4_SSL("imap.gmail.com")
mailbox.login(USER, APP_PASSWORD)
mailbox.select("INBOX")

status, results = mailbox.search(None, f'UNSEEN FROM "{INVOICE_SENDER}"')
ids = results[0].split() if results[0] else []
print(f"Found {len(ids)} unread email(s) from {INVOICE_SENDER}")

# ── Save attachments and mark as read ─────────────
SAVE_DIR.mkdir(parents=True, exist_ok=True)
count = 0

for msg_id in ids:
    status, data = mailbox.fetch(msg_id, "(RFC822)")
    msg = message_from_bytes(data[0][1])
    saved = save_attachments(msg, SAVE_DIR)
    if saved:
        for path in saved:
            print(f"  Saved: {path}")
        count += len(saved)
        mailbox.store(msg_id, "+FLAGS", "\\Seen")  # mark as read

mailbox.logout()
print(f"Done. {count} attachment(s) saved to {SAVE_DIR}")

Run it by hand the first time and check the output. Then hand it to cron — the Cron Jobs guide shows how:

0 9 * * * /usr/bin/python3 /home/user/invoice-grabber.py >> /var/log/invoice-grabber.log 2>&1

Now every morning at 9 AM, new invoices land in your Documents/Invoices folder before you've had coffee.

A note on safety: the script marks emails as read only after the attachments save successfully. If a save fails midway, the email stays unread and the next run retries it. If you want extra caution, comment out the mailbox.store line for the first week — download everything, change nothing.

IMAP Gotchas (And Their Fixes)

Symptom Cause Fix
AttributeError: 'NoneType' object has no attribute 'split' No emails matched your search results[0].split() if results[0] else []
imaplib.error: got more than 10000 bytes A single line in an email exceeds imaplib's internal limit Add imaplib._MAXLINE = 10_000_000 at the top
Login rejected Using your normal Gmail password Generate an App Password (2FA required)
Search returns nothing Wrong date format Use DD-Mon-YYYY (01-Aug-2026)
Subject shows =?UTF-8?...?= MIME-encoded header decode_header + the clean_header helper
Weird characters in bodies Unknown encoding decode(errors="replace")
Slow on big mailboxes IMAP is not designed for speed Search first, fetch only what you need

One limit to respect: Gmail caps IMAP downloads at about 2.5 GB per day. Grab only the attachments you need — don't try to download a 15-year-old inbox. For full backups, use Google Takeout instead.

What's Next?

You now have both halves of email automation: sending and reading. Put them together and the loop gets interesting — a script that reads a "backup report" email and replies when something is wrong, or one that scrapes prices, emails you the results, and reads your reply to change settings.

Ideas worth building next:

  • Inbox triage: auto-archive newsletters, star emails from real people, file the rest
  • Sender alerts: when a specific person emails you, get notified immediately
  • Attachment sorting: save attachments into per-sender folders — the Downloads Organizer pattern, applied to your inbox

Start small: run the 15-line script against your real inbox. The first time Python reads your mail feels oddly satisfying — like teaching a dog a new trick, except the dog never asks for treats.

All code in this article was tested and runs successfully on Python 3.8.10 (Ubuntu 20.04) — verified with a real Gmail account, August 2026.