Guide
Python smtplib Wrapper: Build or Skip It
Every Python codebase that sends email eventually grows a thin wrapper around smtplib.SMTP that handles connection setup, TLS, login, and cleanup so the rest of the code can just call send(). Most of those wrappers are written from scratch, slightly differently, every time. This guide shows a 35-line version that gets the details right — context-managed connections, certificate-verifying TLS, retries, and connection reuse for bulk sends — and is honest about the problems no wrapper can fix.
Written by Pouya Sanooei Software Engineer
Command references used in this guide: nylas email send and nylas auth login.
What is a thin wrapper around smtplib.SMTP?
A thin smtplib wrapper is a class or function that owns the SMTP connection lifecycle — connect, TLS upgrade, login, send, quit — so calling code passes a message and nothing else. Python's smtplib is deliberately low-level: a correct one-shot send takes 6 protocol steps, and repeating them in every script is how TLS and cleanup mistakes spread.
The stdlib gives you the right primitives. SMTP has supported the with statement since Python 3.3, and the docs note the SMTP QUIT command "is issued automatically when the with statement exits." send_message() extracts sender and recipients from the message headers per RFC 5322, where the older sendmail() makes you pass addresses separately and disagree with your own headers. A wrapper is mostly about composing those primitives once, correctly.
How do you build a minimal smtplib wrapper?
The wrapper below is 35 lines and handles the 3 details ad-hoc scripts get wrong: it verifies server certificates by passing ssl.create_default_context() to starttls(), it re-issues ehlo() after the TLS upgrade as the smtplib docs instruct, and it never leaks a connection because with guarantees the QUIT. Configuration comes from the environment, so credentials stay out of source control.
import os
import smtplib
import ssl
from email.message import EmailMessage
class Mailer:
"""Thin wrapper around smtplib.SMTP: connection, TLS, login, send."""
def __init__(self):
self.host = os.environ["SMTP_HOST"] # e.g. smtp.gmail.com
self.port = int(os.environ.get("SMTP_PORT", "587"))
self.user = os.environ["SMTP_USER"]
self.password = os.environ["SMTP_PASS"] # app password, not login password
self.context = ssl.create_default_context()
def _connect(self) -> smtplib.SMTP:
server = smtplib.SMTP(self.host, self.port, timeout=30)
server.ehlo()
server.starttls(context=self.context)
server.ehlo() # re-identify over the encrypted channel
server.login(self.user, self.password)
return server
def send(self, to: str, subject: str, body: str) -> None:
msg = EmailMessage()
msg["From"] = self.user
msg["To"] = to
msg["Subject"] = subject
msg.set_content(body)
with self._connect() as server: # QUIT issued automatically
server.send_message(msg)
Mailer().send("ops@example.com", "Backup done", "Nightly backup finished at 02:14.")Port 587 with starttls() is the default here; for servers that require TLS from the first byte, swap SMTP for SMTP_SSL on port 465 and drop the starttls() call. The timeout=30 matters in cron jobs — without it, a stalled connection hangs the whole job instead of failing loudly.
How do you add retries and connection reuse?
Bulk sending changes the wrapper's shape: opening a fresh connection per message means a TCP handshake, a TLS negotiation, and a login for every single send, so a 100-message batch pays that setup cost 100 times instead of once. The fix is holding one connection for the batch and retrying once on SMTPServerDisconnected, which servers raise when they drop idle or long-lived sessions.
def send_batch(self, messages: list[EmailMessage]) -> int:
"""Send a batch over one connection; reconnect once if dropped."""
sent = 0
server = self._connect()
try:
for msg in messages:
try:
server.send_message(msg)
except smtplib.SMTPServerDisconnected:
server = self._connect() # one reconnect, then resume
server.send_message(msg)
sent += 1
finally:
try:
server.quit()
except smtplib.SMTPException:
pass # connection already dead; don't mask a batch error
return sentTwo failure modes deserve different handling. SMTPServerDisconnected is transient and worth the single retry above. SMTPAuthenticationError(535, ...) is not — retrying a bad credential just gets the account throttled, so let it raise. Keep batches modest: consumer Gmail caps sending at roughly 500 recipients per day, a limit no amount of connection engineering moves.
What can't a smtplib wrapper fix?
A wrapper cleans up the protocol code, which is about a third of the problem. The other two thirds are credentials and environment, and they sit outside your class. Four limits come up repeatedly:
- Per-provider credentials — Gmail requires a 16-character app password with 2-Step Verification: per Google's Workspace updates blog, Less Secure Apps (plain-password IMAP/SMTP) "will no longer be supported as of May 1, 2025." Microsoft retired Basic Auth for Exchange Online in October 2022. Each provider means new credentials and a config change.
- Blocked ports — many CI runners, containers, and cloud sandboxes block outbound 25, 465, and 587. The wrapper times out through no fault of its own; see the cron email guide for the HTTPS workaround.
- Send-only — smtplib can't read a mailbox. The moment the script needs to check replies, you're adding
imapliband a second wrapper. - Deliverability — SPF, DKIM, and DMARC live in DNS and on the sending domain, not in your Python process.
None of these are reasons to avoid the wrapper for what it's good at: a single-provider script with working credentials and open ports. They're reasons to recognize when the wrapper has become a maintenance project.
When should you skip the wrapper entirely?
Skip the wrapper when you need multiple providers, headless environments, or read access — the cases where SMTP itself is the constraint. An 8-line subprocess call to nylas email send delivers over HTTPS with OAuth handled by the CLI, works across 6 providers, and needs no app passwords or open SMTP ports.
import json
import subprocess
def send_email(to: str, subject: str, body: str) -> dict:
result = subprocess.run(
["nylas", "email", "send",
"--to", to, "--subject", subject, "--body", body,
"--yes", "--json"],
check=True, capture_output=True, text=True, timeout=30,
)
return json.loads(result.stdout)The comparison is roughly 50 maintained lines (the Mailer class plus the batch method) against 8, with the connection logic, token refresh, and provider differences owned by the CLI instead of your codebase. The Python email methods guide compares smtplib, the Gmail API, and the subprocess path side by side, and the no-SMTP deep dive extends this function with error handling and bulk CSV sends.
Next steps
- Send email from Python: SMTP, API, and CLI — the three-method comparison this wrapper slots into
- Send email from Python without SMTP — the subprocess pattern with error handling and bulk sends
- Gmail SMTP settings — hosts, ports, app passwords, and the error codes behind 535
- Cron job email without Postfix — alerts from servers where SMTP ports are blocked
- Full command reference — every flag and subcommand documented