An inbox is a kitchen counter: mail arrives, gets read once, and then just... sits there. Six months later you're searching for a receipt that's buried under 400 newsletters. You could tidy the counter every Sunday night — or you could teach Python to do it every morning before you wake up.

This is part three of our email automation journey: sending mail was part one, reading mail was part two, and this is the part that pays off. Instead of just looking at your inbox, your script now changes it: archives junk, labels receipts, files orders. All with imaplib, which is built into Python's standard library — no pip install needed.

Setup: Same Two Keys as Before

If you followed the reading guide, you already have everything. Skipping? The 2-minute version:

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

And one safety rule that matters more here than anywhere else: run every organizing script in dry-run mode first. Sending mail can only annoy someone; organizing mail can accidentally archive a week of your boss's messages. The scripts below all default to "look but don't touch."

The Five Organizing Moves

Everything an inbox organizer does is built from five small IMAP actions. Here's the whole toolbox:

import imaplib

mailbox = imaplib.IMAP4_SSL("imap.gmail.com")
mailbox.login(USER, APP_PASSWORD)
mailbox.select("INBOX")

# Move 1: mark as read (or star it with \\Flagged)
mailbox.store(msg_id, "+FLAGS", "\\Seen")

# Move 2: archive — remove from inbox, Gmail keeps it in All Mail
mailbox.store(msg_id, "+FLAGS", "\\Deleted")
mailbox.expunge()

# Move 3: apply a label (create it once, reuse forever)
mailbox.create("Receipts")                              # first time only
mailbox.store(msg_id, "+X-GM-LABELS", '"Receipts"')

# Move 4: file = label + archive in one go
mailbox.store(msg_id, "+X-GM-LABELS", '"Receipts"')
mailbox.store(msg_id, "+FLAGS", "\\Deleted")
mailbox.expunge()

# Move 5: trash it for real (see below — the folder name needs a lookup)
trash_folder = find_folder(mailbox, "\\Trash")
mailbox.copy(msg_id, trash_folder)
mailbox.store(msg_id, "+FLAGS", "\\Deleted")
mailbox.expunge()

mailbox.logout()

Wait — what's find_folder? Before the moves, grab it with this helper:

def find_folder(mailbox, flag):
    """Return the server's real name of the folder that carries `flag`."""
    status, folders = mailbox.list()
    for folder in folders:
        if flag in folder.decode():
            return folder.decode().split('"/"')[-1].strip().strip('"')
    return None

Three things worth understanding before you use these:

Why the trash folder needs a lookup. Gmail's system folders have encoded server names — if you literally pass "[Gmail]/Trash", the copy silently does nothing. find_folder asks the server for the folder that carries the \Trash flag, whatever its encoded name happens to be. This also makes the code work on non-Gmail servers, where the trash folder might just be called "Trash".

Why archive uses \Deleted + expunge? On a normal IMAP server, that combination deletes a message. Gmail bends the rules: expunging a \Deleted message inside the INBOX removes it from the inbox but keeps it in All Mail — that's the archive button. (That's a Gmail special case — to actually trash something, copy it to the Trash folder first, then expunge.)

Why +X-GM-LABELS? It's a Gmail-specific extension that other email providers don't support. The + adds the label, a - removes it. If you're not on Gmail, skip Move 3 and use plain folders instead.

Labels must exist first. Applying "Receipts" when no such label exists raises an imaplib error. mailbox.create() is idempotent enough for our purposes — if the label exists you'll get a friendly error you can ignore, or you can delete-and-recreate as the scripts below do.

Gmail Labels Are IMAP Folders in Disguise

Here's a mental model that makes the rest of this easy: Gmail shows you "labels," IMAP sees "folders" — they're the same thing. The label "Receipts" in the Gmail web app is the folder Receipts over IMAP. When your script applies a label, it's filing the message into that folder.

That's why the file move is so natural: it applies a label and archives, which is exactly what the Gmail web app does when you drag a message onto a label in the sidebar.

The Project: Your Own Inbox Tidy Bot

Here's the full script — a rules engine. You declare rules in plain English-ish Python, it applies them to your inbox. The dry-run mode prints what it would do; flip one variable to make it real.

"""inbox-tidy.py — organize your inbox with rules you write yourself."""
import os
import imaplib

# ── Config ────────────────────────────────────────
USER = os.environ["GMAIL_ADDRESS"]
APP_PASSWORD = os.environ["GMAIL_APP_PASSWORD"]
DRY_RUN = True   # set to False only after checking the dry-run output!

# One rule per line: what to find, what to do with it.
RULES = [
    {"query": 'FROM "newsletters@example.com"', "action": "archive"},
    {"query": 'FROM "orders@example.com"',     "action": "file",  "label": "Orders"},
    {"query": 'SUBJECT "receipt"',             "action": "file",  "label": "Receipts"},
    {"query": 'SUBJECT "urgent"',              "action": "read"},
]

def connect():
    mailbox = imaplib.IMAP4_SSL("imap.gmail.com")
    mailbox.login(USER, APP_PASSWORD)
    return mailbox

def find_folder(mailbox, flag):
    """Return the server's real name of the folder that carries `flag`."""
    status, folders = mailbox.list()
    for folder in folders:
        if flag in folder.decode():
            return folder.decode().split('"/"')[-1].strip().strip('"')
    return None

mailbox = connect()
mailbox.select("INBOX")
trash_folder = find_folder(mailbox, "\\Trash")

for rule in RULES:
    # search() returns (status, results) — results[0] is the ID list
    status, results = mailbox.search(None, rule["query"])
    ids = results[0].split() if results[0] else []
    print(f"{rule['query']}: {len(ids)} email(s)")
    for msg_id in ids:
        if DRY_RUN:
            print(f"  would {rule['action']} message {msg_id}")
            continue
        if rule["action"] == "archive":
            mailbox.store(msg_id, "+FLAGS", "\\Deleted")
        elif rule["action"] == "read":
            mailbox.store(msg_id, "+FLAGS", "\\Seen")
        elif rule["action"] == "label":
            mailbox.store(msg_id, "+X-GM-LABELS", f'"{rule["label"]}"')
        elif rule["action"] == "file":
            mailbox.store(msg_id, "+X-GM-LABELS", f'"{rule["label"]}"')
            mailbox.store(msg_id, "+FLAGS", "\\Deleted")
        elif rule["action"] == "trash":
            mailbox.copy(msg_id, trash_folder)
            mailbox.store(msg_id, "+FLAGS", "\\Deleted")
        print(f"  done: message {msg_id}")

mailbox.expunge()   # archive/trash moves only happen at expunge
mailbox.logout()

Run it with DRY_RUN = True first. You'll see something like:

FROM "newsletters@example.com": 12 email(s)
  would archive message b'81'
  would archive message b'84'
FROM "orders@example.com": 2 email(s)
  would file message b'88'
SUBJECT "receipt": 5 email(s)
  would file message b'85'
SUBJECT "urgent": 0 email(s)

Check the counts against your expectations (newsletter sender has 12? makes sense. receipt matches 5? okay). Only then flip DRY_RUN = False.

One timing detail: expunge() runs once after all rules, not per message. Archiving and trashing only take effect at expunge — before that, the messages are just flagged. That's fine, and it's faster on big mailboxes.

Rule Ideas Worth Stealing

What you want Rule
Newsletters out of your face, keepable {"query": 'FROM "news@example.com"', "action": "archive"}
Receipts filed for tax season {"query": 'SUBJECT "receipt"', "action": "file", "label": "Receipts"}
Orders in one place {"query": 'FROM "orders@amazon.com"', "action": "file", "label": "Orders"}
Bank alerts never buried {"query": 'FROM "alerts@yourbank.com"', "action": "label", "label": "Bank"}
Old mail from one sender {"query": 'FROM "old-job@example.com" SINCE 01-Aug-2026', "action": "trash"}

Searches accept everything from the reading guide's search table — FROM, SUBJECT, SINCE, UNSEEN, combine them with spaces. Dates use DD-Mon-YYYY format. One caution: trash rules deserve extra dry-runs. Archiving is reversible in one click; trashing is not.

Make It a Morning Routine

Once you trust the rules, stop running the script by hand. Cron will do it for you — the Cron Jobs guide has the full walkthrough:

0 7 * * * /usr/bin/python3 /home/you/inbox-tidy.py >> /var/log/inbox-tidy.log 2>&1

Every morning at 7 AM, your inbox gets tidied before your first coffee. (Mac users: cron works there too. Windows users: Task Scheduler runs the same command.)

Gotchas (And Their Fixes)

Symptom Cause Fix
SEARCH command error: BAD [Could not parse command] Unbalanced quotes in your search string, like SUBJECT "receipt Every " needs a closing "
Label applied, but email still in inbox label only labels — it doesn't archive Use file instead of label
STORE command error on label Label doesn't exist yet mailbox.create("Name") first
Trash rule did nothing Passed the literal name "[Gmail]/Trash" Use find_folder() to get the real name
Nothing changed after the run Forgot expunge() Archive/trash take effect at expunge
imaplib.error on create Label already exists Wrap in try/except, or delete it first
Works on Gmail but not another provider X-GM-LABELS is Gmail-only Use plain IMAP folders elsewhere
Dry run shows 200 newsletters Your rules are too broad Tighten queries: add SINCE, sender, subject

What's Next?

You now own the full email pipeline: send, read, and organize. The fun part is what the pipeline can produce: a daily digest that scrapes the news, summarizes it, and emails you a five-minute briefing — that's the next article in this series. Meanwhile, the same "rules engine" idea transfers nicely to cleaning up your downloads folder, and if you'd rather be notified than emailed, the Telegram bot guide covers that.

Start small: one rule, dry-run, one real rule. An organized inbox is a weirdly satisfying thing to look at — even if Python is the one doing the folding.

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.