How to Send Emails in Python (Django, Flask, FastAPI - 2026)

Python's standard library has smtplib for sending emails. It works. It's also verbose, handles errors poorly, and leaves you managing SMTP connections, TLS, and deliverability yourself.
This guide covers the practical approach: using email API providers that handle delivery infrastructure so you can focus on your app. Working examples for Django, Flask, FastAPI, and standalone scripts. All code uses type hints and modern Python patterns.
smtplib vs API Providers
Here's the difference in practice:
# smtplib: you manage SMTP, TLS, MIME encoding, retries
import smtplib
from email.mime.text import MIMEText
msg = MIMEText("<p>Hello</p>", "html")
msg["Subject"] = "Hello"
msg["From"] = "you@gmail.com"
msg["To"] = "user@example.com"
with smtplib.SMTP("smtp.gmail.com", 587) as server:
server.starttls()
server.login("you@gmail.com", "app-password")
server.send_message(msg)# API provider: one HTTP call, they handle the rest
import requests
requests.post(
"https://api.sequenzy.com/v1/transactional/send",
headers={"Authorization": "Bearer sq_your_key"},
json={"to": "user@example.com", "subject": "Hello", "body": "<p>Hello</p>"},
)Use smtplib if you're talking to an internal SMTP server. Use an API provider for everything else.
Pick a Provider
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management from one API. Has native Stripe integration. If you're building a SaaS product, this keeps everything in one place.
- Resend is developer-friendly with a clean API. Good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise option. Feature-rich, sometimes complex. Good for high volume.
Install and Configure
pip install httpxpip install resendpip install sendgridAdd your API key to .env:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereCreate a reusable email client:
import os
import httpx
SEQUENZY_API_KEY = os.environ["SEQUENZY_API_KEY"]
SEQUENZY_BASE_URL = "https://api.sequenzy.com/v1"
client = httpx.Client(
base_url=SEQUENZY_BASE_URL,
headers={"Authorization": f"Bearer {SEQUENZY_API_KEY}"},
timeout=30.0,
)
def send_email(to: str, subject: str, body: str) -> dict:
response = client.post(
"/transactional/send",
json={"to": to, "subject": subject, "body": body},
)
response.raise_for_status()
return response.json()import os
import resend
resend.api_key = os.environ["RESEND_API_KEY"]
FROM_EMAIL = "Your App <noreply@yourdomain.com>"
def send_email(to: str, subject: str, html: str) -> dict:
return resend.Emails.send({
"from": FROM_EMAIL,
"to": to,
"subject": subject,
"html": html,
})import os
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
sg = SendGridAPIClient(os.environ["SENDGRID_API_KEY"])
FROM_EMAIL = "noreply@yourdomain.com"
def send_email(to: str, subject: str, html: str) -> dict:
message = Mail(
from_email=FROM_EMAIL,
to_emails=to,
subject=subject,
html_content=html,
)
response = sg.send(message)
return {"status_code": response.status_code}Send Your First Email
from email_client import send_email
result = send_email(
to="user@example.com",
subject="Hello from Python",
body="<p>Your app is sending emails.</p>",
)
print(f"Sent: {result}")from email_client import send_email
result = send_email(
to="user@example.com",
subject="Hello from Python",
html="<p>Your app is sending emails.</p>",
)
print(f"Sent: {result}")from email_client import send_email
result = send_email(
to="user@example.com",
subject="Hello from Python",
html="<p>Your app is sending emails.</p>",
)
print(f"Sent: {result}")python send_test.pySend from Django
Django has a built-in email system, but it uses SMTP by default. Here's how to use an API provider instead.
Option 1: Custom Email Backend
Create a backend that routes Django's send_mail() through your API provider:
import httpx
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
class SequenzyEmailBackend(BaseEmailBackend):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.client = httpx.Client(
base_url="https://api.sequenzy.com/v1",
headers={"Authorization": f"Bearer {settings.SEQUENZY_API_KEY}"},
timeout=30.0,
)
def send_messages(self, email_messages):
sent = 0
for message in email_messages:
try:
self.client.post(
"/transactional/send",
json={
"to": message.to[0],
"subject": message.subject,
"body": message.body if message.content_subtype == "html"
else f"<pre>{message.body}</pre>",
},
)
sent += 1
except httpx.HTTPError:
if not self.fail_silently:
raise
return sentimport resend
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
class ResendEmailBackend(BaseEmailBackend):
def __init__(self, **kwargs):
super().__init__(**kwargs)
resend.api_key = settings.RESEND_API_KEY
def send_messages(self, email_messages):
sent = 0
for message in email_messages:
try:
resend.Emails.send({
"from": settings.DEFAULT_FROM_EMAIL,
"to": message.to[0],
"subject": message.subject,
"html": message.body if message.content_subtype == "html"
else f"<pre>{message.body}</pre>",
})
sent += 1
except Exception:
if not self.fail_silently:
raise
return sentfrom django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
class SendGridEmailBackend(BaseEmailBackend):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.sg = SendGridAPIClient(settings.SENDGRID_API_KEY)
def send_messages(self, email_messages):
sent = 0
for message in email_messages:
try:
mail = Mail(
from_email=settings.DEFAULT_FROM_EMAIL,
to_emails=message.to[0],
subject=message.subject,
html_content=message.body if message.content_subtype == "html"
else f"<pre>{message.body}</pre>",
)
self.sg.send(mail)
sent += 1
except Exception:
if not self.fail_silently:
raise
return sentAdd to settings.py:
EMAIL_BACKEND = "myapp.email_backend.SequenzyEmailBackend"
SEQUENZY_API_KEY = os.environ["SEQUENZY_API_KEY"]EMAIL_BACKEND = "myapp.email_backend.ResendEmailBackend"
RESEND_API_KEY = os.environ["RESEND_API_KEY"]EMAIL_BACKEND = "myapp.email_backend.SendGridEmailBackend"
SENDGRID_API_KEY = os.environ["SENDGRID_API_KEY"]Now Django's built-in send_mail() uses your provider:
from django.core.mail import send_mail
send_mail(
subject="Welcome to our app",
message="",
from_email=None, # uses DEFAULT_FROM_EMAIL
recipient_list=["user@example.com"],
html_message="<h1>Welcome!</h1><p>Your account is ready.</p>",
)Option 2: Direct API Calls in Views
If you prefer calling the API directly (skip Django's email abstraction):
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from email_client import send_email
@require_POST
def send_welcome(request):
email = request.POST.get("email")
name = request.POST.get("name")
if not email or not name:
return JsonResponse({"error": "email and name required"}, status=400)
result = send_email(
to=email,
subject=f"Welcome, {name}",
body=f"<h1>Welcome, {name}</h1><p>Your account is ready.</p>",
)
return JsonResponse(result)from django.http import JsonResponse
from django.views.decorators.http import require_POST
from email_client import send_email
@require_POST
def send_welcome(request):
email = request.POST.get("email")
name = request.POST.get("name")
if not email or not name:
return JsonResponse({"error": "email and name required"}, status=400)
result = send_email(
to=email,
subject=f"Welcome, {name}",
html=f"<h1>Welcome, {name}</h1><p>Your account is ready.</p>",
)
return JsonResponse(result)from django.http import JsonResponse
from django.views.decorators.http import require_POST
from email_client import send_email
@require_POST
def send_welcome(request):
email = request.POST.get("email")
name = request.POST.get("name")
if not email or not name:
return JsonResponse({"error": "email and name required"}, status=400)
result = send_email(
to=email,
subject=f"Welcome, {name}",
html=f"<h1>Welcome, {name}</h1><p>Your account is ready.</p>",
)
return JsonResponse(result)Send from Flask
from flask import Flask, request, jsonify
from email_client import send_email
app = Flask(__name__)
@app.post("/api/send-welcome")
def send_welcome():
data = request.get_json()
email = data.get("email")
name = data.get("name")
if not email or not name:
return jsonify({"error": "email and name required"}), 400
result = send_email(
to=email,
subject=f"Welcome, {name}",
body=f"<h1>Welcome, {name}</h1><p>Your account is ready.</p>",
)
return jsonify(result)from flask import Flask, request, jsonify
from email_client import send_email
app = Flask(__name__)
@app.post("/api/send-welcome")
def send_welcome():
data = request.get_json()
email = data.get("email")
name = data.get("name")
if not email or not name:
return jsonify({"error": "email and name required"}), 400
result = send_email(
to=email,
subject=f"Welcome, {name}",
html=f"<h1>Welcome, {name}</h1><p>Your account is ready.</p>",
)
return jsonify(result)from flask import Flask, request, jsonify
from email_client import send_email
app = Flask(__name__)
@app.post("/api/send-welcome")
def send_welcome():
data = request.get_json()
email = data.get("email")
name = data.get("name")
if not email or not name:
return jsonify({"error": "email and name required"}), 400
result = send_email(
to=email,
subject=f"Welcome, {name}",
html=f"<h1>Welcome, {name}</h1><p>Your account is ready.</p>",
)
return jsonify(result)Send from FastAPI
FastAPI is async, so use httpx.AsyncClient for non-blocking email sends.
import os
import httpx
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
SEQUENZY_API_KEY = os.environ["SEQUENZY_API_KEY"]
class WelcomeRequest(BaseModel):
email: str
name: str
@app.post("/api/send-welcome")
async def send_welcome(data: WelcomeRequest):
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.sequenzy.com/v1/transactional/send",
headers={"Authorization": f"Bearer {SEQUENZY_API_KEY}"},
json={
"to": data.email,
"subject": f"Welcome, {data.name}",
"body": f"<h1>Welcome, {data.name}</h1><p>Your account is ready.</p>",
},
)
response.raise_for_status()
return response.json()import resend
import os
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
resend.api_key = os.environ["RESEND_API_KEY"]
class WelcomeRequest(BaseModel):
email: str
name: str
@app.post("/api/send-welcome")
async def send_welcome(data: WelcomeRequest):
# resend SDK is synchronous, wrap with run_in_threadpool for async
result = resend.Emails.send({
"from": "Your App <noreply@yourdomain.com>",
"to": data.email,
"subject": f"Welcome, {data.name}",
"html": f"<h1>Welcome, {data.name}</h1><p>Your account is ready.</p>",
})
return resultimport os
from fastapi import FastAPI
from pydantic import BaseModel
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
app = FastAPI()
sg = SendGridAPIClient(os.environ["SENDGRID_API_KEY"])
class WelcomeRequest(BaseModel):
email: str
name: str
@app.post("/api/send-welcome")
async def send_welcome(data: WelcomeRequest):
message = Mail(
from_email="noreply@yourdomain.com",
to_emails=data.email,
subject=f"Welcome, {data.name}",
html_content=f"<h1>Welcome, {data.name}</h1><p>Your account is ready.</p>",
)
response = sg.send(message)
return {"status_code": response.status_code}HTML Email Templates with Jinja2
Use Jinja2 templates to keep your email HTML separate from your Python code. This works with any framework.
pip install jinja2<!-- templates/emails/welcome.html -->
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; background: #f6f9fc; padding: 40px 0;">
<div style="max-width: 480px; margin: 0 auto; background: #fff; padding: 40px; border-radius: 8px;">
<h1 style="font-size: 24px; margin-bottom: 16px;">Welcome, {{ name }}</h1>
<p style="font-size: 16px; line-height: 1.6; color: #374151;">
Your account is ready. Click below to get started.
</p>
<a href="{{ login_url }}"
style="display:inline-block; background:#f97316; color:#fff; padding:12px 24px; border-radius:6px; text-decoration:none; font-weight:600; margin-top:16px;">
Go to Dashboard
</a>
</div>
</body>
</html># email_templates.py
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
templates_dir = Path(__file__).parent / "templates" / "emails"
env = Environment(loader=FileSystemLoader(str(templates_dir)), autoescape=True)
def render_welcome(name: str, login_url: str) -> str:
template = env.get_template("welcome.html")
return template.render(name=name, login_url=login_url)
def render_password_reset(reset_url: str) -> str:
template = env.get_template("password_reset.html")
return template.render(reset_url=reset_url)Then use it with any provider:
from email_client import send_email
from email_templates import render_welcome
html = render_welcome(name="Jane", login_url="https://app.yoursite.com")
send_email(
to="jane@example.com",
subject="Welcome to our app",
body=html,
)from email_client import send_email
from email_templates import render_welcome
html = render_welcome(name="Jane", login_url="https://app.yoursite.com")
send_email(
to="jane@example.com",
subject="Welcome to our app",
html=html,
)from email_client import send_email
from email_templates import render_welcome
html = render_welcome(name="Jane", login_url="https://app.yoursite.com")
send_email(
to="jane@example.com",
subject="Welcome to our app",
html=html,
)Background Email Sending with Celery
Don't send emails in your request handler. Use Celery to process them in the background.
pip install celery redis# tasks.py
from celery import Celery
from email_client import send_email
app = Celery("tasks", broker=os.environ.get("REDIS_URL", "redis://localhost:6379"))
@app.task(bind=True, max_retries=3, default_retry_delay=60)
def send_email_task(self, to: str, subject: str, body: str):
try:
return send_email(to=to, subject=subject, body=body)
except Exception as exc:
self.retry(exc=exc)Queue from your view:
# In your Django view or Flask route
from tasks import send_email_task
send_email_task.delay(
to="user@example.com",
subject="Welcome",
body="<h1>Welcome!</h1>",
)
# Returns immediately, email sends in the backgroundError Handling
import httpx
import os
import logging
logger = logging.getLogger(__name__)
SEQUENZY_API_KEY = os.environ["SEQUENZY_API_KEY"]
client = httpx.Client(
base_url="https://api.sequenzy.com/v1",
headers={"Authorization": f"Bearer {SEQUENZY_API_KEY}"},
timeout=30.0,
)
def send_email(to: str, subject: str, body: str) -> dict:
try:
response = client.post(
"/transactional/send",
json={"to": to, "subject": subject, "body": body},
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
logger.warning("Rate limited, try again later")
elif e.response.status_code == 401:
logger.error("Bad API key")
else:
logger.error(f"Email failed: {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Network error sending email: {e}")
raiseimport resend
import os
import logging
logger = logging.getLogger(__name__)
resend.api_key = os.environ["RESEND_API_KEY"]
FROM_EMAIL = "Your App <noreply@yourdomain.com>"
def send_email(to: str, subject: str, html: str) -> dict:
try:
return resend.Emails.send({
"from": FROM_EMAIL,
"to": to,
"subject": subject,
"html": html,
})
except Exception as e:
logger.error(f"Email to {to} failed: {e}")
raiseimport os
import logging
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
logger = logging.getLogger(__name__)
sg = SendGridAPIClient(os.environ["SENDGRID_API_KEY"])
FROM_EMAIL = "noreply@yourdomain.com"
def send_email(to: str, subject: str, html: str) -> dict:
message = Mail(
from_email=FROM_EMAIL,
to_emails=to,
subject=subject,
html_content=html,
)
try:
response = sg.send(message)
return {"status_code": response.status_code}
except Exception as e:
logger.error(f"Email to {to} failed: {e}")
raiseAdd a retry decorator:
import time
from functools import wraps
def with_retry(max_retries: int = 3):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
for attempt in range(max_retries + 1):
try:
return fn(*args, **kwargs)
except Exception:
if attempt == max_retries:
raise
time.sleep(2 ** attempt)
return wrapper
return decorator
@with_retry(max_retries=3)
def send_email_safe(to: str, subject: str, body: str) -> dict:
return send_email(to=to, subject=subject, body=body)Going to Production
1. Verify Your Domain
Add SPF, DKIM, and DMARC DNS records through your provider's dashboard. Without this, your emails land in spam.
2. Use a Dedicated Sending Domain
Send from mail.yourapp.com, not your root domain. Protects your main domain's reputation.
3. Use Environment Variables
Never hardcode API keys. Use python-dotenv for local development:
pip install python-dotenvfrom dotenv import load_dotenv
load_dotenv() # Loads .env file into os.environ4. Rate Limit Email Endpoints
from collections import defaultdict
import time
email_timestamps: dict[str, list[float]] = defaultdict(list)
def can_send_email(to: str, max_per_hour: int = 10) -> bool:
now = time.time()
hour_ago = now - 3600
timestamps = [t for t in email_timestamps[to] if t > hour_ago]
if len(timestamps) >= max_per_hour:
return False
timestamps.append(now)
email_timestamps[to] = timestamps
return TrueBeyond Transactional: Marketing and Automation
Once you're sending transactional emails, you'll want onboarding sequences, marketing campaigns, and lifecycle automation. Most teams wire together a transactional provider with Mailchimp or ConvertKit. Two dashboards, two billing systems, subscriber sync headaches.
Sequenzy handles both from one API. Transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration.
import httpx
# Add a subscriber when they sign up
client.post("/subscribers", json={
"email": "user@example.com",
"firstName": "Jane",
"tags": ["signed-up"],
"customAttributes": {"plan": "free", "source": "organic"},
})
# Tag them when they upgrade
client.post("/subscribers/tags", json={
"email": "user@example.com",
"tag": "customer",
})
# Track events to trigger sequences
client.post("/subscribers/events", json={
"email": "user@example.com",
"event": "onboarding.completed",
"properties": {"completedSteps": 5},
})Set up sequences in the Sequenzy dashboard, and your Python app triggers them based on user actions.
Wrapping Up
Here's what we covered:
- smtplib vs API providers and when to use each
- Django, Flask, and FastAPI examples for sending emails
- Jinja2 templates for maintainable email HTML
- Celery background tasks to keep responses fast
- Error handling with logging and retries
- Production checklist: domain verification, env variables, rate limiting
The code in this guide is production-ready. Pick your framework and provider, copy the patterns, and start sending.