← Back to blog

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.

Updated October 21, 2026
How to Send Email with Python

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:

ExceptionMeaningAction
SMTPAuthenticationErrorAuthentication failedWrong password or app password; don't retry
SMTPRecipientsRefusedRecipient rejectedInvalid address or refused; don't retry
SMTPSenderRefusedSender rejectedDomain unverified or address not allowed
SMTPDataErrorContent rejectedMessage or headers non-compliant
SMTPServerDisconnectedConnection droppedRetry
OSError / socket.timeoutNetwork layerRetry

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

StatusMeaningAction
200/201SentRead id from response
400Bad payloadFix the request; don't retry
401Invalid API keyCheck env var; rotate key if needed
422Validation errorRead error detail; fix and resubmit
429Rate limitedBack off and retry after Retry-After seconds
5xxServer errorRetry 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.