Test Report Emails with a Local SMTP Server
A report job's last step is the one most often tested by hand: run it, wait for the email, open the attachment. That is slow, it spams whoever is on the recipient list, and it does not happen for every change. Python can run a small SMTP server of its own that accepts mail and keeps it instead of delivering it. Point the job at that server and every message it sends can be inspected — recipients, subject, body, and the workbook itself — inside an automated test. This page, part of Emailing Excel Reports with smtplib, sets that up with aiosmtpd and pytest.
Prerequisites
pip install aiosmtpd pytest pandas openpyxl
report_mailer.py holds the code under test: send_report builds a message with a plain-text and HTML body and an Excel attachment made from a DataFrame, and sends it using an SmtpConfig. Its TLS and login steps are driven by the config — the one change that makes it testable.
Step 1 — Run a server that captures mail
An aiosmtpd handler is a class with an async handle_DATA method, called once per message. Store the envelope — the sender and recipients from the SMTP conversation — and the raw message bytes. Controller runs the server in a background thread:
from aiosmtpd.controller import Controller
class Capture:
def __init__(self):
self.messages = []
async def handle_DATA(self, server, session, envelope):
self.messages.append({"mail_from": envelope.mail_from, "rcpt_tos": list(envelope.rcpt_tos),
"data": envelope.content})
return "250 Message accepted"
handler = Capture()
controller = Controller(handler, hostname="127.0.0.1", port=8025)
controller.start()
print("listening on", controller.hostname, controller.port)
The server listens only on the loopback address, so nothing outside the machine can reach it. Nothing is delivered anywhere: messages exist only in handler.messages.
Step 2 — Send the report to it
Build a test configuration — local host, the captured port, no TLS, no login — and call the real sending function:
import pandas as pd
from report_mailer import SmtpConfig, send_report
test_cfg = SmtpConfig(host="127.0.0.1", port=8025, use_tls=False)
df = pd.DataFrame({"Region": ["North", "South", "East"], "Revenue": [48200, 39100, 51750]})
send_report(test_cfg, df, to=["finance@example.com", "ops@example.com"], bcc=["archive@example.com"])
captured = handler.messages[0]
print(captured["mail_from"], "->", captured["rcpt_tos"])
print(len(captured["data"]), "bytes received")
The envelope lists three recipients: the two in To and the archive address in Bcc. The code path is exactly the one production uses; only the configuration differs. In production, the same function receives SmtpConfig(host="smtp.example.com", username=..., password=...) with TLS on by default.
A local server is worth the few extra lines over mocking smtplib, because it tests the message rather than the method call:
Step 3 — Inspect what was sent
Parse the captured bytes back into a message and check every part: headers, both bodies, and the attachment, which should open as a workbook with the right data:
import io
from email import message_from_bytes
from email.policy import default
msg = message_from_bytes(captured["data"], policy=default)
print("Subject:", msg["Subject"])
print("To:", msg["To"], "| Bcc header present:", "Bcc" in msg)
print("text:", msg.get_body(preferencelist=("plain",)).get_content().strip())
print("html:", msg.get_body(preferencelist=("html",)).get_content().strip())
attachment = next(msg.iter_attachments())
print("attachment:", attachment.get_filename(), attachment.get_content_type())
print(pd.read_excel(io.BytesIO(attachment.get_content())))
The Bcc header is gone — smtplib.send_message removes it before sending, as it should, so blind-copied recipients stay hidden. That is why the test checks the envelope for Bcc recipients, not the headers. The subject's em dash survives the round trip, the total appears in both bodies, and the attachment reads back as the DataFrame that went in.
Step 4 — Turn it into pytest tests
Wrap the server in a fixture so each test gets a fresh one, and write tests for what matters: the right people get the report, the subject names the period, and the workbook contains the data:
import subprocess, sys, textwrap
controller.stop()
open("conftest.py", "w").write(textwrap.dedent('''
import socket
import pytest
from aiosmtpd.controller import Controller
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Capture:
def __init__(self):
self.messages = []
async def handle_DATA(self, server, session, envelope):
self.messages.append((list(envelope.rcpt_tos), envelope.content))
return "250 OK"
@pytest.fixture
def smtp_server():
handler = Capture()
port = free_port()
controller = Controller(handler, hostname="127.0.0.1", port=port)
controller.start()
yield port, handler
controller.stop()
'''))
open("test_report_mailer.py", "w").write(textwrap.dedent('''
import io
from email import message_from_bytes
from email.policy import default
import pandas as pd
from report_mailer import SmtpConfig, send_report
DF = pd.DataFrame({"Region": ["North", "South"], "Revenue": [48200, 39100]})
def send(port, **kw):
send_report(SmtpConfig("127.0.0.1", port, use_tls=False), DF, **kw)
def test_everyone_receives_it_including_bcc(smtp_server):
port, handler = smtp_server
send(port, to=["finance@example.com"], bcc=["archive@example.com"])
rcpts, _ = handler.messages[0]
assert sorted(rcpts) == ["archive@example.com", "finance@example.com"]
def test_bcc_is_not_visible(smtp_server):
port, handler = smtp_server
send(port, to=["finance@example.com"], bcc=["archive@example.com"])
msg = message_from_bytes(handler.messages[0][1], policy=default)
assert "Bcc" not in msg and "archive" not in msg["To"]
def test_workbook_matches_the_data(smtp_server):
port, handler = smtp_server
send(port, to=["finance@example.com"])
msg = message_from_bytes(handler.messages[0][1], policy=default)
part = next(msg.iter_attachments())
assert part.get_filename() == "sales_September_2026.xlsx"
pd.testing.assert_frame_equal(pd.read_excel(io.BytesIO(part.get_content())), DF)
def test_subject_and_total(smtp_server):
port, handler = smtp_server
send(port, to=["finance@example.com"])
msg = message_from_bytes(handler.messages[0][1], policy=default)
assert msg["Subject"] == "Sales report — September 2026"
assert "87,300" in msg.get_body(preferencelist=("plain",)).get_content()
'''))
result = subprocess.run([sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "test_report_mailer.py"],
capture_output=True, text=True)
print(result.stdout.strip().splitlines()[-1])
free_port() asks the operating system for a port nobody is using — binding to port 0 does that — so tests never clash with each other or with something already listening on 8025. aiosmtpd's Controller cannot take port 0 itself, because after starting it connects to the port it was given to check the server is up; hence the helper. Each test starts with an empty mailbox, and the server stops afterwards even if the test fails, so one broken test cannot leave a server running for the next.
Step 5 — Look at the emails by hand when you need to
Automated checks cover structure; how the HTML actually looks needs eyes. Run aiosmtpd from the command line and every message is printed to the terminal as it arrives:
python -m aiosmtpd -n -l 127.0.0.1:8025
For a mailbox with a web interface that renders HTML and opens attachments, run a local mail catcher such as Mailpit, which listens for SMTP on one port and shows the messages in a browser on another. Point a staging run of the job at it and review the result before the configuration is switched to the real server.
A third option is a dry-run mode that writes each message to a .eml file with path.write_bytes(msg.as_bytes()) instead of sending it; most desktop mail clients open .eml files directly. It is useful in production too, for rerunning a report to check it without resending it.
Common pitfalls
- Hard-coded TLS and login. Put them in configuration so tests can use a plain local server.
- Checking Bcc in the headers. It is removed before sending; check the envelope.
- A fixed test port. Ask the operating system for a free port so parallel test runs do not collide.
- Mocking smtplib instead. A mock cannot catch broken encodings or attachments.
- Production credentials in tests. Tests should never be able to reach the real server; default the config to the test server in the test suite.
Performance notes
Starting and stopping the server per test takes a few milliseconds. For a large suite, use a session-scoped fixture and clear handler.messages at the start of each test instead.
Conclusion
Run an aiosmtpd Controller with a handler that stores the envelope and message bytes, make the job's TLS and login steps configurable, and point a test configuration at the local server. Parse captured messages with policy=default to check the subject, both bodies and the attachment, and check Bcc recipients in the envelope. Wrap the server in a pytest fixture on a free port, and use the command-line server, a mail catcher or .eml files when a person needs to see the result.
Frequently asked questions
How do I test code that sends email without sending real email? Run a local SMTP server that accepts messages and keeps them instead of delivering them — aiosmtpd does this in a few lines — and point your code at it through configuration. Then inspect the captured messages in your tests.
Why not just mock smtplib? A mock only proves your code called a method. A real local server receives a real SMTP conversation and the exact bytes a mail server would get, so the test catches encoding problems, missing headers and broken attachments that a mock would pass.
How do I check Bcc recipients in a test? smtplib removes the Bcc header before sending, so it is not in the captured message. Bcc recipients appear in the SMTP envelope instead — the RCPT TO addresses — which aiosmtpd hands to the handler as envelope.rcpt_tos.
My code calls starttls() and login(). How can it talk to a local server? Make TLS and login configuration settings rather than hard-coded calls. Tests use a config with use_tls False and no username; production uses the real server settings. The sending code is otherwise identical.
Related
- Up one level: Emailing Excel Reports with smtplib — the sending side in full.
- Email an Excel Report with an HTML Summary Body — the kind of message tested here.
- Test Excel Output with pytest — testing the workbook itself.
- Send Each Recipient Their Own Excel Report — many messages to check at once.
More on email delivery
- How-toBuild a Credit Control Dunning Letter Run
- How-toAttach Multiple Excel Files to One Email in Python
- How-toDownload Excel Attachments from an Email Inbox with Python
- How-toEmail a Download Link Instead of an Excel Attachment
- How-toEmail an Excel Report with an HTML Summary Body
- How-toEmbed a Chart Image in the Body of a Report Email