SmtpPlugin Guide¶
SmtpPlugin replaces smtplib.SMTP with a fake class that routes all SMTP operations through a session script. It is included in core bigfoot -- no extra required.
Setup¶
In pytest, access SmtpPlugin through the bigfoot.smtp_mock proxy. It auto-creates the plugin for the current test on first use:
import bigfoot
def test_send_email():
(bigfoot.smtp_mock
.new_session()
.expect("connect", returns=None)
.expect("ehlo", returns=(250, b"OK"))
.expect("sendmail", returns={})
.expect("quit", returns=(221, b"Bye")))
with bigfoot:
import smtplib
smtp = smtplib.SMTP("mail.example.com", 25)
smtp.ehlo()
smtp.sendmail("from@example.com", ["to@example.com"], "Subject: hi\r\n\r\nhello")
smtp.quit()
bigfoot.smtp_mock.assert_connect(host="mail.example.com", port=25)
bigfoot.smtp_mock.assert_ehlo(name="")
bigfoot.smtp_mock.assert_sendmail(
from_addr="from@example.com",
to_addrs=["to@example.com"],
msg="Subject: hi\r\n\r\nhello",
)
bigfoot.smtp_mock.assert_quit()
For manual use outside pytest, construct SmtpPlugin explicitly:
from bigfoot import StrictVerifier
from bigfoot.plugins.smtp_plugin import SmtpPlugin
verifier = StrictVerifier()
smtp = SmtpPlugin(verifier)
Each verifier may have at most one SmtpPlugin. A second SmtpPlugin(verifier) raises ValueError.
State machine¶
disconnected --connect--> connected --ehlo/helo--> greeted
greeted --starttls--> greeted (optional, self-loop)
greeted --login--> authenticated (optional)
greeted/authenticated/sending --sendmail/send_message--> sending
sending/greeted/authenticated --quit--> closed
The connect step fires automatically during smtplib.SMTP(host, port) construction. After that, the SMTP protocol requires a greeting (ehlo or helo) before any mail operations. starttls and login are optional intermediate steps.
Scripting a session¶
Use new_session() to create a SessionHandle and chain .expect() calls:
(bigfoot.smtp_mock
.new_session()
.expect("connect", returns=None)
.expect("ehlo", returns=(250, b"OK"))
.expect("starttls", returns=(220, b"Ready"))
.expect("login", returns=(235, b"Auth OK"))
.expect("sendmail", returns={})
.expect("quit", returns=(221, b"Bye")))
expect() parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
method |
str |
required | Step name (see below) |
returns |
Any |
required | Value returned by the step (see below) |
raises |
BaseException \| None |
None |
Exception to raise instead of returning |
required |
bool |
True |
Whether an unused step causes UnusedMocksError at teardown |
Return values by step¶
| Step | returns type |
Description |
|---|---|---|
connect |
None |
Connection is established implicitly |
ehlo |
tuple[int, bytes] |
SMTP response code and message |
helo |
tuple[int, bytes] |
SMTP response code and message |
starttls |
tuple[int, bytes] |
SMTP response code and message |
login |
tuple[int, bytes] |
SMTP response code and message |
sendmail |
dict[str, tuple[int, bytes]] |
Empty dict for success; maps refused recipients to error codes |
send_message |
dict[str, tuple[int, bytes]] |
Same as sendmail |
quit |
tuple[int, bytes] |
SMTP response code and message |
Asserting interactions¶
Each step records an interaction on the timeline. Use the typed assertion helpers on bigfoot.smtp_mock:
assert_connect(*, host, port)¶
assert_ehlo(*, name)¶
assert_helo(*, name)¶
assert_starttls()¶
No fields are required.
assert_login(*, user, password)¶
assert_sendmail(*, from_addr, to_addrs, msg)¶
bigfoot.smtp_mock.assert_sendmail(
from_addr="from@example.com",
to_addrs=["to@example.com"],
msg="Subject: hello\r\n\r\nhello",
)
assert_send_message(*, msg)¶
assert_quit()¶
No fields are required.
Full authenticated flow¶
The full flow with TLS and authentication:
import smtplib
import bigfoot
def send_secure_email(host, port, user, password, from_addr, to_addrs, body):
smtp = smtplib.SMTP(host, port)
smtp.ehlo()
smtp.starttls()
smtp.login(user, password)
smtp.sendmail(from_addr, to_addrs, body)
smtp.quit()
def test_send_secure_email():
(bigfoot.smtp_mock
.new_session()
.expect("connect", returns=None)
.expect("ehlo", returns=(250, b"OK"))
.expect("starttls", returns=(220, b"Ready"))
.expect("login", returns=(235, b"Auth OK"))
.expect("sendmail", returns={})
.expect("quit", returns=(221, b"Bye")))
with bigfoot:
send_secure_email(
"smtp.example.com", 587,
"user@example.com", "s3cret",
"user@example.com", ["recipient@example.com"],
"Subject: Report\r\n\r\nSee attached.",
)
bigfoot.smtp_mock.assert_connect(host="smtp.example.com", port=587)
bigfoot.smtp_mock.assert_ehlo(name="")
bigfoot.smtp_mock.assert_starttls()
bigfoot.smtp_mock.assert_login(user="user@example.com", password="s3cret")
bigfoot.smtp_mock.assert_sendmail(
from_addr="user@example.com",
to_addrs=["recipient@example.com"],
msg="Subject: Report\r\n\r\nSee attached.",
)
bigfoot.smtp_mock.assert_quit()
Unauthenticated flow¶
Skip starttls and login for servers that do not require authentication:
def test_send_unauthenticated_email():
(bigfoot.smtp_mock
.new_session()
.expect("connect", returns=None)
.expect("ehlo", returns=(250, b"OK"))
.expect("sendmail", returns={})
.expect("quit", returns=(221, b"Bye")))
with bigfoot:
smtp = smtplib.SMTP("mail.example.com", 25)
smtp.ehlo()
smtp.sendmail("from@example.com", ["to@example.com"], "Subject: test\r\n\r\ntest")
smtp.quit()
bigfoot.smtp_mock.assert_connect(host="mail.example.com", port=25)
bigfoot.smtp_mock.assert_ehlo(name="")
bigfoot.smtp_mock.assert_sendmail(
from_addr="from@example.com",
to_addrs=["to@example.com"],
msg="Subject: test\r\n\r\ntest",
)
bigfoot.smtp_mock.assert_quit()
The state machine validates that sendmail is called from greeted (after ehlo without login) or from authenticated (after login). Calling sendmail from connected (skipping ehlo) raises InvalidStateError.
Using helo instead of ehlo¶
Some legacy servers use HELO instead of EHLO. The state machine treats both identically:
def test_helo_flow():
(bigfoot.smtp_mock
.new_session()
.expect("connect", returns=None)
.expect("helo", returns=(250, b"OK"))
.expect("sendmail", returns={})
.expect("quit", returns=(221, b"Bye")))
with bigfoot:
smtp = smtplib.SMTP("mail.example.com", 25)
smtp.helo()
smtp.sendmail("from@example.com", ["to@example.com"], "Subject: test\r\n\r\ntest")
smtp.quit()
bigfoot.smtp_mock.assert_connect(host="mail.example.com", port=25)
bigfoot.smtp_mock.assert_helo(name="")
bigfoot.smtp_mock.assert_sendmail(
from_addr="from@example.com",
to_addrs=["to@example.com"],
msg="Subject: test\r\n\r\ntest",
)
bigfoot.smtp_mock.assert_quit()
Using send_message¶
send_message accepts an email.message.EmailMessage object and works the same as sendmail from a state machine perspective:
from email.message import EmailMessage
def test_send_message():
msg = EmailMessage()
msg["Subject"] = "Report"
msg["From"] = "from@example.com"
msg["To"] = "to@example.com"
msg.set_content("See attached.")
(bigfoot.smtp_mock
.new_session()
.expect("connect", returns=None)
.expect("ehlo", returns=(250, b"OK"))
.expect("send_message", returns={})
.expect("quit", returns=(221, b"Bye")))
with bigfoot:
smtp = smtplib.SMTP("mail.example.com", 25)
smtp.ehlo()
smtp.send_message(msg)
smtp.quit()
bigfoot.smtp_mock.assert_connect(host="mail.example.com", port=25)
bigfoot.smtp_mock.assert_ehlo(name="")
bigfoot.smtp_mock.assert_send_message(msg=msg)
bigfoot.smtp_mock.assert_quit()