New guide: how to warm up a dedicated IP without hurting your reputation. Read now →
Log in
MigoSMTP
SMTP and APIUpdated 26 Oct 2026

Send Email With Python smtplib: STARTTLS, SSL, HTML and Attachments

A complete guide to sending email from Python with smtplib and EmailMessage: STARTTLS on 587, SSL on 465, HTML with a text fallback, attachments, error handling, sending in bulk and avoiding spam folders.

TK
Written by Tomasz Kowalski
Infrastructure and Reliability Engineer
Reviewed by Karan Malhotra
  • Published12 May 2026
  • Read time8 min
  • Length1,632 words
Share this guide

Python's standard library can send email without any extra packages. The smtplib module speaks SMTP, and the email package builds correct messages. Together they cover plain text, HTML, attachments and encrypted connections. This guide shows a complete, current example using EmailMessage, the right way to use STARTTLS on port 587 and SSL on port 465, how to handle the exceptions you will meet, how to send many messages without opening a new connection each time and what to do so that mail from a Python service reaches the inbox. All examples use placeholders. Take the host, user name and password from your SMTP provider's dashboard and keep them in environment variables. If a login fails, our guide to SMTP error 535 lists the causes.

BuildEmailMessage for headers, text, HTML and attachments
ConnectSMTP + starttls() on 587, or SMTP_SSL on 465
Sendlogin() then send_message(), inside try and except

A first working example (STARTTLS on port 587)

The script below builds a message with a plain-text body and an HTML alternative, connects, upgrades the connection to TLS, logs in and sends. It uses ssl.create_default_context(), which turns on certificate verification and host name checking.

import os
import smtplib
import ssl
from email.message import EmailMessage
from email.utils import formatdate, make_msgid

HOST = os.environ["SMTP_HOST"]          # e.g. smtp.example.com
USER = os.environ["SMTP_USER"]
PASSWORD = os.environ["SMTP_PASS"]

msg = EmailMessage()
msg["Subject"] = "Welcome to Example"
msg["From"] = "Example App <[email protected]>"
msg["To"] = "[email protected]"
msg["Reply-To"] = "[email protected]"
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain="example.com")

msg.set_content("Thanks for signing up.")
msg.add_alternative("<p>Thanks for signing up.</p>", subtype="html")

context = ssl.create_default_context()

with smtplib.SMTP(HOST, 587, timeout=30) as server:
    server.starttls(context=context)
    server.login(USER, PASSWORD)
    refused = server.send_message(msg)

print("Refused recipients:", refused)

Three details matter. First, call starttls() before login(), so that your credentials are never sent unencrypted. Second, set_content() creates the plain-text part and add_alternative() adds the HTML part, giving you a correct multipart/alternative message. Third, send_message() returns a dictionary of recipients that were refused. An empty dictionary means every recipient was accepted by the server. It does not mean the mail reached an inbox, which depends on the receiving side, as we explain in delivered but not received.

SSL on port 465 (implicit TLS)

Some providers recommend port 465, where the connection is encrypted from the first byte. Use SMTP_SSL instead of SMTP and do not call starttls().

with smtplib.SMTP_SSL(HOST, 465, context=context, timeout=30) as server:
    server.login(USER, PASSWORD)
    server.send_message(msg)
PortClassCall starttls()?
587smtplib.SMTPYes, then login
465smtplib.SMTP_SSLNo, the connection is already encrypted
25smtplib.SMTPServer-to-server port, often blocked for applications

Calling starttls() on an SMTP_SSL connection, or using plain SMTP on port 465, produces timeouts or errors such as "wrong version number". Our guide on SSL, TLS and STARTTLS explains the difference.

Adding attachments

EmailMessage makes attachments simple. Read the file as bytes and pass the correct media type. The library takes care of encoding and the MIME structure.

import mimetypes
from pathlib import Path

path = Path("invoice.pdf")
ctype, _ = mimetypes.guess_type(path.name)
maintype, subtype = (ctype or "application/octet-stream").split("/", 1)

with path.open("rb") as f:
    msg.add_attachment(f.read(), maintype=maintype, subtype=subtype, filename=path.name)

Keep attachments modest. The message grows by about a third when it is encoded, and receiving servers enforce limits, so a very large attachment is better replaced by a link. For inline images, use a content ID on the HTML part and reference it with cid:. The structure behind all of this is described in MIME, multipart messages and content types.

Handling errors properly

smtplib raises specific exceptions, all subclasses of smtplib.SMTPException (or OSError for network failures). Catch them in a sensible order and decide for each whether to retry.

ExceptionMeaningRetry?
SMTPAuthenticationErrorThe server rejected the credentials (535)No. Fix the credentials
SMTPConnectErrorThe server refused or dropped the connectionYes, with backoff
SMTPServerDisconnectedThe connection closed unexpectedlyYes, with a fresh connection
SMTPSenderRefusedThe From address was not acceptedNo. Use an allowed sender
SMTPRecipientsRefusedEvery recipient was rejectedDepends on the code: 4xx yes, 5xx no
SMTPDataErrorThe server rejected the message contentCheck the reply; usually no retry
TimeoutError or socket.timeoutNo answer within the timeoutYes, with backoff
import logging
import smtplib

log = logging.getLogger("mailer")

def send(msg):
    try:
        with smtplib.SMTP(HOST, 587, timeout=30) as server:
            server.starttls(context=context)
            server.login(USER, PASSWORD)
            return server.send_message(msg)
    except smtplib.SMTPAuthenticationError:
        log.error("SMTP login rejected; check credentials")
        raise
    except smtplib.SMTPRecipientsRefused as e:
        log.warning("Recipients refused: %s", e.recipients)
        raise
    except (smtplib.SMTPException, OSError) as e:
        log.warning("Temporary mail failure: %s", e)
        raise

Each 5xx reply is a permanent failure and a 4xx reply is temporary. Retrying a permanent failure only wastes time. Our guide rate limits, retries and backoff gives a pattern with a growing delay, and SMTP bounce codes helps you read the replies.

See the conversation while you debug

Call server.set_debuglevel(1) after creating the connection. Python prints every command and reply to standard error. Use it for testing, and remove it in production because the output can include encoded credentials. A good habit is to test with openssl s_client -starttls smtp -connect HOST:587 first, as shown in how to test an SMTP connection, so you know whether the problem is the network or your code.

Sending many messages efficiently

Opening a connection, negotiating TLS and logging in costs time. If you send many messages in a short period, open one connection and send them all on it, then close it. Do not hold a connection open for hours, because servers close idle connections, so build in a reconnect. Respect the rate limits of your provider, send in batches with a short pause and stop when you see a 4xx throttling reply.

def send_many(messages):
    with smtplib.SMTP(HOST, 587, timeout=30) as server:
        server.starttls(context=context)
        server.login(USER, PASSWORD)
        for m in messages:
            try:
                server.send_message(m)
            except smtplib.SMTPRecipientsRefused as e:
                log.warning("Skipped %s: %s", m["To"], e.recipients)

For large volumes, a worker with a queue is a better design than a loop in a web process. Tools such as Celery, RQ or a simple database-backed queue let you retry only the failed messages. An email API can also reduce the overhead for bulk sending, which is the subject of email API integration patterns.

Keep configuration out of your code

Hard-coded credentials end up in version control, in screenshots and in error reports. Read them from environment variables, as the examples do, or from a secrets manager if your platform has one. Provide a clear failure when a value is missing, such as a startup check that raises an error naming the variable, so that a misconfigured deployment fails fast instead of sending nothing. Use one set of credentials per environment, so that a test run on a laptop can never send through the production account. When you rotate a credential, restart long-running processes, because they keep the old value in memory. Our guide to API key security and rotation explains a safe order of steps.

Test locally without sending real mail

During development you rarely want to send real email. Python can run a local debugging server that prints every message it receives to the terminal. Start it in one shell with python -m smtpd -n -c DebuggingServer localhost:1025 in Python 3.11 and earlier (the module was removed in 3.12), or a small local mail catcher tool on current versions, then point your code at localhost on that port with no encryption and no login. This lets you check headers, encoding and attachments safely. When the output looks right, switch the host, port and credentials to your real relay in a staging environment and send a handful of messages to your own mailboxes before you go live. Our guide to a staging and sandbox setup for email describes a safe approach.

Using Python mail code inside a framework

If you use Django, Flask or FastAPI, prefer the framework's own mail layer or a thin wrapper around the code above. Django's send_mail uses the same SMTP settings, which you provide as EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD and EMAIL_USE_TLS. Flask extensions follow similar patterns. In an asynchronous application, do not call blocking smtplib code directly inside an event loop. Run it in a thread pool, or hand the message to a background worker, so that a slow server cannot stall your whole application.

Do not build headers from user input

If you put form input into headers such as To, Subject or Reply-To, validate it. EmailMessage refuses header values with newlines, which blocks header injection, but you should still check addresses with a proper validator and limit the number of recipients a form can trigger. Contact forms that accept any address as the sender are a common route for abuse. Our article on email validation before sending covers the checks worth doing.

Deliverability for Python services

Python gives you a correct message, but inbox placement depends on your domain and your sending practices. Authenticate your domain with SPF, DKIM and DMARC and send from an address on that domain. Always include a plain-text part. Use a consistent From name and avoid a random Message-ID domain. Keep complaint and bounce rates low by removing invalid addresses and honouring unsubscribes for marketing mail. Separate transactional mail from bulk mail. The guides on authenticating your domain and bounce rates are the next steps once your code works.

Connect your Python service Copy the SMTP settings from your dashboard and send your first message through a managed relay.

See the SMTP relay

References and further reading

Frequently asked questions

Do I need to call starttls() before login()?

Yes, on port 587. Upgrade the connection to TLS first so that your user name and password are not sent unencrypted.

What is the difference between SMTP and SMTP_SSL in Python?

SMTP starts in plain text and can upgrade with starttls(), usually on port 587. SMTP_SSL encrypts from the first byte and is used on port 465.

How do I send both HTML and plain text?

Call set_content() with the text, then add_alternative() with the HTML and subtype html. This creates a multipart alternative message.

What does send_message() return?

A dictionary of refused recipients. An empty dictionary means every recipient was accepted by the server, not that the message reached an inbox.

Why does my login fail with SMTPAuthenticationError?

The server rejected the credentials. Check the user name and password, use the SMTP credentials rather than the web password and confirm that special characters are intact.

Topics#python#smtplib#smtp#code examples#developers
Was this guide helpful?Your answer helps us improve our documentation.

Start sending in minutes.

Create an account, add your domain and send your first email.

Get started Talk to sales