Every app that sends email ends up with the same problem in development and CI: you need to see the password-reset email your code just generated, and you really don't want it reaching a real inbox. The common options are a hosted test inbox (another account and another API key for CI), a Docker image you have to keep running, or print()-ing the message and hoping the MIME is right.
MailSandbox is a fake SMTP server plus a small web inbox, in one Python file with no dependencies. Your app sends to localhost:1025 the same way it would send to a real server. MailSandbox stores each message in SQLite, parses the MIME, and shows it at http://localhost:8025 and through a JSON API your tests can query. Everything below was run locally with the shipped mailsandbox.py.
Start it
python3 mailsandbox.py --smtp-host 127.0.0.1 --http-host 127.0.0.1 --db ms.db
The defaults are SMTP on port 1025, HTTP on port 8025 and mailsandbox.db in the current directory, all bound to 0.0.0.0. On a laptop, pass 127.0.0.1 as above so nothing on your network can reach it.
Point your app at it
The app doesn't need any special code, only different settings. With the standard library:
import smtplib
from email.message import EmailMessage
m = EmailMessage()
m["From"], m["To"], m["Subject"] = "app@staging.local", "ada@example.com", "Reset your password"
m.set_content("Hi Ada,\nUse this link: https://staging.example.com/reset?token=abc123\n")
m.add_alternative('<p>Hi Ada,</p><p><a href="https://staging.example.com/reset?token=abc123">Reset password</a></p>', subtype="html")
m.add_attachment(b"%PDF-1.4 fake", maintype="application", subtype="pdf", filename="invoice-1009.pdf")
with smtplib.SMTP("127.0.0.1", 1025) as s:
s.send_message(m)
In Django it's settings only:
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "localhost"
EMAIL_PORT = 1025
EMAIL_USE_TLS = False
EMAIL_HOST_USER = "" # MailSandbox doesn't do AUTH, leave these empty
EMAIL_HOST_PASSWORD = ""
Flask-Mail, Rails' smtp_settings, Nodemailer and the rest work the same way: host localhost, port 1025, no TLS, no auth.
What gets captured
The server log shows each message as it lands:
[INFO] MailSandbox: [Captured Email #1] 'Reset your password' from 'app@staging.local' to ['ada@example.com']
[INFO] MailSandbox: [Captured Email #2] 'Welcome aboard' from 'app@staging.local' to ['luis@example.org', 'ops@example.org']
GET /api/emails lists them, newest first:
[
{"id": 2, "sender": "app@staging.local", "recipients": ["luis@example.org", "ops@example.org"],
"subject": "Welcome aboard", "size_bytes": 234, "created_at": "2026-10-19 23:49:41"},
{"id": 1, "sender": "app@staging.local", "recipients": ["ada@example.com"],
"subject": "Reset your password", "size_bytes": 1053, "created_at": "2026-10-19 23:49:41"}
]
GET /api/emails/1 returns the parsed message. Here are the useful fields, with the raw source shortened:
subject: Reset your password
body_text: 'Hi Ada,\r\nUse this link: https://staging.example.com/reset?token=abc123\r\n'
body_html: '<p>Hi Ada,</p><p><a href="https://staging.example.com/reset?token=abc123">Reset password</a></p>\r\n'
attachments: [{'filename': 'invoice-1009.pdf', 'type': 'application/pdf', 'size': 13}]
raw_content: 1053 characters of the original message, headers included
You get the plain-text and HTML parts separately, attachment metadata, every header, and the raw source for when you need to debug the MIME structure itself. Search uses ?q= and matches subject, sender and recipients:
curl -s "localhost:8025/api/emails?q=reset"
[{"id": 1, "sender": "app@staging.local", "recipients": ["ada@example.com"], "subject": "Reset your password", ...}]
DELETE /api/emails/<id> removes a single message, DELETE /api/emails clears the inbox (handy between test runs), and POST /api/send-test sends a sample email through the server, which is handy for checking the dashboard works. Open http://localhost:8025/ to see the same data in the browser.
Asserting on emails in tests
This is where a local SMTP sink earns its keep. The test below runs your mailer, then asks MailSandbox what actually went out:
import json, smtplib, urllib.request, uuid
from email.message import EmailMessage
SANDBOX = "http://127.0.0.1:8025"
def send_welcome(to): # stand-in for your app's mailer, pointed at the sandbox
msg = EmailMessage()
msg["From"], msg["To"], msg["Subject"] = "app@staging.local", to, "Confirm your account"
msg.set_content("Click https://staging.example.com/confirm?code=7741 to confirm.")
with smtplib.SMTP("127.0.0.1", 1025) as s:
s.send_message(msg)
def api(method, path):
req = urllib.request.Request(SANDBOX + path, method=method)
with urllib.request.urlopen(req) as r:
return json.load(r)
def test_signup_sends_confirmation():
to = f"user-{uuid.uuid4().hex[:8]}@example.com" # unique per test run
send_welcome(to)
inbox = api("GET", f"/api/emails?q={to}")
assert len(inbox) == 1
mail = api("GET", f"/api/emails/{inbox[0]['id']}")
assert mail["subject"] == "Confirm your account"
assert "confirm?code=7741" in mail["body_text"]
ok: confirmation email captured
The unique recipient per test means tests don't step on each other and never need to empty the inbox first. In CI, start MailSandbox in the background with a throwaway --db path before the test step, and kill it afterwards.
How it works
The SMTP side is an asyncio server that implements the commands real clients use: HELO/EHLO, MAIL FROM, RCPT TO, DATA, RSET, NOOP and QUIT. The end of the message and dot-unstuffing are handled as RFC 5321 describes:
if data_mode:
if line == ".\r\n" or line == ".\n":
data_mode = False
self.process_raw_email("".join(data_buffer))
await self.send("250 2.0.0 Ok: queued")
else:
if line.startswith(".."):
line = line[1:]
data_buffer.append(line)
The message is then parsed with the standard library's email package using policy.default. It walks the multipart tree to pull out the first text/plain and text/html parts and records each attachment's name, type and size before writing the row to SQLite (WAL mode). The HTTP side is a second small asyncio server that serves the dashboard and the JSON routes.
Limitations
-
No STARTTLS and no AUTH.
EHLOonly advertises8BITMIME. A client that insists on TLS or a login fails withSMTPNotSupportedError, so turn both off in your dev and CI mail settings. That's also why it should only ever listen on localhost or a private CI network. - It's a sink. It never relays mail anywhere, and that's the point.
- The dashboard and API have no authentication. Don't expose port 8025 publicly.
- Attachments are listed by name, type and size. The bytes stay in
raw_contentif you need them.
FAQ
Is it a Mailtrap/MailHog replacement? For local development and CI, where you need to capture, inspect and assert, yes. It doesn't do team inboxes, spam scoring or forwarding.
Does it need Docker or pip installs? No. It's one file that runs on the standard library.
Will my emails persist between runs? Yes, in the SQLite file you pass with --db. Use a temporary path in CI if you want a clean inbox every run.
MailSandbox is a one-time purchase that includes the single-file source and README: get MailSandbox ($29). Details are on the MailSandbox product page.












