How to Send Email with Python
Two ways to send email from Python: the HTTP API (recommended for new code) and SMTP. Covers retries with backoff, idempotency keys, attachments, Django integration, and error handling.

How to Send Email with Python
Python is one of the most popular languages for sending transactional email — it ships batteries-included with smtplib and email, and the HTTP API approach via requests is even simpler for modern applications. This guide covers both paths, when to use each, and how to handle the real-world issues that come up in production: authentication, attachments, retries, and error handling.
The Two Approaches
HTTP API — Send a POST request to Rovela's REST endpoint. No mail server configuration needed, works from any environment (serverless, Docker, CI), and you get structured error responses, delivery webhooks, and idempotency keys. This is the recommended approach for new code.
SMTP — Connect to a mail server and relay the message. Required when you're using an existing library or framework that only speaks SMTP (Django's email backend, for example), or when you need to send via a server you control.
Sending via the HTTP API
Install requests if you don't already have it:
pip install requests
A minimal send:
import requests
resp = requests.post(
"https://api.rovela.dev/emails",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"from": "no-reply@yourdomain.com",
"to": ["user@example.com"],
"subject": "Your order has shipped",
"html": "<p>Your order <strong>#1234</strong> is on its way.</p>",
"text": "Your order #1234 is on its way.",
},
)
resp.raise_for_status()
print(resp.json()["id"])
Always include both html and text. Many mail clients display the plain-text version in previews, and some users prefer it; a missing text body scores worse with spam filters.
Production Patterns
Environment variables for credentials
Never hard-code your API key. Load it from the environment:
import os, requests
API_KEY = os.environ["ROVELA_API_KEY"]
def send_email(to, subject, html, text):
resp = requests.post(
"https://api.rovela.dev/emails",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"from": "no-reply@yourdomain.com", "to": to,
"subject": subject, "html": html, "text": text},
timeout=10,
)
resp.raise_for_status()
return resp.json()["id"]
Retry with exponential backoff
Network errors and transient 5xx responses happen. Wrap sends in a retry loop:
import time, random
def send_with_retry(payload, max_attempts=3):
for attempt in range(max_attempts):
try:
resp = requests.post(
"https://api.rovela.dev/emails",
headers={"Authorization": f"Bearer {API_KEY}"},
json=payload, timeout=10,
)
if resp.status_code < 500:
resp.raise_for_status()
return resp.json()["id"]
except requests.RequestException:
pass
if attempt < max_attempts - 1:
time.sleep(2 ** attempt + random.random())
raise RuntimeError("Failed to send after retries")
Don't retry 4xx errors — a 422 (validation error) or 401 (bad key) won't succeed no matter how many times you try.
Idempotency keys
If your application might call the send function more than once for the same event (retried background jobs, duplicate webhooks), pass an idempotency key:
import hashlib
idem_key = hashlib.sha256(f"order-shipped-{order_id}".encode()).hexdigest()[:32]
requests.post(url, headers={
"Authorization": f"Bearer {API_KEY}",
"Idempotency-Key": idem_key,
}, json=payload)
Rovela will deduplicate sends with the same key within 24 hours.
Sending Attachments
import base64, pathlib
pdf_bytes = pathlib.Path("invoice.pdf").read_bytes()
requests.post(url, headers=headers, json={
"from": "billing@yourdomain.com",
"to": ["customer@example.com"],
"subject": "Your invoice",
"html": "<p>Please find your invoice attached.</p>",
"text": "Please find your invoice attached.",
"attachments": [{
"filename": "invoice.pdf",
"content": base64.b64encode(pdf_bytes).decode(),
"content_type": "application/pdf",
}],
})
Keep attachment sizes reasonable — most providers reject messages over 10 MB, and large attachments increase spam scores. For large files, link to a signed URL in the email body instead.
Sending via SMTP
Use this when you're integrating with a framework that provides an SMTP abstraction (Django, Flask-Mail, etc.) or when your infrastructure routes all outbound mail through a relay.
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
msg = MIMEMultipart("alternative")
msg["Subject"] = "Test message"
msg["From"] = "no-reply@yourdomain.com"
msg["To"] = "recipient@example.com"
msg.attach(MIMEText("Plain text version.", "plain"))
msg.attach(MIMEText("<p>HTML version.</p>", "html"))
with smtplib.SMTP_SSL("smtp.rovela.dev", 465) as s:
s.login("no-reply@yourdomain.com", "YOUR_SMTP_PASSWORD")
s.send_message(msg)
Port 465 uses implicit TLS from the start of the connection. Port 587 uses STARTTLS:
with smtplib.SMTP("smtp.rovela.dev", 587) as s:
s.ehlo()
s.starttls()
s.login("no-reply@yourdomain.com", "YOUR_SMTP_PASSWORD")
s.send_message(msg)
Django Integration
Django uses SMTP by default but can use any backend. With Rovela's HTTP API:
# settings.py
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.rovela.dev"
EMAIL_PORT = 465
EMAIL_USE_SSL = True
EMAIL_HOST_USER = "no-reply@yourdomain.com"
EMAIL_HOST_PASSWORD = env("ROVELA_SMTP_PASSWORD")
DEFAULT_FROM_EMAIL = "no-reply@yourdomain.com"
Then send from anywhere in your Django app:
from django.core.mail import send_mail
send_mail(
"Subject",
"Plain text body",
"no-reply@yourdomain.com",
["recipient@example.com"],
html_message="<p>HTML body</p>",
)
Timeouts, Connections and Concurrency
Three settings belong in the wrapper function rather than being decided at each call site:
Timeout. requests sets none by default, so a server that stops responding leaves your call hanging until the operating system gives up on the TCP connection — potentially minutes. Inside a web request that means the whole request thread goes with it. timeout=10 is a reasonable default; it covers both the connect and the read phase.
Connection reuse. Creating a new Session on every requests.post wastes the TLS handshake. In a long-running service, build one module-level session = requests.Session() so the connection pool underneath can reuse it. At volume this is a measurable share of your latency.
A concurrency ceiling. When sending in bulk, don't reach for ThreadPoolExecutor(max_workers=500). Rate limits are counted per second; saturate them and you get a burst of 429s, everything falls into the retry queue, and the batch finishes slower than a modest pool would have. Ten to twenty workers with Retry-After backoff on 429 gives steadier throughput.
from concurrent.futures import ThreadPoolExecutor
def send_batch(recipients, payload_for):
with ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(send_with_retry, payload_for(r)): r for r in recipients}
results = {}
for fut in futures:
addr = futures[fut]
try:
results[addr] = fut.result()
except Exception as exc:
results[addr] = f"failed: {exc}"
return results
Each send is caught on its own, so one failure doesn't stop the batch. That matters more than anything else here — an uncaught exception halts the run midway and leaves you without a record of what went out.
Receiving Delivery Events
A 200 from the API means the message was accepted, not that it was delivered. Delivery, bounces, opens and clicks arrive as webhook events, which you configure in the Rovela dashboard and receive on an endpoint in your application.
Verify the signature against the raw request body, never the parsed JSON. Once a framework has parsed and re-serialised the payload, byte order, whitespace and escaping can change and the signature will not match. In Flask:
from flask import Flask, request, abort
import hmac, hashlib
app = Flask(__name__)
@app.post("/webhooks/rovela")
def rovela_webhook():
signature = request.headers.get("X-Rovela-Signature", "")
raw = request.get_data() # raw bytes, before request.get_json()
expected = hmac.new(
WEBHOOK_SECRET.encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
handle_event(event) # deduplicate on event["id"]
return "", 204
Two things that are easy to miss:
- Use
hmac.compare_digest, not==, to avoid a timing side channel. - Webhooks are redelivered. The same
event["id"]can arrive twice, so the handler must be idempotent or users get duplicate notifications.
Reading SMTP Return Values
With SMTP, the exception type smtplib raises already tells you whether a retry is worth it:
| Exception | Meaning | Action |
|---|---|---|
SMTPAuthenticationError | Authentication failed | Wrong password or app password; don't retry |
SMTPRecipientsRefused | Recipient rejected | Invalid address or refused; don't retry |
SMTPSenderRefused | Sender rejected | Domain unverified or address not allowed |
SMTPDataError | Content rejected | Message or headers non-compliant |
SMTPServerDisconnected | Connection dropped | Retry |
OSError / socket.timeout | Network layer | Retry |
SMTPRecipientsRefused carries a (code, message) pair per address. Log it: a 550 usually means the mailbox doesn't exist and belongs on your suppression list, while 450/451 is a temporary condition worth another attempt later.
When to Move from SMTP to the API
Projects already sending over SMTP don't need to rush. Once any of the following is true, the migration pays for itself:
- You need per-message delivery status and today you're guessing or reading logs.
- You need idempotent retries, and SMTP has no idempotency semantics — a resend is a second delivery.
- You deploy to serverless or a container platform that blocks outbound port 25.
- You want suppression lists, bounce classification and complaint handling without building them yourself.
The move can be incremental: point new code paths at the API, leave the old ones alone, and replace them one at a time once the new path has proven itself. Both can coexist — a domain can carry include:mail.rovela.dev in SPF and SMTP credentials at the same time.
Error Handling Reference
| Status | Meaning | Action |
|---|---|---|
| 200/201 | Sent | Read id from response |
| 400 | Bad payload | Fix the request; don't retry |
| 401 | Invalid API key | Check env var; rotate key if needed |
| 422 | Validation error | Read error detail; fix and resubmit |
| 429 | Rate limited | Back off and retry after Retry-After seconds |
| 5xx | Server error | Retry with backoff |
Log the Rovela message ID (resp.json()["id"]) on success. If a user reports a missing email, you can use that ID to look up the delivery event in the Rovela dashboard.