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.
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.
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.
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.
Related
- Generating Reports from Pipeline Data — the reporting workflow end to end
- Build HTML Reports with Jinja2 Templates — producing the body this sends
- Add Retries and Failure Alerts to Automation Jobs — alerting, as distinct from reporting
- Processing Email Attachments Automatically — the same protocol in the other direction