Guide

Automating Reporting WorkflowsDeep dive

Emailing Excel Reports with smtplib

Attach and send .xlsx reports from Python using only the stdlib smtplib and email modules — with the modern EmailMessage API, TLS, and clear error handling.

Once a reporting script has produced a workbook, the last step is getting it to the people who need it. Python's standard library handles this with no third-party packages: the email module builds a MIME message with the spreadsheet attached, and smtplib opens an authenticated, encrypted connection to your mail server and sends it. This page shows the modern EmailMessage approach, the connection details that differ between ports 587 and 465, and the errors you will actually hit. It is the delivery stage of the broader Automating Reporting Workflows pipeline.

What you need

  • Python 3 — the EmailMessage API used here landed in 3.6, so any currently supported version works.
  • SMTP credentials — host, port, username, and a password. Most providers (Gmail, Outlook/Microsoft 365, Yahoo) block your normal account password for scripts and require an app password generated from the account's security settings.
  • An .xlsx file to send — produced upstream by your report job. The examples build a tiny one so they stand alone.
  • Outbound access to the SMTP port: 587 for STARTTLS or 465 for implicit SSL.
Bash
pip install pandas openpyxl   # only needed to generate the sample workbook

Build a sample report

So the attachment step has a real file to work with, generate a small workbook first. In your own pipeline this file comes from the generate stage of the workflow.

Python
import pandas as pd

report = pd.DataFrame({
    "region": ["North", "South", "West"],
    "revenue": [159.92, 247.50, 137.44],
})
report.to_excel("regional_report.xlsx", sheet_name="Summary", index=False)
print("Wrote regional_report.xlsx")

Build the message with EmailMessage

The email.message.EmailMessage class (Python 3.6+) is the current, recommended way to compose mail — it replaces the older MIMEMultipart/MIMEBase assembly with a single object. Set the headers, set the body with set_content(), then attach the workbook with add_attachment(), passing the MIME type split into maintype and subtype. For .xlsx, the subtype is vnd.openxmlformats-officedocument.spreadsheetml.sheet:

Python
from email.message import EmailMessage
from pathlib import Path

def build_report_email(sender, recipients, subject, body, attachment_path):
    """Compose an EmailMessage with an .xlsx attachment. No network I/O."""
    path = Path(attachment_path)
    if not path.is_file() or path.stat().st_size == 0:
        raise FileNotFoundError(f"Attachment missing or empty: {path}")

    msg = EmailMessage()
    msg["From"] = sender
    msg["To"] = ", ".join(recipients)
    msg["Subject"] = subject
    msg.set_content(body)

    data = path.read_bytes()
    msg.add_attachment(
        data,
        maintype="application",
        subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        filename=path.name,
    )
    return msg

msg = build_report_email(
    sender="reports@example.com",
    recipients=["alice@example.com", "bob@example.com"],
    subject="Regional Revenue Summary",
    body="Hi team,\n\nThe latest regional report is attached.\n\nThanks.",
    attachment_path="regional_report.xlsx",
)
print(msg["Subject"], "->", msg["To"])
print("attachments:", [p.get_filename() for p in msg.iter_attachments()])

add_attachment() handles base64 encoding and the Content-Disposition: attachment header for you, so the binary workbook survives transport without corruption.

Send over a secure connection

The connection differs by port. Port 587 starts as plaintext and upgrades to TLS with starttls() before you log in; port 465 is encrypted from the first byte via SMTP_SSL. In both cases, log in and then hand the whole message to send_message(), which reads the From/To headers for you. A context manager guarantees the socket closes even on error:

Python
import smtplib

def send_report(msg, host, port, username, password):
    """Send a prepared EmailMessage. Requires a reachable SMTP server."""
    if port == 465:
        with smtplib.SMTP_SSL(host, port, timeout=30) as server:
            server.login(username, password)
            server.send_message(msg)
    else:  # 587 (or 25): upgrade to TLS, then authenticate
        with smtplib.SMTP(host, port, timeout=30) as server:
            server.ehlo()
            server.starttls()
            server.ehlo()
            server.login(username, password)
            server.send_message(msg)

# Example call (do not run without real, reachable credentials):
# send_report(msg, "smtp.gmail.com", 587, "reports@example.com", APP_PASSWORD)

Pull credentials from the environment rather than literals — for example os.getenv("SMTP_PASSWORD") — so secrets never live in the script or in version control.

Hardening the send

Two refinements turn the example into something deployable:

  • Retry transient failures. Network blips and temporary throttling are common. Wrap the send in a short retry loop with growing delays, and only retry on transient errors (smtplib.SMTPException, OSError) — never on SMTPAuthenticationError, which will never succeed on retry.
  • Sanitize header inputs. If a subject is built from user or upstream data, strip newlines first: subject.replace("\n", " ").replace("\r", " "). A bare newline in a header value raises a ValueError when the header is set. There is no BadHeaderError in the standard library.
Python
import time, smtplib

def send_with_retry(send_fn, attempts=3, base_delay=2):
    """Retry a no-arg send callable on transient SMTP/socket errors only."""
    for attempt in range(1, attempts + 1):
        try:
            send_fn()
            return True
        except smtplib.SMTPAuthenticationError:
            raise  # bad credentials never recover — fail immediately
        except (smtplib.SMTPException, OSError) as exc:
            if attempt == attempts:
                raise
            wait = base_delay * (2 ** (attempt - 1))
            print(f"Send attempt {attempt} failed ({exc}); retrying in {wait}s")
            time.sleep(wait)
    return False

Sending to undisclosed recipients

To hide the recipient list from each reader, drop the To header and use Bcc instead. send_message() reads To, Cc, and Bcc to determine the envelope recipients, then strips the Bcc header before transmission:

Python
from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = "reports@example.com"
msg["Bcc"] = ", ".join(["alice@example.com", "bob@example.com"])
msg["Subject"] = "Regional Revenue Summary"
msg.set_content("Report attached.")
print("Bcc set; To header present:", "To" in msg)

Common errors and fixes

ErrorCauseFix
SMTPAuthenticationError (e.g. 535)Account password used where an app password is required, or 2FA blocking the loginGenerate an app-specific password in the provider's security settings and use it as password.
ConnectionRefusedError / timeoutFirewall blocking outbound SMTP, or wrong host/portConfirm port 587 or 465 is open outbound; verify the host name and that the port matches the encryption mode.
SMTPSenderRefused / SMTPRecipientsRefusedSender not authorized to relay, or a malformed addressAuthenticate as a mailbox allowed to send as From; validate addresses before sending.
ValueError on header assignmentNewline or carriage return in Subject/From/ToStrip \n and \r from any header built from external input.
Message size exceeds fixed limitAttachment over the provider's cap (often 20–25 MB)Zip the workbook with zipfile, or upload it to storage and send a link instead.

Picking 587 vs 465

Both ports give you an encrypted session; the difference is when encryption starts. Port 587 with STARTTLS is the modern submission standard and is the safer default — start there. Port 465 (implicit SSL) is widely supported and equally secure in practice; use it if your provider documents it or if STARTTLS is blocked on your network. Plain port 25 is for server-to-server relay and is usually blocked for authenticated submission, so avoid it for sending reports.

Conclusion

The stdlib email + smtplib stack handles all the mechanics: EmailMessage composes and encodes the attachment, smtplib.SMTP (or SMTP_SSL) handles the encrypted handshake, and send_message() routes delivery using the message headers. What it does not do is handle transient failures, credential management, or provider-specific quirks — those are your responsibility. Read credentials from environment variables, wrap the send in a retry loop for transient errors, and test against your specific provider's SMTP host and port before scheduling.

Where to go next

With delivery working, schedule the whole job — generate then send — to run unattended with Scheduling Python Excel Scripts with Cron. For the upstream stages that produce the workbook you attach here, see Automating Reporting Workflows and Writing DataFrames to Excel with pandas.