Guide
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
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.
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.
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.