Emailing Excel Reports with smtplib
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
EmailMessageAPI 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
.xlsxfile 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.
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.
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:
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:
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 onSMTPAuthenticationError, 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 aValueErrorwhen the header is set. There is noBadHeaderErrorin the standard library.
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:
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
| Error | Cause | Fix |
|---|---|---|
SMTPAuthenticationError (e.g. 535) | Account password used where an app password is required, or 2FA blocking the login | Generate an app-specific password in the provider's security settings and use it as password. |
ConnectionRefusedError / timeout | Firewall blocking outbound SMTP, or wrong host/port | Confirm port 587 or 465 is open outbound; verify the host name and that the port matches the encryption mode. |
SMTPSenderRefused / SMTPRecipientsRefused | Sender not authorized to relay, or a malformed address | Authenticate as a mailbox allowed to send as From; validate addresses before sending. |
ValueError on header assignment | Newline or carriage return in Subject/From/To | Strip \n and \r from any header built from external input. |
Message size exceeds fixed limit | Attachment 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.
Credentials, TLS and the settings that decide delivery
Three configuration choices cause most email failures, and none of them is about 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.
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:
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:
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.
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.
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.
Related
- Up: Automating Reporting Workflows — the full pipeline this delivery stage belongs to.
- Go deeper here: Send an Excel Report to Multiple Recipients in Python — sending one report to a whole distribution list, visibly, via Bcc, or as personalized per-recipient mail.
- Schedule it: Scheduling Python Excel Scripts with Cron — run the generate-then-send job unattended on a fixed timetable.
- Produce the workbook: Writing DataFrames to Excel with pandas and Building Multi-Sheet Excel Dashboards — the upstream stages that create the
.xlsxyou attach here. - Other formats: Exporting Excel Reports to PDF for a read-only attachment, or Generating Excel Reports from Templates to keep a branded layout.