Send Email with Python: smtplib, HTML, Attachments and Bulk Mail (2026)
Sending email is one of the most useful things a Python script can do: a daily sales report to your manager, an alert when a job fails, invoices to every customer in a spreadsheet or a password reset from your web app. You do not need a paid library for any of it. Python ships with smtplib (talks to the mail server) and the email package with its modern EmailMessage class (builds the message). In this tutorial we build a real report email with a plain-text body, an HTML version, an inline chart and a CSV attachment, send it securely with STARTTLS and a login, then send a personalised mail merge from a CSV file over a single connection with proper error handling. To test everything safely without spamming anyone, we run a local SMTP server with aiosmtpd 1.4.6 that requires TLS and a password, exactly like Gmail or Outlook. Everything was tested on Python 3.13.5, and every output block below is the real output of the scripts shown.
How sending email works (in 30 seconds)
- SMTP is the protocol used to hand a message to a mail server. Your script is the client; the provider (Gmail, Outlook, your host, SendGrid, Amazon SES) is the server that delivers it.
- Port 587 + STARTTLS is the standard for submitting mail: the connection starts in plain text and is upgraded to TLS before you log in. Port 465 uses TLS from the first byte (
smtplib.SMTP_SSL). Never log in on an unencrypted connection. - An email is a MIME tree. A typical modern message is
multipart/mixed(body plus attachments) containingmultipart/alternative(plain text and HTML versions of the same body), and the HTML part can bemultipart/relatedwith inline images. EmailMessagebuilds that tree for you withset_content(),add_alternative(),add_related()andadd_attachment(). The oldMIMEMultipart/MIMETextstyle from many older tutorials still works, but it is more code and easier to get wrong.
Install
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install aiosmtpd==1.4.6 # only for the local test server
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 30 \
-subj "/CN=localhost" -addext "subjectAltName=DNS:localhost"
smtplib, email, ssl and csv are all in the standard library. The openssl command creates a self-signed certificate for localhost so that our test server can offer real STARTTLS and our client can verify it. Note that since Python 3.12 the old keyfile and certfile arguments of starttls() and SMTP_SSL are gone: always pass an ssl.SSLContext instead.
Step 1: A local SMTP server for safe testing
Testing against a real mailbox is slow and risky: one bug in a loop and you have emailed your whole customer list. This small server, built on aiosmtpd, behaves like a real provider. It refuses to log in without STARTTLS, checks a username and password, rejects one unknown address with a 550 error and saves every accepted message as an .eml file in an outbox folder.
# test_smtp_server.py - a local SMTP server with STARTTLS + login, for safe testing
import logging, ssl, time
from pathlib import Path
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import AuthResult, LoginPassword
logging.getLogger("mail.log").setLevel(logging.ERROR) # hide aiosmtpd internal notices
OUTBOX = Path("outbox"); OUTBOX.mkdir(exist_ok=True)
def authenticator(server, session, envelope, mechanism, auth_data):
ok = isinstance(auth_data, LoginPassword) and auth_data.login == b"me@example.com" \
and auth_data.password == b"app-password-123"
return AuthResult(success=ok, handled=False) # handled=False -> server replies 535 on failure
class SaveToOutbox:
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
if not address.endswith("@example.com"): # simulate a rejected address
return "550 5.1.1 Mailbox does not exist"
envelope.rcpt_tos.append(address)
return "250 OK"
async def handle_DATA(self, server, session, envelope):
n = len(list(OUTBOX.glob("*.eml"))) + 1
(OUTBOX / f"{n:03d}.eml").write_bytes(envelope.original_content)
print(f"[server] #{n} from={envelope.mail_from} to={envelope.rcpt_tos} "
f"bytes={len(envelope.original_content)}", flush=True)
return "250 Message accepted for delivery"
tls = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
tls.load_cert_chain("cert.pem", "key.pem")
controller = Controller(SaveToOutbox(), hostname="localhost", port=8587,
tls_context=tls, require_starttls=True,
authenticator=authenticator, auth_require_tls=True)
controller.start()
print("[server] listening on localhost:8587 (STARTTLS required)", flush=True)
try:
while True: time.sleep(1)
except KeyboardInterrupt:
controller.stop()
One detail that cost us a few minutes: AuthResult defaults to handled=True, which tells aiosmtpd that your code has already replied. With that default a wrong password makes the client hang until it times out. Setting handled=False lets the server send the proper 535 error. Start it in its own terminal with python test_smtp_server.py.
Step 2: Send an HTML email with an inline image and an attachment
The script reads the server settings from environment variables, so the same code works locally and with Gmail. It builds a plain-text body first, adds an HTML alternative that displays a chart through a cid: reference, attaches a CSV file with a MIME type guessed from the file name and sends it over STARTTLS with full certificate verification. send_message() reads the recipients from the To, Cc and Bcc headers and removes Bcc before sending.
# send_email.py - one email with plain text + HTML, an inline image and an attachment
import os, smtplib, ssl, mimetypes
from email.message import EmailMessage
from email.utils import make_msgid, formatdate
from pathlib import Path
SMTP_HOST = os.environ.get("SMTP_HOST", "localhost") # smtp.gmail.com in real life
SMTP_PORT = int(os.environ.get("SMTP_PORT", "8587")) # 587 for STARTTLS
SMTP_USER = os.environ.get("SMTP_USER", "me@example.com")
SMTP_PASS = os.environ.get("SMTP_PASS", "app-password-123") # never hard-code real passwords
msg = EmailMessage()
msg["Subject"] = "Your weekly sales report"
msg["From"] = f"Reports Bot <{SMTP_USER}>"
msg["To"] = "boss@example.com"
msg["Cc"] = "team@example.com"
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain="example.com")
# 1) plain-text body (shown by clients that block HTML)
msg.set_content("Hi,\n\nSales this week: 1,284 orders (+12%).\nThe CSV is attached.\n")
# 2) HTML alternative with an inline chart (cid: reference)
chart_cid = make_msgid(domain="example.com")
msg.add_alternative(f"""<html><body>
<h2 style="color:#0d47a1">Weekly sales report</h2>
<p>Sales this week: <b>1,284 orders</b> (<span style="color:green">+12%</span>).</p>
<img src="cid:{chart_cid[1:-1]}" alt="chart" width="300">
</body></html>""", subtype="html")
html_part = msg.get_payload()[1]
html_part.add_related(Path("chart.png").read_bytes(), "image", "png", cid=chart_cid)
# 3) a file attachment, MIME type guessed from the name
for path in [Path("sales.csv")]:
ctype, _ = mimetypes.guess_type(path.name)
maintype, subtype = (ctype or "application/octet-stream").split("/")
msg.add_attachment(path.read_bytes(), maintype=maintype, subtype=subtype, filename=path.name)
# 4) send over STARTTLS with certificate verification
context = ssl.create_default_context(cafile=os.environ.get("SMTP_CAFILE"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as server:
server.ehlo()
server.starttls(context=context)
server.ehlo()
server.login(SMTP_USER, SMTP_PASS)
refused = server.send_message(msg) # To + Cc are used as recipients
print("sent:", msg["Subject"], "| refused:", refused)
print("structure:", " > ".join(p.get_content_type() for p in msg.walk()))
Real output
$ SMTP_CAFILE=cert.pem python send_email.py
sent: Your weekly sales report | refused: {}
structure: multipart/mixed > multipart/alternative > text/plain > multipart/related > text/html > image/png > text/csv
The printed structure is exactly the MIME tree described above: multipart/mixed holds the alternative bodies and the CSV, and the HTML part sits inside multipart/related together with the PNG it displays. refused is an empty dictionary, which means every recipient was accepted. Always send a plain-text version as well as HTML: some clients and many spam filters penalise HTML-only mail.
Step 3: Personalised bulk email (mail merge) from a CSV file
Opening a new connection and logging in for every message is slow and looks suspicious to providers. The right pattern is one connection, one login and a loop. Each message is built fresh, user data is escaped with html.escape() before it goes into HTML, and a refused recipient is caught and logged instead of crashing the whole run.
# bulk_send.py - personalised mail merge from a CSV over ONE connection
import csv, html, os, smtplib, ssl, time
from email.message import EmailMessage
USER, PASS = "me@example.com", os.environ.get("SMTP_PASS", "app-password-123")
context = ssl.create_default_context(cafile=os.environ.get("SMTP_CAFILE"))
sent, failed = 0, []
with smtplib.SMTP("localhost", 8587, timeout=30) as server:
server.starttls(context=context)
server.login(USER, PASS)
for row in csv.DictReader(open("customers.csv", newline="", encoding="utf-8")):
msg = EmailMessage()
msg["Subject"] = f"{row['name']}, your invoice {row['invoice']} is ready"
msg["From"], msg["To"] = USER, row["email"]
msg.set_content(f"Hi {row['name']},\nInvoice {row['invoice']}: {row['amount']} USD.\n")
msg.add_alternative(f"<p>Hi <b>{html.escape(row['name'])}</b>,</p>"
f"<p>Invoice {row['invoice']}: <b>{row['amount']} USD</b>.</p>",
subtype="html")
try:
server.send_message(msg)
sent += 1
print(f"OK {row['email']}")
except smtplib.SMTPRecipientsRefused as e:
code, reason = e.recipients[row["email"]]
failed.append(row["email"])
print(f"FAIL {row['email']} -> {code} {reason.decode()}")
time.sleep(0.2) # stay under provider rate limits
print(f"done: {sent} sent, {len(failed)} failed")
Real output (bulk send)
$ SMTP_CAFILE=cert.pem python bulk_send.py
OK aisha@example.com
OK ben@example.com
FAIL chen@not-a-real-domain.test -> 550 5.1.1 Mailbox does not exist
OK diego@example.com
done: 3 sent, 1 failed
Three invoices were delivered and the bad address was rejected by the server with 550 5.1.1. Because we caught SMTPRecipientsRefused, the loop simply continued with Diego. In production, write the failed addresses to a file so you can fix them and resend only those.
Step 4: Prove what was really sent
Here is the server log, followed by a short script that parses the saved .eml files with the modern policy.default parser. get_body() picks the plain-text body and iter_attachments() lists the files.
[server] listening on localhost:8587 (STARTTLS required)
[server] #1 from=me@example.com to=['boss@example.com', 'team@example.com'] bytes=2694
[server] #2 from=me@example.com to=['aisha@example.com'] bytes=612
[server] #3 from=me@example.com to=['ben@example.com'] bytes=602
[server] #4 from=me@example.com to=['diego@example.com'] bytes=610
# read_outbox.py - parse the saved .eml files and prove what was really sent
from email import policy
from email.parser import BytesParser
from pathlib import Path
for f in sorted(Path("outbox").glob("*.eml")):
msg = BytesParser(policy=policy.default).parsebytes(f.read_bytes())
lines = msg.get_body(preferencelist=("plain",)).get_content().splitlines()
body = next(l for l in lines if any(ch.isdigit() for ch in l)) # first line with numbers
files = [a.get_filename() for a in msg.iter_attachments() if a.get_filename()]
print(f"{f.name} | {msg['To']:<18} | {msg['Subject'][:42]:<42} | {body[:34]:<34} | files={files}")
$ python read_outbox.py
001.eml | boss@example.com | Your weekly sales report | Sales this week: 1,284 orders (+12 | files=['sales.csv']
002.eml | aisha@example.com | Aisha, your invoice INV-1001 is ready | Invoice INV-1001: 120.00 USD. | files=[]
003.eml | ben@example.com | Ben, your invoice INV-1002 is ready | Invoice INV-1002: 89.50 USD. | files=[]
004.eml | diego@example.com | Diego, your invoice INV-1004 is ready | Invoice INV-1004: 15.75 USD. | files=[]
The report went to both the To and Cc addresses (two recipients on message #1) and its CSV attachment arrived intact. Each invoice email contains the right name, invoice number and amount.
Step 5: The two errors you will meet first
# check_errors.py - what a wrong password and a missing STARTTLS look like
import smtplib, ssl
ctx = ssl.create_default_context(cafile="cert.pem")
try:
with smtplib.SMTP("localhost", 8587, timeout=10) as s:
s.starttls(context=ctx)
s.login("me@example.com", "wrong-password")
except smtplib.SMTPAuthenticationError as e:
print("wrong password ->", e.smtp_code, e.smtp_error.decode())
try:
with smtplib.SMTP("localhost", 8587, timeout=10) as s:
s.login("me@example.com", "app-password-123") # forgot starttls()
except smtplib.SMTPNotSupportedError as e:
print("no STARTTLS ->", e)
$ python check_errors.py
wrong password -> 535 5.7.8 Authentication credentials invalid
no STARTTLS -> SMTP AUTH extension not supported by server.
A 535 reply means the username or password is wrong. With Gmail this almost always means you used your normal password instead of an app password. "SMTP AUTH extension not supported" means you tried to log in before starttls(): secure servers only advertise AUTH after the connection is encrypted.
Using Gmail, Outlook and other providers
- Gmail: host
smtp.gmail.com, port 587 (STARTTLS) or 465 (SSL). Turn on 2-Step Verification for the Google account and create an app password, then use that 16-character password asSMTP_PASS. Your normal Google password will be rejected. - Microsoft 365 / Exchange Online: host
smtp.office365.com, port 587. Microsoft is retiring Basic authentication for SMTP AUTH: under its January 2026 timeline it becomes disabled by default for existing tenants at the end of December 2026, and new tenants must use OAuth. For new projects, plan for OAuth tokens or the Microsoft Graph API instead of a password. - Transactional services (Amazon SES, SendGrid, Mailgun, Postmark): they all accept SMTP with a username and API key on port 587, so the same script works. Use them for high volume, because personal mailboxes have daily sending limits.
- For the switch to
SMTP_SSLon port 465, replace thewithblock withsmtplib.SMTP_SSL(host, 465, context=context)and remove thestarttls()call.
Best practices for email that arrives
- Keep secrets out of code. Read the SMTP password from environment variables or a settings class such as pydantic-settings, never from a string in your repository.
- Always verify TLS. Use
ssl.create_default_context(). Never disable certificate checks to "make it work". - Send text and HTML together, keep images small and use inline images or hosted images rather than huge attachments.
- Authenticate your domain with SPF, DKIM and DMARC if you send from your own domain. Gmail and Yahoo require them from bulk senders, and unauthenticated mail goes to spam.
- Respect rate limits: reuse one connection, add a small delay and handle
SMTPRecipientsRefused,SMTPAuthenticationErrorandSMTPServerDisconnectedseparately. - Test locally first with a server like the one above, then send to yourself before sending to real users.
Where to use this in your automation
- Build an Excel report with openpyxl and attach the .xlsx file with
add_attachment()(its MIME type is guessed automatically). - Send the report every Monday at 8:00 with a scheduler from our APScheduler vs Prefect guide.
- Add a failure alert to any script: wrap the main function in
try/exceptand email the traceback to yourself.
Conclusion
With nothing more than the standard library you can send professional emails from Python 3.13: EmailMessage builds a correct MIME message with text, HTML, inline images and attachments, and smtplib delivers it securely over STARTTLS. Add a CSV loop on one connection and you have a reliable mail merge, and with a local aiosmtpd server you can test all of it without sending a single real email by mistake.