How-to
Automating Reporting WorkflowsDeep dive

Download Excel Attachments from an Email Inbox with Python

Pick up the spreadsheets suppliers and colleagues email in, without anyone saving them by hand. Search a mailbox over IMAP, save only genuine Excel attachments under safe file names, decode international file names, skip messages already processed, and move handled mail to a folder so the job can run every few minutes.

Many reporting pipelines start with a person emailing a spreadsheet: a supplier's weekly stock file, a branch's sales return, a bank's statement export. Someone then saves the attachment into a folder where a script can find it — which works until they are on holiday. Python's standard library can do that step itself: imaplib searches the mailbox and fetches messages, and the email package pulls out the attachments. The care is in what to trust — file names from strangers, attachments that are not really workbooks, messages seen twice. This page is part of Emailing Excel Reports with smtplib, which covers the sending side.

From inbox to a folder of checked workbooks Five steps. Search the mailbox over IMAP for messages from known senders. Fetch each message without marking it read. Keep only attachments that are Excel files and really open as workbooks. Save them under a safe, unique name. Record the Message-ID and move the message to a Processed folder. Search FROM, SINCE Fetch BODY.PEEK[] Filter real Excel files Save safe unique name Mark done Message-ID, move

Prerequisites

Nothing to install for the mail handling — imaplib and email are in the standard library. openpyxl is used to check the saved files:

Bash
pip install openpyxl

You need an IMAP-enabled mailbox and credentials, ideally a dedicated address such as reports@ rather than a person's inbox. The examples below parse four sample messages: a supplier's stock file with a logo image alongside it, a statement with a German file name, an attachment whose name tries to escape the download folder, and a "workbook" that is not one.

Step 1 — Search and fetch messages over IMAP

Connect over TLS, select the mailbox and search. IMAP search criteria are strings; combine sender and date so the job only looks at recent mail from expected senders. Fetch with BODY.PEEK[], which downloads the message without marking it as read:

Python
import imaplib
import os

HOST = "imap.example.com"
with imaplib.IMAP4_SSL(HOST) as imap:
    imap.login(os.environ["IMAP_USER"], os.environ["IMAP_PASSWORD"])
    imap.select("INBOX")
    status, data = imap.uid("SEARCH", None, '(FROM "northline.example" SINCE "20-Sep-2026")')
    uids = data[0].split()
    raw_messages = []
    for uid in uids:
        status, parts = imap.uid("FETCH", uid, "(BODY.PEEK[])")
        raw_messages.append(parts[0][1])
print(len(raw_messages), "messages fetched")

Use imap.uid(...) rather than plain search and fetch: UIDs stay stable for a message, while sequence numbers shift whenever a message is deleted or moved. Keep credentials in environment variables or a secrets store, never in the script. Microsoft 365 no longer accepts passwords for IMAP and requires OAuth; there, the Microsoft Graph API is usually the easier route, and Gmail needs an app password or OAuth.

Step 2 — Parse messages and list their attachments

Parse each message with the modern email policy. Without policy=default, file names written in RFC 2231 or encoded-word form come back as raw encoded strings:

Python
from email import message_from_bytes
from email.policy import default

for raw in raw_messages:
    msg = message_from_bytes(raw, policy=default)
    print(msg["From"], "|", msg["Subject"], "|", msg["Message-ID"])
    for part in msg.iter_attachments():
        print("   ", repr(part.get_filename()), part.get_content_type(), len(part.get_content()), "bytes")

iter_attachments() yields each attached part, skipping the message body. The German file name Übersicht September.xlsx arrives decoded, ready to use. The third message's attachment is named ../../invoice.xlsx — a file name chosen by whoever sent the mail, which could point anywhere on disk if used as it is.

Step 3 — Keep only real Excel files, under safe names

Decide which attachments to keep by extension and by content: the extension and content type are claims made by the sender's mail client, and a .xlsx that is really something else will break the next step of the pipeline. An xlsx file is a zip archive, so a valid one starts with the bytes PK. Build the saved file name yourself, from a sanitised version of the original, and never let it contain a path:

Python
import re
from pathlib import Path

EXCEL = {".xlsx", ".xlsm", ".xls"}
inbox = Path("incoming"); inbox.mkdir(exist_ok=True)

def safe_name(name):
    base = Path(name.replace("\\", "/")).name
    base = re.sub(r"[^\w.\- ]", "_", base).strip(" .")
    return base or "attachment.xlsx"

def excel_attachments(msg):
    for part in msg.iter_attachments():
        name = part.get_filename() or ""
        ext = Path(name).suffix.lower()
        data = part.get_content()
        if ext not in EXCEL:
            continue
        looks_valid = data[:2] == b"PK" if ext in {".xlsx", ".xlsm"} else data[:4] == b"\xd0\xcf\x11\xe0"
        yield safe_name(name), data, looks_valid

for raw in raw_messages:
    msg = message_from_bytes(raw, policy=default)
    for name, data, ok in excel_attachments(msg):
        print(f"{name:28} {'ok' if ok else 'REJECTED: not a real workbook'}")

Path(...).name drops any directory part, so ../../invoice.xlsx becomes invoice.xlsx; the regular expression replaces characters that are unsafe on Windows or in shell commands, while \w keeps letters such as Ü. The logo is skipped by extension, and the fourth message's "workbook" fails the signature check. Legacy .xls files start with a different signature, which the check also handles.

What each check protects against A table of attachment checks. The extension check skips images and signatures, such as logo.png. The file signature check rejects files that claim to be workbooks but are not, such as a text file named stock_w39.xlsx. The safe name check strips directory parts and unsafe characters, turning ../../invoice.xlsx into invoice.xlsx. The Message-ID record skips messages already processed. Stops Example Extension non-Excel attachments logo.png File signature fake workbooks text named .xlsx Safe name path tricks and bad characters ../../invoice.xlsx Message-ID record processing twice the same mail next run

Step 4 — Save without overwriting, and skip messages already handled

The same supplier sends stock_w39.xlsx every week, sometimes twice. Prefix each saved file with the message date so names never collide, and keep a record of processed Message-IDs so a message is handled exactly once, however many times the job runs:

Python
import json
from email.utils import parsedate_to_datetime

seen_path = Path("processed_ids.json")
seen = set(json.loads(seen_path.read_text())) if seen_path.exists() else set()

saved, rejected = [], []
for raw in raw_messages:
    msg = message_from_bytes(raw, policy=default)
    msg_id = msg["Message-ID"]
    if msg_id in seen:
        continue
    stamp = parsedate_to_datetime(msg["Date"]).strftime("%Y%m%d-%H%M%S")
    for name, data, ok in excel_attachments(msg):
        if not ok:
            rejected.append((msg["From"], name))
            continue
        target = inbox / f"{stamp}_{name}"
        n = 1
        while target.exists():
            target = inbox / f"{stamp}_{n}_{name}"; n += 1
        target.write_bytes(data)
        saved.append(target.name)
    seen.add(msg_id)
seen_path.write_text(json.dumps(sorted(seen)))
print("saved:", saved)
print("rejected:", rejected)

Running the same loop again saves nothing, because every Message-ID is now recorded. Searching only for UNSEEN mail is a tempting alternative but a fragile one: anyone who opens the mailbox in a mail client marks messages as seen, and the job silently skips them.

Attachment names as sent, and as saved Left, the attachment names as the senders wrote them: stock_w39.xlsx, Übersicht September.xlsx, ../../invoice.xlsx and logo.png. Right, what is saved: the stock file and the statement with a date prefix, the invoice with its directory part removed, and the logo skipped because it is not an Excel file. A 1 name 2 stock_w39.xlsx 3 Übersicht Sept.xlsx 4 ../../invoice.xlsx 5 logo.png A 1 name 2 20260921_stock_w39.xlsx 3 20260921_Übersicht… 4 20260921_invoice.xlsx 5 (skipped) before after sanitise

The date prefix in the saved name also gives the folder a natural order: sorting by name sorts by the time the mail was sent. An allowlist of senders adds a further guard — the third message comes from an address nobody expects, and in production its attachment would be set aside for a person to check rather than fed into a report.

Step 5 — Check the workbooks open, then move the mail

A file that passes the signature check can still be damaged or password-protected. Open each saved file before handing it to the rest of the pipeline:

Python
from openpyxl import load_workbook

for name in saved:
    try:
        wb = load_workbook(inbox / name, read_only=True)
        print(f"{name}: sheets {wb.sheetnames}")
        wb.close()
    except Exception as exc:
        print(f"{name}: cannot open ({type(exc).__name__})")

Finally, move processed messages out of the inbox, so a person looking at it sees only what still needs attention. MOVE is an IMAP extension most servers support; without it, copy and then flag the original as deleted:

Python
with imaplib.IMAP4_SSL(HOST) as imap:
    imap.login(os.environ["IMAP_USER"], os.environ["IMAP_PASSWORD"])
    imap.select("INBOX")
    for uid in uids:
        if imap.uid("MOVE", uid, "Processed")[0] != "OK":
            imap.uid("COPY", uid, "Processed")
            imap.uid("STORE", uid, "+FLAGS", r"(\Deleted)")
    imap.expunge()

Send a short reply or alert for rejected attachments, so the sender knows the file did not arrive; Error Handling and Logging in Excel Automation covers alerts that people read.

Common pitfalls

  • Trusting attachment file names. Strip directories and unsafe characters; build the name yourself.
  • Trusting the extension. Check the file signature and try opening the workbook.
  • Parsing without policy=default. International file names stay encoded.
  • Relying on UNSEEN. Anyone reading the mailbox breaks it; record Message-IDs instead.
  • Sequence numbers instead of UIDs. They change when mail is moved; use imap.uid.
  • Accepting mail from anyone. Check senders against an allowlist before saving.
  • Passwords in code. Use environment variables, app passwords or OAuth.

Performance notes

Fetching whole messages downloads every attachment, including large images. For busy mailboxes, fetch BODYSTRUCTURE first to see which parts are spreadsheets, then fetch only those parts with BODY.PEEK[n]. Polling every few minutes is plenty; IMAP IDLE gives near-instant pickup if it matters.

Conclusion

Search the mailbox with imaplib using UIDs and specific criteria, fetch with BODY.PEEK[], and parse with policy=default so file names are decoded. Keep only attachments with an Excel extension and a matching file signature, save them under sanitised, date-prefixed names, and record each Message-ID so nothing is processed twice. Check that each workbook opens, then move the handled messages to a Processed folder.

Frequently asked questions

How do I download email attachments with Python? Connect with imaplib.IMAP4_SSL, log in, select the mailbox, search for the messages you want, fetch each one with BODY.PEEK, parse it with email.message_from_bytes using the default policy, and save the payload of each part returned by iter_attachments().

How do I get the file name of an attachment with accents or non-Latin characters? Parse the message with policy=email.policy.default. Then part.get_filename() decodes both RFC 2231 and encoded-word file names, returning "Übersicht September.xlsx" rather than "=?utf-8?q?...".

How do I avoid downloading the same attachment twice? Record each processed message's Message-ID and skip messages already in the record, and move handled messages to a separate folder. Searching only UNSEEN is fragile, because anyone reading the mailbox marks messages as seen.

Can I use a normal password for Gmail or Microsoft 365? Usually not any more. Microsoft 365 has retired basic authentication for IMAP, and Gmail needs an app password or OAuth. For Microsoft 365 the Graph API is often simpler than IMAP with OAuth.

More on email delivery

More on email delivery →