⟳ Automation

Automatically save email attachments with Python and IMAP

How to download email attachments automatically with Python and IMAP: search for messages, save files under safe names and mark the processed messages.

Supplier invoices, order confirmations, monthly statements, scans sent by the multifunction printer: many documents reach the office as email attachments. Opening each message, downloading the file and moving it to the right folder is a task repeated dozens of times a week. With the IMAP protocol and Python's standard library you can hand it all over to a script that reads the mailbox, saves the attachments and keeps track of what it has already done.

IMAP in brief

IMAP is the protocol email clients use to read messages while leaving them on the server. A script can use it exactly as Outlook or Thunderbird do: it connects, chooses a folder, searches for messages by criteria and downloads them. You need the IMAP server address, normally encrypted on port 993, and the credentials. For accounts with two-step verification you generate an app password, as for sending via SMTP; some business services instead require token-based authentication, to be configured according to the provider's documentation.

Connecting and searching for messages

import imaplib

with imaplib.IMAP4_SSL("imap.provider.com") as imap:
    imap.login(user, password)
    imap.select("INBOX")
    status, data = imap.uid("search", None, '(UNSEEN FROM "invoices@supplier.com")')
    found_uids = data[0].split()
    print(len(found_uids), "messages to process")

The search criterion follows IMAP syntax. Some useful examples:

Criterion Meaning
UNSEEN Unread messages
FROM "address" Specific sender
SUBJECT "invoice" Word in the subject
SINCE 01-Mar-2025 Received from that date onwards
(UNSEEN SUBJECT "order") Several conditions together

The date format uses the abbreviated English month, whatever the account's language. It is best to use UID identifiers, through imap.uid(...), rather than sequence numbers: sequence numbers change when messages are moved or deleted, while UIDs stay stable.

Extracting attachments

Each message is downloaded in raw form and parsed with the email module, which knows how to break down the parts of a complex message:

import email
from email import policy
from pathlib import Path

destination = Path("C:/Accounts/Incoming invoices")

for uid in found_uids:
    status, parts = imap.uid("fetch", uid, "(RFC822)")
    message = email.message_from_bytes(parts[0][1], policy=policy.default)
    for attachment in message.iter_attachments():
        name = attachment.get_filename()
        if not name or not name.lower().endswith(".pdf"):
            continue
        path = destination / safe_name(name)
        path.write_bytes(attachment.get_content())

With policy=policy.default the module returns modern objects, automatically decodes file names containing accented characters and offers iter_attachments(), which lists only the parts marked as attachments.

Safe file names

An attachment's name is chosen by whoever sends the message and must never be used as it is. It may contain characters that are invalid on Windows, be identical to that of a file already saved or, in malicious cases, include sequences such as ..\ to write outside the intended folder. A cleaning function solves all three problems:

import re
from datetime import date

def safe_name(name):
    name = Path(name).name                      # strips any path
    name = re.sub(r'[\\/:*?"<>|]', "_", name).strip(" .")
    return f"{date.today():%Y-%m-%d}_{name}"

Prefixing the date helps sorting and reduces collisions. To eliminate them entirely, check before saving whether the file exists and, if so, add a numeric suffix.

Marking processed messages

The script must remember which messages it has already handled, otherwise on the next run it will save the same attachments again. There are three strategies:

  1. mark as read: fetching with RFC822 already does this automatically on many servers; it is simple, but if a person opens the message before the script does, the script will no longer see it;
  2. move to a dedicated folder, for example "Processed": this is the clearest choice, because the mailbox shows at a glance what remains to be done. It is done with imap.uid("copy", uid, "Processed") followed by marking the message as deleted; many servers also support the MOVE command in a single step;
  3. record the UIDs in a local file: independent of the messages' state, but it needs careful upkeep.

In my work I prefer the second: it leaves a trace visible even to those who don't know the script, and lets you reprocess a message simply by moving it back into the inbox.

Attachment security

Automatically saving files received from the Internet calls for caution. Limit the script to expected senders and expected file types, such as PDFs, ignoring executables, compressed archives and documents with macros. Remember too that an email's sender can be forged: filtering by address reduces noise but does not guarantee authenticity. Saved files must never be opened automatically; they should be made available to people, who will treat them like any other attachment, with the company antivirus active on the destination folder.

In short

  • imaplib lets you search for and download messages; use UIDs and an encrypted connection.
  • The email module with policy.default extracts attachments and decodes their names.
  • Always clean file names and prefix the date to avoid collisions.
  • Move processed messages to a dedicated folder so they aren't handled twice.
  • Filter senders and file types, and never open attachments automatically.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. A computer science graduate, he combines rigorous technical analysis with attention to the people who use technology.

Read also