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

ExceptionMeaningAction
SMTPAuthenticationErrorWrong credentials or app password neededFix credentials; enable app passwords
SMTPRecipientsRefusedOne or more addresses rejectedLog and skip; do not retry same address
SMTPSenderRefusedServer rejected the From addressCheck domain authentication
SMTPDataErrorMessage too large or malformedCheck attachment sizes and encoding
SMTPServerDisconnectedConnection dropped mid-sessionRetry with new connection
SMTPConnectErrorCannot reach the serverCheck 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

FactorsmtplibEmail API (SendGrid, SES)
Setup complexityLow — stdlib, no dependenciesMedium — SDK, API keys, DNS config
DeliverabilityYou manage IP reputationProvider manages reputation
CostFree (but limited by provider)Pay per email (often free tier available)
Bounce handlingManual — parse bounce emailsAutomatic — webhooks and dashboards
Rate limitsProvider-imposed, often opaqueClearly documented, scalable
Vendor lock-inNoneModerate

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.

pythonemailsmtplibautomation

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.