On this page
To send email with Python, build the message with the standard library's email.message.EmailMessage and send it with smtplib: connect to your provider's SMTP server on port 587, upgrade the connection with starttls(), log in, and call send_message(). For application email at volume, an HTTP email API is often simpler: one authenticated request per message, with a message ID and structured errors back.
Both approaches are below, with HTML, attachments, the errors you'll most likely hit, and what to change before production.
What you need
- Python 3.10 or later (the examples use standard library only, except the API section)
- An SMTP server or email API to send through, with credentials
- A sending domain you control, with SPF, DKIM and DMARC set up, so your mail isn't filtered as spam
Keep credentials in environment variables, never in code:
export SMTP_HOST=smtp.yourprovider.example
export SMTP_USER=your-username
export SMTP_PASSWORD=your-password
Option 1: smtplib with STARTTLS
This sends a plain-text email using nothing but the standard library:
import os
import smtplib
import ssl
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = "App <noreply@yourdomain.com>"
msg["To"] = "user@example.com"
msg["Subject"] = "Your receipt"
msg.set_content("Thanks for your order. Your receipt is attached to your account.")
context = ssl.create_default_context()
with smtplib.SMTP(os.environ["SMTP_HOST"], 587, timeout=30) as server:
server.starttls(context=context)
server.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
server.send_message(msg)
What each part does:
EmailMessagebuilds a correctly encoded MIME message. Avoid building messages by hand with string concatenation.smtplib.SMTP(host, 587)opens a plain connection, andstarttls()upgrades it to TLS before anything sensitive is sent.ssl.create_default_context()verifies the server's certificate.send_message()reads the sender and recipients from the headers, includingCcandBcc, and stripsBccfrom what's transmitted.smtplibadds noDateorMessage-IDheaders on its own. Many servers add them, but you can set them yourself withemail.utils.formatdate()andemail.utils.make_msgid(). Messages missing them score worse with spam filters.
If your provider uses implicit TLS on port 465, use smtplib.SMTP_SSL(host, 465, context=context) and skip starttls(). The smtplib documentation covers the rest of the API.
HTML with a plain-text fallback
Always send a plain-text part with your HTML. Some clients show only text, and spam filters penalise HTML-only messages. add_alternative() builds the multipart/alternative structure for you:
msg = EmailMessage()
msg["From"] = "App <noreply@yourdomain.com>"
msg["To"] = "user@example.com"
msg["Subject"] = "Reset your password"
reset_url = "https://yourapp.com/reset?token=abc123"
msg.set_content(f"Reset your password: {reset_url}\n\nThis link expires in 30 minutes.")
msg.add_alternative(
f"""\
<p>We received a request to reset your password.</p>
<p><a href="{reset_url}">Reset password</a></p>
<p>This link expires in 30 minutes.</p>
""",
subtype="html",
)
Attachments
from pathlib import Path
pdf = Path("invoice-1042.pdf")
msg.add_attachment(
pdf.read_bytes(),
maintype="application",
subtype="pdf",
filename=pdf.name,
)
Use mimetypes.guess_type() if you don't know the file type ahead of time. The Python docs have more email examples, including inline images.
Option 2: an HTTP email API
With an API you skip the SMTP conversation and post JSON. Here's the same receipt with the Groundmail Python SDK, which has no dependencies outside the standard library:
pip install groundmail
export GROUNDMAIL_API_KEY=gm_your_key_here
import os
from groundmail import Groundmail
client = Groundmail(os.environ["GROUNDMAIL_API_KEY"])
result = client.emails.send(
from_="App <noreply@yourdomain.com>",
to="user@example.com",
subject="Your receipt",
html="<p>Thanks for your order.</p>",
text="Thanks for your order.",
)
print(result["id"], result["status"]) # msg_..., sent
from_ has a trailing underscore because from is a Python keyword. The response carries a message ID you can store and look up later. Attachments are base64-encoded:
import base64
from pathlib import Path
pdf = Path("invoice-1042.pdf")
client.emails.send(
from_="billing@yourdomain.com",
to="user@example.com",
subject="Invoice 1042",
text="Your invoice is attached.",
attachments=[
{
"name": pdf.name,
"content_type": "application/pdf",
"data": base64.b64encode(pdf.read_bytes()).decode("ascii"),
}
],
)
If you'd rather not add a package, the same request with requests is a POST to https://api.groundmail.dev/emails with an Authorization: Bearer header and a JSON body containing from, to, subject, html and text.
Common errors and what they mean
smtplib.SMTPAuthenticationError: (535, ...). The server rejected your username or password. Check for whitespace in environment variables and that you're using the right credential type: many providers want an API key or SMTP-specific credential, not your account password. With Gmail you'll see 535-5.7.8 Username and Password not accepted unless you use an app password.
smtplib.SMTPNotSupportedError: SMTP AUTH extension not supported by server. You called login() before starttls(). Many servers only offer authentication after the connection is encrypted.
ssl.SSLError: [SSL: WRONG_VERSION_NUMBER] wrong version number. You used SMTP_SSL on port 587, which expects STARTTLS. Use SMTP plus starttls() on 587, or SMTP_SSL on 465.
TimeoutError or ConnectionRefusedError. The port is blocked. Most cloud providers block outbound port 25, and some networks block 587 or 465 too. Use 587 or 465, never 25, for submission.
smtplib.SMTPRecipientsRefused or SMTPSenderRefused. The server refused the address. Read the code and message in the exception: a 550 about the sender often means your From domain isn't verified with the provider.
ssl.SSLCertVerificationError. Python can't verify the server's certificate, often because the host name doesn't match or the system CA certificates are missing. Fix the host name or install CA certificates. Don't disable verification.
Production tips
- Keep keys in the environment or a secret manager, and use a key that can only send, not manage your account.
- Send from a background job, not inside the web request, so a slow mail server doesn't slow your page.
- Retry temporary failures with backoff. Retry rate limits,
5xxresponses and network errors. Don't retry validation errors: they'll fail the same way. - Make retries safe with an idempotency key, so a request that timed out after the provider accepted it doesn't send twice.
Here's a retry loop with the SDK. The SDK sends idempotency_key as an Idempotency-Key header, and Groundmail returns the original response for a repeated key within 24 hours:
import time
from groundmail import Groundmail, GroundmailError
RETRYABLE = {429, 500, 502, 503}
def send_with_retry(client: Groundmail, idempotency_key: str, **email):
for attempt in range(5):
try:
return client.emails.send(idempotency_key=idempotency_key, **email)
except GroundmailError as err:
if err.status not in RETRYABLE or attempt == 4:
raise
except OSError:
# Network error: the email may or may not have been accepted.
# Retrying with the same idempotency key is safe either way.
if attempt == 4:
raise
time.sleep(2 ** attempt)
send_with_retry(
client,
idempotency_key="receipt-order-1042",
from_="billing@yourdomain.com",
to="user@example.com",
subject="Your receipt",
text="Thanks for your order.",
)
Derive the key from what the email is for (an order ID, a reset token ID), not from a random value per attempt.
How Groundmail helps
Groundmail gives you both options on the same account: the groundmail package on PyPI for the API, and an SMTP relay at smtp.groundmail.dev on port 587 for smtplib or Django's SMTP backend. There's also a Django email backend (pip install 'groundmail[django]') if you want Django's send_mail() to use the API. Every message is signed with your domain's DKIM key once the domain is verified, and the quickstart gets you to a first send. To check that what your code sends lands in the inbox, send one message to the free email spam test.
Frequently asked questions
How do I send an email with Python without installing anything?
Use the standard library. Build the message with email.message.EmailMessage, connect with smtplib.SMTP to your provider's host on port 587, call starttls(), log in, and call send_message().
Should I use port 587 or 465 with smtplib?
Use smtplib.SMTP with starttls() on port 587, or smtplib.SMTP_SSL on port 465 if your provider offers implicit TLS. Mixing them up, such as SMTP_SSL on 587, fails with an SSL wrong version number error.
Can I send email from Python with Gmail?
For personal scripts, yes, with an app password on an account that has 2-Step Verification. For application email such as password resets and receipts, use a transactional email provider and send from your own domain, so mail is authenticated and you get delivery logs.
Why does my Python email go to spam?
Usually because the sending domain isn't authenticated with SPF, DKIM and DMARC, or the message is sent from a server or IP with no reputation. Send through a provider that signs with your domain, and include a plain-text part alongside the HTML.