Teams and automated systems frequently receive important files as email attachments: nightly report exports, invoice PDFs, log bundles from monitoring tool
Teams and automated systems frequently receive important files as email attachments: nightly report exports, invoice PDFs, log bundles from monitoring tools, CSV data feeds from partners, and so on. Manually opening a mailbox, finding the right messages, and saving each attachment is tedious and error-prone, especially when the same process must run daily. A script that connects to an IMAP server, scans a specific folder, and downloads every attachment to a local directory solves this cleanly and can be scheduled with cron or a CI job.
This tutorial builds a robust, production-ready downloader using the imapclient library. It supports authentication via password or environment variable, filters by folder, skips messages already processed (via a state file), handles duplicate filenames safely, and works with any standards-compliant IMAP server (Gmail, Outlook/Exchange, Dovecot, etc.) that allows IMAP access.
The script follows a linear pipeline with clear separation of concerns:
imapclient.IMAPClient handles the low-level IMAP protocol, TLS negotiation, and login. We use ssl=True by default and fall back to STARTTLS if configured.--folder argument (default INBOX) is selected read-only so the script never marks messages as seen or modifies the mailbox.--since date filter and fetch only the RFC822 body plus UID. Fetching metadata first keeps memory usage bounded.email module parses each raw message. We walk the MIME tree, identify parts with a Content-Disposition: attachment header (or a filename), decode the payload, and write it to disk.Design decisions: read-only mailbox access (no side effects), a persistent state file instead of relying on \Seen flags (works with read-only folders), and streaming writes to avoid loading large attachments into memory twice.
#!/usr/bin/env python3
"""
Email Attachment Downloader via IMAP.
Connects to an IMAP server, scans a folder for messages, and downloads
every attachment to a local output directory. Tracks processed message
UIDs in a JSON state file so repeated runs are idempotent.
Usage:
python3 mail_attachments.py --host mail.example.com --user admin \
--folder Reports --out ./downloads/
"""
from __future__ import annotations
import argparse
import email
import json
import logging
import os
import re
import ssl
import sys
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from email.message import Message
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Iterable
from imapclient import IMAPClient
from imapclient.exceptions import IMAPClientError
# ---------------------------------------------------------------------------
# Configuration (environment variables with sensible defaults)
# ---------------------------------------------------------------------------
DEFAULT_HOST = os.environ.get("IMAP_HOST", "")
DEFAULT_PORT = int(os.environ.get("IMAP_PORT", "993"))
DEFAULT_USER = os.environ.get("IMAP_USER", "")
DEFAULT_PASSWORD = os.environ.get("IMAP_PASSWORD", "")
DEFAULT_FOLDER = os.environ.get("IMAP_FOLDER", "INBOX")
DEFAULT_OUT = os.environ.get("IMAP_OUT", "./downloads")
DEFAULT_STATE = os.environ.get("IMAP_STATE_FILE", ".mail_attachments_state.json")
DEFAULT_USE_SSL = os.environ.get("IMAP_USE_SSL", "true").lower() in ("1", "true", "yes")
# Characters that are illegal in filenames on Windows/macOS/Linux.
_UNSAFE_FILENAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("mail_attachments")
@dataclass
class DownloadStats:
"""Simple counter for the end-of-run summary."""
messages_scanned: int = 0
attachments_saved: int = 0
attachments_skipped: int = 0
errors: int = 0
saved_paths: list[Path] = field(default_factory=list)
def sanitize_filename(name: str) -> str:
"""Return a filesystem-safe version of an attachment filename."""
name = _UNSAFE_FILENAME.sub("_", name).strip().strip(".")
return name or "attachment.bin"
def unique_path(directory: Path, filename: str) -> Path:
"""Return a non-colliding path inside *directory* for *filename*."""
candidate = directory / filename
if not candidate.exists():
return candidate
stem, suffix = candidate.stem, candidate.suffix
counter = 1
while True:
candidate = directory / f"{stem}_{counter}{suffix}"
if not candidate.exists():
return candidate
counter += 1
def load_state(state_file: Path) -> dict:
"""Load the JSON state file, tolerating a missing or corrupt file."""
if not state_file.exists():
return {"processed_uids": []}
try:
with state_file.open("r", encoding="utf-8") as fh:
data = json.load(fh)
if not isinstance(data.get("processed_uids"), list):
raise ValueError("malformed state")
return data
except (json.JSONDecodeError, ValueError, OSError) as exc:
log.warning("Could not read state file %s (%s); starting fresh", state_file, exc)
return {"processed_uids": []}
def save_state(state_file: Path, state: dict) -> None:
"""Persist the state file atomically to avoid partial writes."""
tmp = state_file.with_suffix(state_file.suffix + ".tmp")
tmp.write_text(json.dumps(state, indent=2), encoding="utf-8")
tmp.replace(state_file)
def iter_attachments(message: Message) -> Iterable[tuple[str, bytes]]:
"""Yield (filename, payload) for every attachment part in *message*."""
for part in message.walk():
if part.get_content_maintype() == "multipart":
continue
disposition = (part.get_content_disposition() or "").lower()
filename = part.get_filename()
if disposition != "attachment" and not filename:
continue
if filename:
# RFC 2231 / RFC 2047 encoded filenames are decoded by get_filename().
filename = email.utils.collapse_rfc2231_value(filename) if "=" in filename else filename
else:
filename = "attachment.bin"
payload = part.get_payload(decode=True)
if payload is None:
continue
yield sanitize_filename(filename), payload
def download_attachments(
host: str,
user: str,
password: str,
folder: str,
out_dir: Path,
state_file: Path,
port: int = 993,
use_ssl: bool = True,
since_days: int | None = None,
) -> DownloadStats:
"""Connect to IMAP, scan *folder*, and save all attachments to *out_dir*."""
stats = DownloadStats()
out_dir.mkdir(parents=True, exist_ok=True)
state = load_state(state_file)
processed: set[int] = set(state.get("processed_uids", []))
ssl_context = ssl.create_default_context()
log.info("Connecting to %s:%s (ssl=%s)", host, port, use_ssl)
try:
with IMAPClient(host, port=port, ssl=use_ssl, ssl_context=ssl_context) as client:
client.login(user, password)
log.info("Authenticated as %s", user)
# Read-only selection: never mutate the mailbox or mark as seen.
client.select_folder(folder, readonly=True)
log.info("Selected folder %r", folder)
# Build the IMAP search criteria.
criteria: list = ["ALL"]
if since_days is not None:
since_date = (datetime.now() - timedelta(days=since_days)).strftime("%d-%b-%Y")
criteria = ["SINCE", since_date]
log.info("Filtering messages since %s", since_date)
uids = client.search(criteria)
log.info("Found %d message(s)", len(uids))
for uid in uids:
stats.messages_scanned += 1
if uid in processed:
log.debug("Skipping UID %s (already processed)", uid)
continue
try:
response = client.fetch([uid], ["RFC822"])
raw = response.get(uid, {}).get(b"RFC822")
if not raw:
log.warning("UID %s returned no body; skipping", uid)
stats.errors += 1
continue
msg = email.message_from_bytes(raw)
subject = msg.get("Subject", "(no subject)")
msg_date = msg.get("Date", "")
try:
date_str = parsedate_to_datetime(msg_date).isoformat()
except (TypeError, ValueError):
date_str = msg_date
for filename, payload in iter_attachments(msg):
target = unique_path(out_dir, filename)
target.write_bytes(payload)
stats.attachments_saved += 1
stats.saved_paths.append(target)
log.info(
"Saved %s (%d bytes) from UID %s [%s, %s]",
target.name,
len(payload),
uid,
subject,
date_str,
)
processed.add(uid)
except (IMAPClientError, OSError) as exc:
stats.errors += 1
log.error("Failed to process UID %s: %s", uid, exc)
# Persist state only after a successful pass.
state["processed_uids"] = sorted(processed)
save_state(state_file, state)
log.info("State saved to %s", state_file)
except IMAPClientError as exc:
log.error("IMAP error: %s", exc)
raise
except ssl.SSLError as exc:
log.error("TLS error: %s", exc)
raise
return stats
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Download email attachments from an IMAP folder.",
)
parser.add_argument("--host", default=DEFAULT_HOST, help="IMAP server hostname")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="IMAP port (default 993)")
parser.add_argument("--user", default=DEFAULT_USER, help="IMAP username")
parser.add_argument(
"--password",
default=DEFAULT_PASSWORD,
help="IMAP password (prefer IMAP_PASSWORD env var over CLI)",
)
parser.add_argument("--folder", default=DEFAULT_FOLDER, help="Mailbox folder to scan")
parser.add_argument("--out", default=DEFAULT_OUT, help="Output directory for attachments")
parser.add_argument("--state-file", default=DEFAULT_STATE, help="Path to JSON state file")
parser.add_argument(
"--since-days",
type=int,
default=None,
help="Only scan messages from the last N days",
)
parser.add_argument(
"--no-ssl",
action="store_true",
help="Disable implicit SSL (use STARTTLS-style plain connect)",
)
parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
if args.verbose:
log.setLevel(logging.DEBUG)
missing = [name for name, value in (("host", args.host), ("user", args.user)) if not value]
if missing:
log.error("Missing required argument(s): %s", ", ".join(missing))
return 2
if not args.password:
log.error("No password provided (set IMAP_PASSWORD or use --password)")
return 2
try:
stats = download_attachments(
host=args.host,
user=args.user,
password=args.password,
folder=args.folder,
out_dir=Path(args.out),
state_file=Path(args.state_file),
port=args.port,
use_ssl=not args.no_ssl,
since_days=args.since_days,
)
except (IMAPClientError, ssl.SSLError, OSError) as exc:
log.error("Fatal error: %s", exc)
return 1
log.info(
"Done. Scanned=%d Saved=%d Errors=%d",
stats.messages_scanned,
stats.attachments_saved,
stats.errors,
)
return 1 if stats.errors and not stats.attachments_saved else 0
if __name__ == "__main__":
sys.exit(main())
The script reads configuration from environment variables first, then allows command-line overrides. This makes it easy to run under cron, systemd, or Docker without embedding secrets in scripts.
| Variable | Default | Purpose |
|---|---|---|
IMAP_HOST |
"" |
IMAP server hostname (required) |
IMAP_PORT |
993 |
IMAP port; 993 for implicit SSL, 143 for STARTTLS |
IMAP_USER |
"" |
Login username (required) |
IMAP_PASSWORD |
"" |
Login password or app-specific password (required) |
IMAP_FOLDER |
INBOX |
Mailbox folder to scan |
IMAP_OUT |
./downloads |
Directory where attachments are written |
IMAP_STATE_FILE |
.mail_attachments_state.json |
JSON file tracking processed UIDs |
IMAP_USE_SSL |
true |
Whether to use implicit SSL |
Security note: never pass --password on the command line in shared environments — process listings (ps aux) expose it. Use IMAP_PASSWORD or a secrets manager. For Gmail, generate an App Password; for Exchange, ensure IMAP is enabled for the account.
Example 1 — Download report attachments from a shared mailbox:
export IMAP_HOST=mail.example.com
export IMAP_USER=admin
export IMAP_PASSWORD='s3cr3t'
python3 mail_attachments.py --folder Reports --out ./downloads/
Expected output:
2024-06-01 09:14:02 [INFO] Connecting to mail.example.com:993 (ssl=True)
2024-06-01 09:14:03 [INFO] Authenticated as admin
2024-06-01 09:14:03 [INFO] Selected folder 'Reports'
2024-06-01 09:14:03 [INFO] Found 12 message(s)
2024-06-01 09:14:04 [INFO] Saved Q2_revenue.csv (18432 bytes) from UID 4211 [Q2 report, 2024-06-01T08:00:00+00:00]
2024-06-01 09:14:04 [INFO] Done. Scanned=12 Saved=8 Errors=0
Example 2 — Only scan the last 7 days, verbose logging:
python3 mail_attachments.py \
--host imap.gmail.com --user me@example.com \
--folder "Invoices" --since-days 7 --out /var/data/invoices -v
Example 3 — Cron-safe invocation using only env vars:
# /etc/cron.d/mail-attachments
0 * * * * deploy IMAP_HOST=mail.example.com IMAP_USER=admin \
IMAP_PASSWORD_FILE=/run/secrets/imap \
/usr/bin/python3 /opt/scripts/mail_attachments.py --folder Reports --out /srv/reports
IMAPClientError is caught in main() and returns exit code 1; the password is never logged.ssl.SSLError is caught and reported distinctly, since misconfigured certificates are a common ops issue.with IMAPClient(...) context manager guarantees logout() is called even on exceptions.try/except, so one bad message does not abort the run.load_state() falls back to an empty state and logs a warning rather than crashing.unique_path() appends _1, _2, etc., so no attachment is ever silently overwritten.sanitize_filename() strips path separators and control characters to prevent directory traversal (e.g., ../../etc/passwd).save_state() writes to a .tmp file and atomically renames it, so an interrupted run won't leave a half-written state file.search() criteria with FROM, SUBJECT, or HEADER to process only messages from a specific partner.--extensions .csv,.pdf flag and skip parts whose filename doesn't match, useful for noisy mailboxes.client.copy(uid, "Processed") and client.add_flags(uid, [b"\\Deleted"]) to keep the source folder clean (requires a non-read-only selection).attachments_saved_total) for observability.target.write_bytes(payload) with an s3.put_object() or Azure Blob upload to archive attachments off-host.