Email Generated Reports with Python
The report builds correctly and the delivery step is where unattended jobs fail. The three failures look unrelated and are not:
smtplib.SMTPAuthenticationError: (535, b'5.7.8 Username and Password not accepted')
smtplib.SMTPServerDisconnected: Connection unexpectedly closed
# ...or the worst one: the send succeeds, and the attachment arrives as a 0 KB file
The first two are configuration. The third is a MIME problem, and it is the one that reaches recipients — a job that reports success while delivering an unopenable attachment is worse than one that crashes.
Root Cause
An email with an attachment is a MIME multipart document. The body is one part, each attachment another, and every part declares a content type and a transfer encoding. Get the encoding wrong — attach binary data as text, or read a PDF in text mode — and the bytes are mangled in transit while the message itself is perfectly valid.
Python's email.message.EmailMessage handles the encoding correctly when given bytes and a MIME type. The classic failure is building the message by hand with older MIMEMultipart recipes copied from the internet, several of which predate EmailMessage and get base64 handling subtly wrong for large files.
The SMTP failures have a different root: most providers no longer accept account passwords over SMTP. An app-specific password or an OAuth token is required, and the port decides whether TLS is negotiated up front (465, implicit) or after a STARTTLS command (587, explicit).
Prerequisites
pip install "pandas>=2.2,<3" # smtplib and email are standard library
# Credentials come from the environment, never from source
export SMTP_HOST=smtp.example.com
export SMTP_PORT=587
export SMTP_USER=[email protected]
export SMTP_PASSWORD='app-specific-password'
The Fix: Build the Message with EmailMessage
# Standard library only
import mimetypes
import os
import smtplib
import ssl
from email.message import EmailMessage
from pathlib import Path
def build_message(sender: str, recipients: list[str], subject: str,
body: str, attachments: list[Path]) -> EmailMessage:
"""Compose a multipart message with correctly typed attachments."""
message = EmailMessage()
message["From"] = sender
message["To"] = ", ".join(recipients)
message["Subject"] = subject
message.set_content(body)
for path in attachments:
if not path.exists():
raise FileNotFoundError(f"Attachment not found: {path}")
if path.stat().st_size == 0:
raise ValueError(f"Refusing to attach an empty file: {path}")
guessed, _encoding = mimetypes.guess_type(path.name)
maintype, subtype = (guessed or "application/octet-stream").split("/", 1)
message.add_attachment(
path.read_bytes(), # bytes, not text — this is the whole fix
maintype=maintype,
subtype=subtype,
filename=path.name,
)
return message
def send(message: EmailMessage, host: str, port: int, user: str, password: str,
timeout: int = 30) -> None:
"""Send over SMTP, choosing implicit or explicit TLS from the port."""
context = ssl.create_default_context()
try:
if port == 465:
with smtplib.SMTP_SSL(host, port, timeout=timeout, context=context) as server:
server.login(user, password)
server.send_message(message)
else:
with smtplib.SMTP(host, port, timeout=timeout) as server:
server.ehlo()
server.starttls(context=context)
server.ehlo()
server.login(user, password)
server.send_message(message)
except smtplib.SMTPAuthenticationError as exc:
raise RuntimeError(
f"SMTP authentication failed for {user} — an app-specific password is "
f"usually required: {exc}"
) from exc
except (smtplib.SMTPException, OSError) as exc:
raise RuntimeError(f"SMTP send failed via {host}:{port}: {exc}") from exc
if __name__ == "__main__":
msg = build_message(
sender=os.environ["SMTP_USER"],
recipients=["[email protected]"],
subject="Q3 reconciliation report",
body="The attached report covers 1 July to 30 September.\n\nGenerated automatically.",
attachments=[Path("out/q3_report.pdf"), Path("out/q3_data.xlsx")],
)
send(msg, os.environ["SMTP_HOST"], int(os.environ["SMTP_PORT"]),
os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
print("sent")
Three guards in build_message earn their place in an unattended job. A missing attachment raises before the send, so nobody receives an email promising a report that is not there. A zero-byte attachment raises too — that is the fingerprint of a generation step that failed silently, exactly the case that fix openpyxl chart not showing in Excel describes. And mimetypes.guess_type produces the right content type for .pdf, .xlsx, .csv and .docx without a hard-coded table.
Variant: An HTML Body with a Summary Table
A short summary in the body means recipients see the headline without opening the attachment.
# pip install "pandas>=2.2,<3"
import pandas as pd
from email.message import EmailMessage
def add_html_summary(message: EmailMessage, frame: pd.DataFrame, intro: str) -> None:
"""Set a plain-text body with an HTML alternative containing a small table."""
plain = f"{intro}\n\n{frame.to_string(index=False)}\n"
message.set_content(plain) # fallback for text-only clients
table = frame.to_html(index=False, border=0, justify="left")
html = f"""<html><body style="font-family:Inter,Arial,sans-serif;font-size:14px">
<p>{intro}</p>
{table}
<p style="color:#475569;font-size:12px">Generated automatically — do not reply.</p>
</body></html>"""
message.add_alternative(html, subtype="html")
if __name__ == "__main__":
summary = pd.DataFrame({"Region": ["EMEA", "AMER"], "Invoices": [412, 388],
"Value": ["£31,004", "£29,880"]})
add_html_summary(msg, summary, "Q3 reconciliation completed.")
Set the plain-text part first and add HTML as the alternative, in that order. Reversing it produces a message whose fallback is the HTML source, which is what turns up as a wall of markup in text-only clients and in some mobile previews.
Keep the summary to a handful of rows. Anything larger belongs in the attachment, where a generated Excel report can be filtered and sorted.
Variant: Retrying Transient Failures
Mail servers rate-limit and occasionally drop connections. Distinguish transient failures, which deserve a retry, from permanent ones, which do not.
# Standard library only
import smtplib
import time
PERMANENT = (smtplib.SMTPAuthenticationError, smtplib.SMTPRecipientsRefused,
smtplib.SMTPSenderRefused)
def send_with_retry(message, host, port, user, password,
attempts: int = 3, base_delay: float = 5.0) -> None:
"""Retry transient SMTP failures with exponential backoff; fail fast on permanent ones."""
last: Exception | None = None
for attempt in range(1, attempts + 1):
try:
send(message, host, port, user, password)
return
except RuntimeError as exc:
cause = exc.__cause__
if isinstance(cause, PERMANENT):
raise # credentials or address: retrying cannot help
last = exc
if attempt < attempts:
delay = base_delay * (2 ** (attempt - 1))
print(f"attempt {attempt} failed, retrying in {delay:.0f}s: {exc}")
time.sleep(delay)
raise RuntimeError(f"send failed after {attempts} attempt(s)") from last
Retrying an authentication failure three times is how an account gets locked, which is why the permanent list is checked first. The same distinction governs the wider job — see scheduling and logging automation jobs for where the retry policy belongs.
Variant: Large Attachments
Most providers cap messages at 10–25 MB after base64 encoding, which inflates the payload by about a third. A 20 MB report will be rejected almost everywhere.
# Standard library only
from pathlib import Path
BASE64_OVERHEAD = 4 / 3
def check_size(attachments: list[Path], limit_mb: float = 10.0) -> float:
"""Encoded size in MB, raising when it exceeds the provider's limit."""
raw = sum(path.stat().st_size for path in attachments)
encoded_mb = raw * BASE64_OVERHEAD / 1e6
if encoded_mb > limit_mb:
raise ValueError(
f"attachments encode to {encoded_mb:.1f} MB, over the {limit_mb} MB limit — "
f"upload and link instead"
)
return encoded_mb
Above the limit, send a link rather than the file: write the report to object storage and put a signed URL in the body. That also gives the delivery an access log, which an attachment never has.
Verification
Verify before sending, not after. Once a message leaves, nothing can be recalled.
# Standard library only
from email.message import EmailMessage
from pathlib import Path
def assert_message_deliverable(message: EmailMessage, expected_attachments: list[str],
max_mb: float = 10.0) -> None:
"""Check headers, attachment names, types and sizes before the send."""
for header in ("From", "To", "Subject"):
assert message[header], f"missing header: {header}"
parts = list(message.iter_attachments())
names = [part.get_filename() for part in parts]
assert names == expected_attachments, f"attachments are {names}, expected {expected_attachments}"
for part in parts:
payload = part.get_payload(decode=True)
assert payload, f"{part.get_filename()}: attachment decodes to nothing"
assert len(payload) > 100, f"{part.get_filename()}: only {len(payload)} byte(s)"
assert part.get_content_type() != "text/plain", (
f"{part.get_filename()}: attached as text — binary content will be corrupted"
)
total_mb = len(bytes(message)) / 1e6
assert total_mb < max_mb, f"message is {total_mb:.1f} MB, over the {max_mb} MB limit"
print(f"message verified: {len(parts)} attachment(s), {total_mb:.2f} MB")
if __name__ == "__main__":
assert_message_deliverable(msg, ["q3_report.pdf", "q3_data.xlsx"])
get_payload(decode=True) round-trips the base64, so this assertion proves the attachment can be reconstructed — which is the check that catches the zero-byte failure before a recipient does. For a full dress rehearsal, run a local SMTP debugging server (python -m aiosmtpd -n -l localhost:8025) and send to it in CI: the message is written to stdout, and the pipeline is exercised end to end without anyone receiving anything.
FAQ
Why does authentication fail with the correct password? Most providers now refuse account passwords over SMTP. Create an app-specific password, or use OAuth. The error text is identical either way, which is why the wrapper above says so explicitly.
Port 587 or 465?
587 with STARTTLS is the current recommendation and what most providers document. 465 with implicit TLS still works widely. Both are encrypted; port 25 unauthenticated is not, and should not be used for this.
How do I stop reports going to spam? Send from a domain with SPF and DKIM configured, use a consistent sender address, and give the message a real subject and body. A bare message with one attachment and no text is a strong spam signal.
Can I send from a scheduled container without storing a password? Prefer a provider API with a scoped token, or an SMTP relay that authorises by network identity. If a password is unavoidable, inject it as an environment secret at runtime — never in the image or the repository.
Should the job fail when the email fails? Yes, loudly — but only after the report itself has been written to durable storage. Generate, persist, then deliver, so a delivery failure never destroys the artefact.
Related
- Generating Reports from Pipeline Data — building the reports this sends
- Scheduling and Logging Automation Jobs — where retries and alerting belong
- Automating Excel Report Generation — producing the spreadsheet attachment
- Generating PDF Reports Dynamically — producing the PDF attachment
- Validating Document Data with Schemas — making sure the figures are worth sending