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

What the message actually contains A multipart mixed message holds three parts. The first is the body, sent as plain text with an optional HTML alternative. The second is a PDF attachment declared as application pdf and base64 encoded. The third is an Excel attachment declared with the spreadsheetml content type and also base64 encoded. A note records that attaching binary content as text is what produces a zero byte or corrupt attachment on arrival. multipart/mixed body part text/plain + text/html alternative attachment 1 application/pdf base64, filename set attachment 2 …spreadsheetml.sheet base64, filename set Attaching binary content as text produces a 0 KB or corrupt file read the file with "rb", pass bytes, and declare maintype and subtype explicitly The message can be perfectly valid while the attachment inside it is unusable

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.

Deciding how to deliver, and how to fail Two decisions. First, if the base64 encoded attachment size is under the provider limit, attach the file; otherwise upload it and send a signed link. Second, when the send fails, permanent errors such as authentication or a refused recipient stop immediately, while transient errors such as a dropped connection or rate limiting are retried with exponential backoff. Report ready PDF or XLSX encoded < 10 MB? yes attach the file no upload + link gives an access log Permanent: stop auth failure, refused sender or recipient Transient: retry dropped connection, rate limit, timeout

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.

Base64 inflation against the provider limit Three attachments are shown at their size on disk and at their encoded size, which is about one third larger. A four megabyte PDF encodes to five point three. An eight megabyte spreadsheet encodes to ten point seven, just over a ten megabyte limit. A twelve megabyte archive encodes to sixteen, well over. The limit applies to the encoded size, so the on-disk figure is misleading. On disk against encoded, versus a 10 MB limit 10 MB limit report.pdf 4 MB 5.3 MB encoded data.xlsx 8 MB 10.7 MB — rejected pack.zip 12 MB Check the encoded size, not the file size, before attaching

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.

Part of Generating Reports from Pipeline Data.