Python smtplib exceptions: which error for which failure (tested)

smtplib has a dozen exception classes, and some of the most common failures raise none of them. We ran every case against a scripted SMTP server: auth, TLS, refused sender, recipients and message, dropped connections, timeouts and DNS. Below is what each one raises and a handler that tells retry from fix.

0. The test server

To get real exceptions without a mail account, we used a small scripted SMTP server on localhost that answers each command with a reply we choose. Save it as fake_smtp.py next to the examples:

# Tiny scripted SMTP server for tests: replies come from a dict you pass in.
import socket, threading

def serve(port, replies, drop_after=None):
    srv = socket.create_server(("127.0.0.1", port))
    def run():
        conn, _ = srv.accept()
        f = conn.makefile("rwb")
        f.write(b"220 fake ready\r\n"); f.flush()
        n = 0
        for line in f:
            n += 1
            if drop_after is not None and n > drop_after:
                break                                # hang up mid-session
            cmd = line.split(b" ", 1)[0].split(b":")[0].strip().upper().decode("latin-1")
            if cmd in ("MAIL", "RCPT"):
                cmd = line[:9].strip().upper().decode("latin-1").split(":")[0]
            if cmd == "DATA":
                f.write(replies.get("DATA", b"354 go") + b"\r\n"); f.flush()
                for body in f:
                    if body == b".\r\n":
                        break
                f.write(replies.get("END", b"250 queued") + b"\r\n"); f.flush()
                continue
            if cmd == "RCPT TO" and replies.get("REFUSE") and replies["REFUSE"] in line:
                f.write(b"550 5.1.1 No such user\r\n"); f.flush()
                continue
            f.write(replies.get(cmd, b"221 bye" if cmd == "QUIT" else b"250 ok") + b"\r\n"); f.flush()
            if cmd == "QUIT":
                break
        conn.close(); srv.close()
    threading.Thread(target=run, daemon=True).start()

1. Which exception each failure raises

import smtplib, socket
from fake_smtp import serve

msg = "Subject: hi\r\n\r\nhello"
def send(port, **kw):
    try:
        with smtplib.SMTP("127.0.0.1", port, timeout=3) as s:
            if kw.get("login"):
                s.login("me", "wrong-password")
            if kw.get("tls"):
                s.starttls()
            s.sendmail("me@example.com", ["you@example.com"], msg)
        return "sent"
    except Exception as e:
        return (f"{type(e).__module__}.{type(e).__name__}: {e} "
                f"| SMTPException? {isinstance(e, smtplib.SMTPException)}")

cases = [
    ("nothing listening",   None, {}),
    ("auth rejected",       {"EHLO": b"250-fake\r\n250 AUTH PLAIN LOGIN",
                             "AUTH": b"535 5.7.8 Authentication failed"}, {"login": 1}),
    ("STARTTLS not offered", {"EHLO": b"250 fake"}, {"tls": 1}),
    ("sender refused",      {"MAIL FROM": b"553 5.7.1 Sender not allowed"}, {}),
    ("recipient refused",   {"RCPT TO": b"550 5.1.1 No such user"}, {}),
    ("message refused",     {"END": b"554 5.7.1 Message rejected as spam"}, {}),
    ("server hangs up",     "drop", {}),
]
for i, (label, replies, kw) in enumerate(cases):
    port = 8964
    if replies == "drop":
        serve(port, {}, drop_after=1)
    elif replies is not None:
        serve(port, replies)
    print(f"{label:21} -> {send(port, **kw)}")

Output:

nothing listening     -> builtins.ConnectionRefusedError: [Errno 111] Connection refused | SMTPException? False
auth rejected         -> smtplib.SMTPAuthenticationError: (535, b'5.7.8 Authentication failed') | SMTPException? True
STARTTLS not offered  -> smtplib.SMTPNotSupportedError: STARTTLS extension not supported by server. | SMTPException? True
sender refused        -> smtplib.SMTPSenderRefused: (553, b'5.7.1 Sender not allowed', 'me@example.com') | SMTPException? True
recipient refused     -> smtplib.SMTPRecipientsRefused: {'you@example.com': (550, b'5.1.1 No such user')} | SMTPException? True
message refused       -> smtplib.SMTPDataError: (554, b'5.7.1 Message rejected as spam') | SMTPException? True
server hangs up       -> smtplib.SMTPServerDisconnected: Connection unexpectedly closed | SMTPException? True

Measured on Python 3.13.5: each SMTP reply maps to its own class: 535 on AUTH → SMTPAuthenticationError, a server without STARTTLS → SMTPNotSupportedError, a refused MAIL FROM → SMTPSenderRefused, a refused RCPT TO → SMTPRecipientsRefused, a rejection after the message body → SMTPDataError, a dropped connection → SMTPServerDisconnected. But nothing listening on the port raises the built-in ConnectionRefusedError, which is not an SMTPException.

2. Connection errors are not SMTPException

import smtplib

for host, port in [("127.0.0.1", 8967),            # nothing listening
                   ("10.255.255.1", 587),          # unroutable: no reply at all
                   ("no-such-host.invalid", 587)]: # DNS failure
    try:
        smtplib.SMTP(host, port, timeout=2)
    except Exception as e:
        print(f"{host:21} {type(e).__module__}.{type(e).__name__}: {e} "
              f"| SMTPException? {isinstance(e, smtplib.SMTPException)}")

Output:

127.0.0.1             builtins.ConnectionRefusedError: [Errno 111] Connection refused | SMTPException? False
10.255.255.1          builtins.TimeoutError: timed out | SMTPException? False
no-such-host.invalid  socket.gaierror: [Errno -2] Name or service not known | SMTPException? False

Measured: connection refused, a connect timeout (an unroutable address) and a DNS failure all happen before any SMTP is spoken, so they are plain OSError subclasses: ConnectionRefusedError, TimeoutError, socket.gaierror. except smtplib.SMTPException misses all three. You can catch everything with one except OSError, because SMTPException itself subclasses OSError.

3. Timeouts after connecting, and the SSL/port mismatch

import smtplib, socket, ssl, threading, time
from fake_smtp import serve

# 1) server accepts the connection but never sends its 220 greeting
silent = socket.create_server(("127.0.0.1", 8965))
t0 = time.monotonic()
try:
    smtplib.SMTP("127.0.0.1", 8965, timeout=2)
except Exception as e:
    print(f"silent server: {type(e).__module__}.{type(e).__name__}: {e} "
          f"after {time.monotonic() - t0:.1f}s | SMTPException? {isinstance(e, smtplib.SMTPException)}")
silent.close()

# 2) SMTP_SSL (port 465 style) against a plain-text server (port 587 style)
serve(8964, {})
try:
    smtplib.SMTP_SSL("127.0.0.1", 8964, timeout=3)
except Exception as e:
    print(f"SMTP_SSL on plain port: {type(e).__module__}.{type(e).__name__}: {e} "
          f"| OSError? {isinstance(e, OSError)}")

Output:

silent server: smtplib.SMTPServerDisconnected: Connection unexpectedly closed: timed out after 2.0s | SMTPException? True
SMTP_SSL on plain port: ssl.SSLError: [SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1029) | OSError? True

Measured: once the TCP connection exists, a server that never answers gives SMTPServerDisconnected: Connection unexpectedly closed: timed out after the 2-second timeout, not TimeoutError. We got the same message when the server greeted us and then went silent at EHLO. Using SMTP_SSL (for port 465) against a plain server (port 587 or 25) gives ssl.SSLError: [SSL: WRONG_VERSION_NUMBER]; the fix is SMTP + starttls() on 587, or SMTP_SSL on 465. For a custom socket that returns None mid-reply, see our getreply guide.

4. The trap: some recipients refused is NOT an exception

import smtplib
from fake_smtp import serve

serve(8964, {"REFUSE": b"typo@example.com"})
with smtplib.SMTP("127.0.0.1", 8964, timeout=3) as s:
    refused = s.sendmail("me@example.com",
                         ["you@example.com", "typo@example.com"],
                         "Subject: hi\r\n\r\nhello")
print("no exception; sendmail returned:", refused)

serve(8964, {"REFUSE": b"@example.com"})            # every recipient refused
try:
    with smtplib.SMTP("127.0.0.1", 8964, timeout=3) as s:
        s.sendmail("me@example.com", ["a@example.com", "b@example.com"], "hi")
except smtplib.SMTPRecipientsRefused as e:
    print("all refused -> SMTPRecipientsRefused")
    for addr, (code, text) in e.recipients.items():
        print(f"   {addr}: {code} {text.decode()}")

serve(8964, {"MAIL FROM": b"553 5.7.1 Sender not allowed"})
try:
    with smtplib.SMTP("127.0.0.1", 8964, timeout=3) as s:
        s.sendmail("me@example.com", ["a@example.com"], "hi")
except smtplib.SMTPResponseException as e:     # base of Sender/Data/Auth errors
    print(f"{type(e).__name__}: smtp_code={e.smtp_code} smtp_error={e.smtp_error!r}")

Output:

no exception; sendmail returned: {'typo@example.com': (550, b'5.1.1 No such user')}
all refused -> SMTPRecipientsRefused
   a@example.com: 550 5.1.1 No such user
   b@example.com: 550 5.1.1 No such user
SMTPSenderRefused: smtp_code=553 smtp_error=b'5.7.1 Sender not allowed'

Measured: when the server refuses one of two recipients, sendmail() raises nothing: the message goes to the good address and the refused one only appears in the returned dict. Only when every recipient is refused do you get SMTPRecipientsRefused, whose .recipients maps each address to its (code, message). send_message() behaves the same way. Classes that carry one server reply (SMTPSenderRefused, SMTPDataError, SMTPAuthenticationError, SMTPHeloError, SMTPConnectError) subclass SMTPResponseException and expose .smtp_code and .smtp_error (bytes); SMTPRecipientsRefused and SMTPNotSupportedError do not.

5. A handler that sorts retry vs fix vs reject

import smtplib, socket, ssl
from fake_smtp import serve

def send_mail(host, port, sender, rcpts, msg):
    """Send; return a short status a caller can log or retry on."""
    try:
        with smtplib.SMTP(host, port, timeout=10) as s:
            refused = s.sendmail(sender, rcpts, msg)
        return f"sent, refused={refused}" if refused else "sent"
    except smtplib.SMTPAuthenticationError as e:
        return f"FIX CONFIG: login rejected {e.smtp_code}"
    except smtplib.SMTPRecipientsRefused as e:
        return f"BAD ADDRESSES: {sorted(e.recipients)}"
    except smtplib.SMTPResponseException as e:     # sender/data/other coded replies
        kind = "RETRY LATER" if 400 <= e.smtp_code < 500 else "REJECTED"
        return f"{kind}: {e.smtp_code} {e.smtp_error.decode(errors='replace')}"
    except (smtplib.SMTPServerDisconnected, TimeoutError, ConnectionError) as e:
        return f"RETRY LATER: {type(e).__name__}"
    except (socket.gaierror, ssl.SSLError, smtplib.SMTPException) as e:
        return f"FIX CONFIG: {type(e).__name__}: {e}"

msg = "Subject: hi\r\n\r\nhello"
print(send_mail("127.0.0.1", 8967, "me@example.com", ["a@example.com"], msg))
serve(8964, {"END": b"451 4.7.1 Greylisted, try again later"})
print(send_mail("127.0.0.1", 8964, "me@example.com", ["a@example.com"], msg))
serve(8964, {"END": b"554 5.7.1 Message rejected as spam"})
print(send_mail("127.0.0.1", 8964, "me@example.com", ["a@example.com"], msg))
serve(8964, {"REFUSE": b"@example.com"})
print(send_mail("127.0.0.1", 8964, "me@example.com", ["a@example.com"], msg))
serve(8964, {"REFUSE": b"typo@"})
print(send_mail("127.0.0.1", 8964, "me@example.com", ["a@example.com", "typo@example.com"], msg))

Output:

RETRY LATER: ConnectionRefusedError
RETRY LATER: 451 4.7.1 Greylisted, try again later
REJECTED: 554 5.7.1 Message rejected as spam
BAD ADDRESSES: ['a@example.com']
sent, refused={'typo@example.com': (550, b'5.1.1 No such user')}

Measured: the handler checks the returned dict and splits failures by what you should do next. 4xx codes (here a greylisting 451) are temporary, so retry later with backoff. 5xx codes are permanent, so don't retry the same message. Connection problems are retryable; DNS, TLS and login failures need a config fix. Order matters: SMTPAuthenticationError must come before SMTPResponseException, its base class.

Frequently asked questions

What exceptions can smtplib raise?

SMTPException subclasses (SMTPAuthenticationError, SMTPSenderRefused, SMTPRecipientsRefused, SMTPDataError, SMTPServerDisconnected, SMTPNotSupportedError, SMTPHeloError, SMTPConnectError), plus OSError subclasses that happen before SMTP starts: we measured ConnectionRefusedError, TimeoutError, socket.gaierror and ssl.SSLError.

Why doesn't except smtplib.SMTPException catch connection refused?

Connection refused, connect timeouts and DNS failures are raised by the socket layer as plain OSError subclasses. We measured all three with SMTPException? False. Catch OSError, which also covers SMTPException.

Does sendmail raise an error if one recipient is invalid?

No. We measured one of two recipients refused: no exception, and sendmail() returned {'typo@example.com': (550, ...)}. It raises SMTPRecipientsRefused only if all recipients are refused.

How do I get the SMTP error code from an exception?

For SMTPResponseException subclasses use e.smtp_code and e.smtp_error (bytes). For SMTPRecipientsRefused use e.recipients, a dict of address to (code, message). We measured both.

What does SMTPServerDisconnected: Connection unexpectedly closed mean?

The server closed the connection or didn't answer within the timeout. We measured both a hang-up and a silent server producing it (the timeout case ends in ': timed out').

Why do I get ssl.SSLError WRONG_VERSION_NUMBER with smtplib?

You used SMTP_SSL on a plain-text port. We measured it; use SMTP plus starttls() on 587, or SMTP_SSL on 465.

Written with AI assistance. Measured on 2026-10-09 on Debian with Python 3.13.5's smtplib against a scripted SMTP server on localhost (code shown): auth, STARTTLS, sender, recipient and data refusals, hang-ups, silent servers, SMTP_SSL on a plain port, connection refused, an unroutable address and a DNS failure. Every block was run verbatim and its output pasted from that run. No real mail server was contacted. PlainHeaders is our extension; Python's smtplib is built in.