Python smtplib Sending Emails — Deep Dive
System-level framing
At the infrastructure level, email sending is a multi-hop relay system governed by RFC 5321 (SMTP) and RFC 5322 (message format). Python’s smtplib implements the client side of this protocol. Understanding the full chain — from your script through relay servers to the recipient’s MX records — is essential for building email features that work reliably at scale.
Connection lifecycle in detail
Opening and negotiating
import smtplib
from email.message import EmailMessage
# Port 587 with STARTTLS (recommended)
with smtplib.SMTP('smtp.gmail.com', 587, timeout=30) as server:
server.ehlo() # Identify to the server
server.starttls() # Upgrade to TLS
server.ehlo() # Re-identify over encrypted channel
server.login('user@gmail.com', 'app-password-here')
msg = EmailMessage()
msg['From'] = 'user@gmail.com'
msg['To'] = 'recipient@example.com'
msg['Subject'] = 'Order Confirmation #12345'
msg.set_content('Your order has been confirmed.')
server.send_message(msg)
The double ehlo() is not a mistake. After starttls() upgrades the connection, the server resets its knowledge of client capabilities, so you must re-introduce yourself. Many tutorials skip this and it works by accident — send_message() calls ehlo() internally if needed — but being explicit prevents subtle bugs with servers that strictly follow the RFC.
Implicit SSL alternative
# Port 465 with implicit SSL
with smtplib.SMTP_SSL('smtp.gmail.com', 465, timeout=30) as server:
server.login('user@gmail.com', 'app-password-here')
server.send_message(msg)
Use this when the server requires SSL from the start. The connection is encrypted before any data is exchanged, which is slightly simpler but less flexible than STARTTLS.
Building rich messages
HTML email with plain-text fallback
msg = EmailMessage()
msg['From'] = 'noreply@myapp.com'
msg['To'] = 'customer@example.com'
msg['Subject'] = 'Your Weekly Report'
# Plain text version (shown by clients that don't render HTML)
msg.set_content('Your weekly report is ready. Visit https://myapp.com/reports')
# HTML version (shown by most modern clients)
msg.add_alternative("""\
<html>
<body>
<h2>Weekly Report</h2>
<p>Your metrics improved by <strong>12%</strong> this week.</p>
<a href="https://myapp.com/reports">View Full Report</a>
</body>
</html>
""", subtype='html')
Always include a plain-text version. Some corporate mail filters strip HTML, and accessibility tools rely on the text part.
Attachments
import mimetypes
from pathlib import Path
filepath = Path('report.pdf')
mime_type, _ = mimetypes.guess_type(str(filepath))
maintype, subtype = mime_type.split('/')
msg.add_attachment(
filepath.read_bytes(),
maintype=maintype,
subtype=subtype,
filename=filepath.name
)
For large attachments (over 10 MB), consider hosting the file and including a download link instead. Many mail servers reject messages exceeding 25 MB.
Production patterns
Connection pooling for bulk sending
Opening a new SMTP connection per email is wasteful when sending hundreds of messages. Reuse the connection:
def send_batch(recipients: list[str], subject: str, body: str):
with smtplib.SMTP('smtp.gmail.com', 587, timeout=30) as server:
server.starttls()
server.login('user@gmail.com', 'app-password-here')
for addr in recipients:
msg = EmailMessage()
msg['From'] = 'noreply@myapp.com'
msg['To'] = addr
msg['Subject'] = subject
msg.set_content(body)
try:
server.send_message(msg)
except smtplib.SMTPRecipientsRefused:
log.warning(f"Rejected: {addr}")
del msg['To'] # Reset for next iteration
For very high volumes (thousands per hour), use a dedicated service like Amazon SES or Postmark. They handle IP reputation, bounce processing, and complaint tracking.
Retry with exponential backoff
import time
from smtplib import SMTPServerDisconnected, SMTPConnectError
def send_with_retry(msg, max_retries=3):
for attempt in range(max_retries):
try:
with smtplib.SMTP('smtp.gmail.com', 587, timeout=30) as server:
server.starttls()
server.login('user@gmail.com', 'app-password-here')
server.send_message(msg)
return True
except (SMTPServerDisconnected, SMTPConnectError, TimeoutError):
wait = 2 ** attempt
time.sleep(wait)
return False
Environment-based configuration
Never hardcode credentials:
import os
SMTP_HOST = os.environ['SMTP_HOST']
SMTP_PORT = int(os.environ.get('SMTP_PORT', '587'))
SMTP_USER = os.environ['SMTP_USER']
SMTP_PASS = os.environ['SMTP_PASS']
In production, store these in a secrets manager (AWS Secrets Manager, HashiCorp Vault) rather than environment variables, which can leak through process inspection or crash dumps.
Deliverability: the hidden battle
Sending an email is easy. Getting it into the inbox is hard. Three DNS records determine whether your email is trusted:
-
SPF (Sender Policy Framework) — A DNS TXT record listing which IP addresses are allowed to send email for your domain. If your server’s IP is not listed, receiving servers may reject or spam-flag your message.
-
DKIM (DomainKeys Identified Mail) — A cryptographic signature added to outgoing email headers. The receiving server checks this signature against a public key in your DNS records. This proves the message was not tampered with in transit.
-
DMARC (Domain-based Message Authentication) — A policy record that tells receiving servers what to do when SPF or DKIM checks fail: do nothing, quarantine, or reject. It also enables aggregate reports so you can monitor authentication failures.
Without all three, your transactional emails will eventually land in spam — even if they were delivered fine for the first few weeks.
Error handling taxonomy
| Exception | Meaning | Action |
|---|---|---|
SMTPAuthenticationError | Wrong credentials or app password needed | Fix credentials; enable app passwords |
SMTPRecipientsRefused | One or more addresses rejected | Log and skip; do not retry same address |
SMTPSenderRefused | Server rejected the From address | Check domain authentication |
SMTPDataError | Message too large or malformed | Check attachment sizes and encoding |
SMTPServerDisconnected | Connection dropped mid-session | Retry with new connection |
SMTPConnectError | Cannot reach the server | Check network, DNS, firewall |
Testing without sending real emails
Use Python’s built-in debugging server for local development:
python -m smtpd -n -c DebuggingServer localhost:1025
Or the newer aiosmtpd:
pip install aiosmtpd
python -m aiosmtpd -n -l localhost:1025
Point your application at localhost:1025 and every email will be printed to the console instead of being delivered. For automated tests, libraries like smtpdfix provide pytest fixtures that spin up a temporary SMTP server.
Tradeoffs: smtplib vs email APIs
| Factor | smtplib | Email API (SendGrid, SES) |
|---|---|---|
| Setup complexity | Low — stdlib, no dependencies | Medium — SDK, API keys, DNS config |
| Deliverability | You manage IP reputation | Provider manages reputation |
| Cost | Free (but limited by provider) | Pay per email (often free tier available) |
| Bounce handling | Manual — parse bounce emails | Automatic — webhooks and dashboards |
| Rate limits | Provider-imposed, often opaque | Clearly documented, scalable |
| Vendor lock-in | None | Moderate |
For prototypes, internal tools, and low-volume alerts, smtplib is perfectly fine. For customer-facing transactional email at scale, a dedicated API service is almost always worth the cost.
The one thing to remember: Sending email is a solved problem at the protocol level — the real engineering challenge is deliverability, error handling, and not getting your domain blacklisted.
See Also
- Python Discord Bot Development Learn how Python creates Discord bots that moderate servers, play music, and respond to commands — explained for total beginners.
- Python Email Templating Jinja Discover how Jinja templates let Python create personalized emails for thousands of people without writing each one by hand.
- Python Imap Reading Emails See how Python reads your inbox using IMAP — explained with a mailbox-and-key analogy anyone can follow.
- Python Push Notifications How Python sends those buzzing alerts to your phone and browser — explained for anyone who has ever wondered where notifications come from.
- Python Slack Bot Development Find out how Python builds Slack bots that read messages, reply to commands, and automate team workflows — no Slack expertise needed.