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.

Sending an Excel report with the email module and smtplib The email module builds an EmailMessage, the xlsx workbook is attached as a MIME part, then smtplib opens an encrypted STARTTLS or SSL connection to the mail server which delivers it to the recipient inbox. email module EmailMessage To, Subject, body attach report.xlsx MIME part smtplib STARTTLS / SSL port 587 / 465 delivered recipient inbox All stdlib: the email module builds and attaches; smtplib authenticates, encrypts, and sends. No third-party packages.

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 — typically writing a DataFrame to an .xlsx with pandas, or a richer multi-sheet dashboard — and the emailing code below never needs to know how it was made.

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

When encryption starts: port 587 STARTTLS versus port 465 implicit SSL Port 587 connects in plaintext, sends EHLO, then calls starttls() to upgrade the socket to TLS before ehlo, login and send_message run encrypted. Port 465 opens with SMTP_SSL so the connection is encrypted from the first byte, skipping the STARTTLS handshake entirely and going straight to login and send_message. Port 587 · STARTTLS Port 465 · implicit SSL SMTP(host, 587) ehlo() starttls() ehlo() login(user, pw) send_message(msg) Plaintext until STARTTLS upgrades the socket. SMTP_SSL(host, 465) no EHLO / STARTTLS handshake needed — TLS is already established login(user, pw) send_message(msg) Encrypted from the first byte. plaintext TLS-encrypted (safe to send credentials)

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.

Credentials, TLS and the settings that decide delivery

Three configuration choices cause most email failures, and none of them is about Python:

Three delivery settings and what each decides Port 587 with STARTTLS is the modern default. Port 465 opens an already-encrypted connection. Port 25 is unencrypted and widely blocked. Credentials belong in the environment, never in the script. port 587 · STARTTLS connect, then upgrade the usual default works with most providers port 465 · SMTP_SSL encrypted from byte one no upgrade step still common port 25 · plaintext blocked by most networks no encryption avoid entirely
Python
import os
import smtplib
from email.message import EmailMessage

def send(path, to, subject, body):
    host = os.environ["SMTP_HOST"]
    user = os.environ["SMTP_USER"]
    password = os.environ["SMTP_PASSWORD"]      # never in the source file

    msg = EmailMessage()
    msg["From"], msg["To"], msg["Subject"] = user, ", ".join(to), subject
    msg.set_content(body)
    with open(path, "rb") as handle:
        msg.add_attachment(
            handle.read(),
            maintype="application",
            subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            filename=path.split("/")[-1],
        )

    with smtplib.SMTP(host, 587, timeout=30) as smtp:
        smtp.starttls()
        smtp.login(user, password)
        smtp.send_message(msg)
    return msg["To"]

Reading credentials from the environment is not a formality: a password committed to a repository outlives the job it was written for, and rotating it becomes a search rather than a variable change. Setting a timeout matters just as much — without it a hung mail server blocks the job indefinitely, which on a scheduled run means a report that never completes and never alerts.

The MIME type is the third detail. Sending an .xlsx as application/octet-stream works, but some clients then refuse to preview it and some gateways treat it as suspicious; naming the real spreadsheet type avoids both.

Failures worth handling separately

An SMTP error code says whether retrying can help. A 4xx is temporary — greylisting, a busy server, a rate limit — and the same message will usually go through a minute later. A 5xx is permanent: a bad address, a rejected attachment, an authentication failure. Retrying a 5xx simply delays the alert, and retrying it forty times looks like abuse to the receiving server.

Python
import smtplib

try:
    send("march.xlsx", ["finance@example.com"], "March report", "Attached.")
except smtplib.SMTPRecipientsRefused as exc:
    print("permanent — fix the address:", exc.recipients)
except smtplib.SMTPAuthenticationError:
    print("permanent — credentials rejected")
except (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError, TimeoutError) as exc:
    print("transient — retry with backoff:", exc)

Separating them at the point of sending is what lets the retry layer do the right thing, and what stops a wrong address quietly consuming five attempts before anyone is told.

Size limits and the alternative to attaching

Mail servers commonly reject anything over 10–25 MB, and the rejection arrives after the report has been built. Checking first turns that into a decision rather than a failure:

Python
from pathlib import Path

LIMIT_MB = 10

def deliver(path, to):
    size_mb = Path(path).stat().st_size / 1_000_000
    if size_mb <= LIMIT_MB:
        return send(path, to, "Monthly report", "Attached.")
    link = publish_to_share(path)                      # copy to a shared location
    return send_link(to, link, size_mb)

Past the limit the honest options are a link to a shared folder or a smaller artefact — usually a summary workbook attached with the detail available elsewhere. That split is worth designing in early: it keeps delivery reliable, and it is nearly always what the recipients wanted anyway.

Write the message for the person, not the process

The body of a report email is the only part most recipients read. Three sentences — what this is, what changed, what to do — beat an attachment with no context, and they cost nothing to generate from figures the job already has:

Python
def body_for(month, rows, revenue, change_pct):
    direction = "up" if change_pct >= 0 else "down"
    return (
        f"{month} report attached.\n\n"
        f"{rows:,} orders, revenue £{revenue:,.0f} ({direction} {abs(change_pct):.1f}% on last month).\n\n"
        "The Summary tab has the regional breakdown; Detail has every line.\n"
        "Questions to the reporting team."
    )

Generating the summary from the same numbers that went into the workbook keeps the email and the attachment consistent — a body written by hand drifts within two months. It also means a recipient reading on a phone gets the headline without opening anything.

An email with context compared with a bare attachment A message stating the period, the headline figures and where to look lets a recipient act without opening the file. A bare attachment with no body forces every reader to open the workbook to learn whether anything changed. with context period and headline numbers what changed since last time where to look in the file bare attachment subject line only nothing to act on everyone opens it to check

Sending to many recipients

One message to a list is not the same as one message per recipient, and the difference matters for both privacy and deliverability. To exposes every address to everyone; Bcc hides them but is more likely to be filtered; separate messages are slower but personalise cleanly and keep one bad address from affecting the rest.

Python
import smtplib

def send_individually(paths_by_recipient, subject, host, user, password):
    sent, failed = [], []
    with smtplib.SMTP(host, 587, timeout=30) as smtp:      # one connection, many messages
        smtp.starttls()
        smtp.login(user, password)
        for recipient, path in paths_by_recipient.items():
            try:
                smtp.send_message(build_message(path, recipient, subject, user))
                sent.append(recipient)
            except smtplib.SMTPRecipientsRefused:
                failed.append(recipient)
    return sent, failed

Reusing one connection for the whole batch is the important detail: opening and authenticating per message is slow and looks like abuse to rate-limited providers. Catching the per-recipient refusal inside the loop keeps one wrong address from stopping the other forty.

Test the send path before it matters

An email job that has only ever been tested by sending to yourself will eventually meet a rejected address, an attachment over the limit, or credentials that expired. Sending a small test message to a real distribution list as part of a deployment — and asserting on the SMTP response rather than assuming success — turns those into problems found on a Tuesday afternoon instead of at six on reporting day.

Keep a record of what went out

Delivery is the step most likely to be questioned later — "did anyone send March?" — and the cheapest answer is a line per message in a small log: timestamp, recipients, attachment name, size and the SMTP response. That record turns an argument into a lookup, and it is what makes a re-send safe, because you can see whether the original ever arrived. Pair it with a per-period marker file so a retry cannot deliver the same report twice, and the whole delivery step becomes something you can reason about after the fact rather than something that either happened or did not.

Log what the run actually did

Row counts at each boundary, what was filled, what was quarantined, how long it took: five or six lines per run turn a question about a number into a lookup. The value is not in reading them on a good day but in having them on a bad one, when a total has moved and nobody can say whether the source changed, the cleaning changed, or a filter was added. A job that records its own behaviour is one that can be debugged after the fact rather than re-run and watched.

Separate building from sending

The most maintainable reporting jobs keep the code that produces a file and the code that delivers it in different functions with a path between them. That separation makes the delivery testable on its own, lets a failed send be retried without regenerating anything, and keeps the mail configuration out of the reporting logic entirely.

It also makes the failure modes distinguishable. A report that was built but not sent is a delivery problem someone can fix by re-running one step; a report that was never built is a data problem. Merging the two into one function turns both into the same unhelpful error, and it is the reason so many reporting scripts have to be re-run from the beginning to recover from a mail server hiccup.

Frequently asked questions

Why does login fail with SMTPAuthenticationError when my password is correct? Most providers (Gmail, Outlook, Yahoo) block your normal account password for scripts and require an app password generated from the account's security settings. Generate one and pass it as password; never retry on this error, since it will never succeed.

Which MIME subtype do I use for an .xlsx attachment? Pass maintype="application" and subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet" to add_attachment(). It handles the base64 encoding and Content-Disposition header so the binary workbook survives transport.

Should I use port 587 or 465? Both give an encrypted session; the difference is when encryption starts. Port 587 with starttls() is the modern submission default — start there; use 465 with SMTP_SSL if your provider documents it or STARTTLS is blocked.

How do I hide the recipient list from each reader? Drop the To header and set Bcc instead. send_message() reads To, Cc, and Bcc for the envelope, then strips the Bcc header before transmission.

Why does setting the subject raise a ValueError? A newline or carriage return in a header value triggers it — there is no BadHeaderError in the stdlib. Strip them first with subject.replace("\n", " ").replace("\r", " ") when the subject comes from external data.

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.