Back to Blog

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

15 min read

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

Terminal
pip install httpx
Terminal
pip install resend
Terminal
pip install sendgrid

Add your API key to .env:

.env
SEQUENZY_API_KEY=sq_your_api_key_here
.env
RESEND_API_KEY=re_your_api_key_here
.env
SENDGRID_API_KEY=SG.your_api_key_here

Create a reusable email client:

email_client.py
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()
email_client.py
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,
  })
email_client.py
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

send_test.py
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}")
send_test.py
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}")
send_test.py
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.py

Send 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:

myapp/email_backend.py
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 sent
myapp/email_backend.py
import 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 sent
myapp/email_backend.py
from 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 sent

Add to settings.py:

settings.py
EMAIL_BACKEND = "myapp.email_backend.SequenzyEmailBackend"
SEQUENZY_API_KEY = os.environ["SEQUENZY_API_KEY"]
settings.py
EMAIL_BACKEND = "myapp.email_backend.ResendEmailBackend"
RESEND_API_KEY = os.environ["RESEND_API_KEY"]
settings.py
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):

myapp/views.py
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)
myapp/views.py
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)
myapp/views.py
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

app.py
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)
app.py
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)
app.py
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.

main.py
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()
main.py
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 result
main.py
import 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:

send_welcome.py
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,
)
send_welcome.py
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,
)
send_welcome.py
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 background

Error Handling

email_client.py
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}")
      raise
email_client.py
import 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}")
      raise
email_client.py
import 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}")
      raise

Add 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-dotenv
from dotenv import load_dotenv
load_dotenv()  # Loads .env file into os.environ

4. 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 True

Beyond 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:

  1. smtplib vs API providers and when to use each
  2. Django, Flask, and FastAPI examples for sending emails
  3. Jinja2 templates for maintainable email HTML
  4. Celery background tasks to keep responses fast
  5. Error handling with logging and retries
  6. 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.