Download Excel Attachments from an Email Inbox with Python
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.
Prerequisites
Nothing to install for the mail handling — imaplib and email are in the standard library. openpyxl is used to check the saved files:
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:
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:
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:
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.
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:
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.
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:
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:
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.
Related
- Up one level: Emailing Excel Reports with smtplib — sending the reports out.
- Watch a Folder and Process New Excel Files — the next step after saving.
- Validate an Uploaded Excel File in a Web App — the same distrust for uploads.
- Send an Excel Report with the Microsoft Graph API — Microsoft 365 without IMAP.
More on email delivery
- How-toBuild a Credit Control Dunning Letter Run
- How-toAttach Multiple Excel Files to One Email in Python
- How-toEmail a Download Link Instead of an Excel Attachment
- How-toEmail an Excel Report with an HTML Summary Body
- How-toEmbed a Chart Image in the Body of a Report Email
- How-toPersonalise Report Emails with Jinja2 Templates