Email Reports Automatically with Python

The report is generated; now it has to reach people. The first version works on a laptop and fails in production:

import smtplib
server = smtplib.SMTP("smtp.example.com", 587)
server.sendmail("[email protected]", recipients, f"Subject: Report\n\n{summary}")

The message arrives with the subject as body text, non-ASCII characters mangled, no attachment, and — after the job is retried following an unrelated failure — three copies of the same report in everyone's inbox.

Root Cause

Three separate mistakes, all common. Building the message as a string means headers and body are separated by convention rather than structure, so a missing blank line puts the subject in the body and any non-ASCII character breaks the encoding; email.message.EmailMessage handles headers, encoding and MIME structure correctly. Attachments and an HTML body require a multipart message, which a string cannot express. And sending is a side effect with no natural idempotency: a job that retries after sending, or runs twice because a schedule fired late, sends the report again, because nothing records that it already went.

The structure of a report email The envelope carries from, to, subject and date headers, correctly encoded for non-ASCII content. Inside, a multipart alternative part holds both a plain text body and an HTML body, and the recipient's client chooses. Alongside it, a multipart mixed container holds the attachments, each with its own content type and filename. Building this with EmailMessage produces the right structure automatically; building it as a string does not. Headers: from, to, subject encoded for non-ASCII structure multipart/alternative plain text and HTML bodies not string concatenation multipart/mixed the attachments both Each attachment content type and filename so every client renders

Minimal Diagnostic

Check the delivery path before blaming the code: connectivity, authentication and what the server will accept.

# stdlib only
import os
import smtplib
import socket
import ssl

HOST = os.environ.get("SMTP_HOST", "smtp.example.com")
PORT = int(os.environ.get("SMTP_PORT", "587"))

def smtp_report() -> None:
    print(f"resolving {HOST} ...")
    try:
        print(f"  address: {socket.gethostbyname(HOST)}")
    except socket.gaierror as error:
        print(f"  DNS FAILED: {error}")
        return
    try:
        with smtplib.SMTP(HOST, PORT, timeout=15) as server:
            code, banner = server.connect(HOST, PORT)
            print(f"  banner: {code} {banner.decode(errors='replace')[:70]}")
            server.ehlo()
            print(f"  extensions: {sorted(server.esmtp_features)}")
            size = server.esmtp_features.get("size")
            print(f"  max message size: {int(size):,} bytes" if size else "  no SIZE advertised")
            if "starttls" in server.esmtp_features:
                server.starttls(context=ssl.create_default_context())
                server.ehlo()
                print("  STARTTLS: ok")
            user, password = os.environ.get("SMTP_USER"), os.environ.get("SMTP_PASSWORD")
            if user and password:
                server.login(user, password)
                print(f"  auth as {user}: ok")
            else:
                print("  no credentials supplied")
    except (smtplib.SMTPException, OSError) as error:
        print(f"  FAILED: {type(error).__name__}: {error}")

if __name__ == "__main__":
    smtp_report()
resolving smtp.example.com ...
  address: 198.51.100.24
  banner: 220 smtp.example.com ESMTP ready
  extensions: ['8bitmime', 'auth', 'size', 'starttls']
  max message size: 26,214,400 bytes
  STARTTLS: ok
  auth as [email protected]: ok

A 25MB limit is the number that matters most — a report with embedded charts and a CSV attachment passes it more easily than expected, and base64 encoding adds about a third to every attachment's size.

Fix: Build a Proper Message and Send Once

Use EmailMessage, attach by content type, and record what was sent.

# stdlib only
import hashlib
import json
import mimetypes
import os
import smtplib
import ssl
from dataclasses import dataclass
from datetime import date
from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid
from pathlib import Path

SENT_LOG = Path("state/sent.json")

@dataclass(frozen=True)
class Mailer:
    host: str
    port: int = 587
    user: str | None = None
    password: str | None = None
    sender: str = "[email protected]"
    sender_name: str = "Document pipeline"

    def send(self, message: EmailMessage) -> None:
        context = ssl.create_default_context()
        with smtplib.SMTP(self.host, self.port, timeout=30) as server:
            server.ehlo()
            if "starttls" in server.esmtp_features:
                server.starttls(context=context)                     # changed: encrypt before auth
                server.ehlo()
            if self.user and self.password:
                server.login(self.user, self.password)
            server.send_message(message)                             # changed: uses the message's headers

def build_report_email(mailer: Mailer, to: list[str], subject: str,
                       html_body: str, text_body: str,
                       attachments: list[Path] | None = None) -> EmailMessage:
    message = EmailMessage()
    message["From"] = formataddr((mailer.sender_name, mailer.sender))
    message["To"] = ", ".join(to)
    message["Subject"] = subject                                     # changed: encoded automatically
    message["Date"] = formatdate(localtime=True)
    message["Message-ID"] = make_msgid(domain=mailer.sender.split("@")[-1])
    message.set_content(text_body)                                   # changed: plain-text fallback
    message.add_alternative(html_body, subtype="html")
    for path in attachments or []:
        mime, _ = mimetypes.guess_type(path.name)
        maintype, subtype = (mime or "application/octet-stream").split("/", 1)
        message.add_attachment(path.read_bytes(), maintype=maintype,
                               subtype=subtype, filename=path.name)
    return message

def send_once(mailer: Mailer, message: EmailMessage, key: str) -> bool:
    """Returns True if sent, False if this key was already sent."""
    sent = json.loads(SENT_LOG.read_text()) if SENT_LOG.exists() else {}
    if key in sent:
        return False                                                 # changed: retries do not resend
    mailer.send(message)
    sent[key] = {"at": formatdate(localtime=True), "message_id": message["Message-ID"]}
    SENT_LOG.parent.mkdir(parents=True, exist_ok=True)
    SENT_LOG.write_text(json.dumps(sent, indent=2))
    return True

if __name__ == "__main__":
    mailer = Mailer(host=os.environ["SMTP_HOST"], user=os.environ.get("SMTP_USER"),
                    password=os.environ.get("SMTP_PASSWORD"))
    report = Path("out/report.html")
    message = build_report_email(
        mailer, ["[email protected]"],
        subject=f"Document pipeline — {date.today():%d %B %Y}",
        html_body=report.read_text(encoding="utf-8"),
        text_body="4,182 documents processed, 11 quarantined. Full report attached.",
        attachments=[Path("out/quarantined.csv")])
    key = hashlib.sha256(f"daily-report|{date.today()}".encode()).hexdigest()[:16]
    print("sent" if send_once(mailer, message, key) else "already sent today; not resending")

The idempotency key is the part that makes this safe inside a job with retries. Deriving it from what the message is — a daily report for a given date — rather than from when it was sent means a rerun an hour later recognises it as the same report. Make the key from the report's content hash instead where a corrected version genuinely should go out again.

Sending the HTML report as the body and the exceptions as a CSV attachment is usually the right split: the summary is read on a phone, and the detail is opened in a spreadsheet by whoever acts on it.

What goes wrong sending report email Building the message as a string puts the subject in the body and mangles non-ASCII characters. Sending only an HTML body means some clients and notification previews show nothing. Embedding large charts pushes the message past the server's size limit and it bounces. Sending inside a retried job delivers duplicates. Using a personal mailbox as the sender means the job stops when that person leaves. Putting every recipient in the To header exposes the list. Mistake Symptom Fix Message as a string subject in the body EmailMessage HTML only blank in some clients set_content then add_alternative Huge embedded charts bounced, over SIZE attach or link instead Send inside a retry duplicate emails idempotency key Personal sender address breaks when they leave a role mailbox All recipients in To list exposed a distribution address

Variant Fix 1: Staying Under the Size Limit

An HTML report with embedded chart images is often several megabytes, and base64 adds a third:

# stdlib only
from email.message import EmailMessage
from pathlib import Path

def sized(message: EmailMessage) -> int:
    return len(bytes(message))

def attach_within_limit(message: EmailMessage, paths: list[Path],
                        limit_bytes: int = 20_000_000) -> list[Path]:
    """Attach what fits; return what did not."""
    skipped = []
    for path in sorted(paths, key=lambda p: p.stat().st_size):
        probe = len(path.read_bytes()) * 4 // 3 + 200          # base64 overhead plus headers
        if sized(message) + probe > limit_bytes:
            skipped.append(path)
            continue
        message.add_attachment(path.read_bytes(), maintype="application",
                               subtype="octet-stream", filename=path.name)
    return skipped

Attaching smallest-first fits the most files rather than the first ones listed. Anything skipped should be named in the body with where to find it — a report that silently drops the attachment people need is worse than one that bounces, because the bounce at least gets noticed.

Variant Fix 2: An API Instead of SMTP

Where the organisation uses a transactional email service, its API avoids SMTP's operational baggage entirely:

# stdlib only
import json
import os
import urllib.error
import urllib.request

def send_via_api(message_dict: dict, endpoint: str, token: str, timeout: int = 30) -> str:
    request = urllib.request.Request(
        endpoint, data=json.dumps(message_dict).encode(),
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            return json.loads(response.read()).get("id", "")
    except urllib.error.HTTPError as error:
        detail = error.read().decode(errors="replace")[:300]
        raise RuntimeError(f"send failed {error.code}: {detail}") from error

The practical advantages are a clear error body when a send is rejected, delivery status available afterwards, and no dependence on port 587 being open from wherever the job runs — which it often is not on a cloud runner. The trade is one more credential to manage and a vendor in the path.

Sending the report from a scheduled job Generate the report and any attachments first, so the email step has nothing to compute. Build the message with both a plain text and an HTML body and attach what fits within the server's size limit. Derive an idempotency key from what the report is, such as its kind and date. Check whether that key has already been sent and stop if so. Send, then record the key and the message id. A failure leaves no record, so the next attempt sends normally. Generate the report and its attachments Build the message text plus HTML Fit within the size limit attach smallest first Derive the key kind plus date Send if not already sent no duplicates Record key and message id after success only

Deliverability and Who It Comes From

A report that reliably lands in a spam folder has not been delivered. Three things decide this more than the content does.

Send from a domain whose SPF record authorises the sending server, and where DKIM signing is in place — with a transactional service that is configuration rather than code, and with a company relay it is usually already done. Use a role address such as pipeline@ rather than a person's mailbox, so the job survives staff changes and replies go somewhere monitored. And set a Reply-To pointing at a real destination: an automated report generates questions, and a no-reply address turns each one into a separate search for whoever owns the job.

Keep the subject line stable and informative. Document pipeline — 17 September 2026 sorts and filters usefully; a subject that changes shape depending on the outcome defeats every rule anyone writes to file them.

Verification

Assert the message is well-formed and the send is idempotent, without sending anything.

# stdlib only
from email import policy
from email.parser import BytesParser
from pathlib import Path

def verify_message(message, expected_attachments: int, max_bytes: int = 20_000_000) -> None:
    raw = bytes(message)
    assert len(raw) < max_bytes, f"message is {len(raw):,} bytes, over the {max_bytes:,} limit"
    parsed = BytesParser(policy=policy.default).parsebytes(raw)
    for header in ("From", "To", "Subject", "Date", "Message-ID"):
        assert parsed[header], f"missing header {header}"
    body = parsed.get_body(preferencelist=("plain",))
    html = parsed.get_body(preferencelist=("html",))
    assert body is not None and body.get_content().strip(), "no plain-text body"
    assert html is not None and html.get_content().strip(), "no HTML body"
    attachments = [part.get_filename() for part in parsed.iter_attachments()]
    assert len(attachments) == expected_attachments, \
        f"{len(attachments)} attachment(s) {attachments}, expected {expected_attachments}"
    assert all(attachments), "an attachment has no filename"
    assert "{{" not in html.get_content(), "unrendered template tag in the HTML body"
    print(f"{len(raw):,} bytes, {len(attachments)} attachment(s), text and HTML bodies present")

def verify_idempotent(mailer, message, key: str) -> None:
    calls = []
    mailer_stub = type(mailer)(**{**mailer.__dict__})
    object.__setattr__(mailer_stub, "send", lambda m: calls.append(m))
    assert send_once(mailer_stub, message, key) is True, "first send did not happen"
    assert send_once(mailer_stub, message, key) is False, "second send was not suppressed"
    assert len(calls) == 1, f"send called {len(calls)} times"

Parsing the message back from its own bytes is what catches structural mistakes — a body added in the wrong order, an attachment without a filename, a header that failed to encode. Testing idempotency with a stub means the duplicate-suppression logic is covered without a mail server and without the risk of a test sending a real report to a real distribution list.

FAQ

Why does my HTML body show as raw markup? It was added with set_content rather than add_alternative(..., subtype="html"), so it is being sent as plain text.

Can I embed images rather than attach them? Yes, as data URIs in the HTML — see build HTML reports with Jinja2. Some clients block remote images but render data URIs.

Should failures email too? Failures belong in an alerting channel, grouped by cause. See retries and failure alerts.

Where should the sent log live? Next to the job's other state. A file is fine for a daily report; use the pipeline's database if several workers may send concurrently.

Part of Generating Reports from Pipeline Data.