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. We also have dedicated, in-depth guides for Django, Flask, and FastAPI. 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. See our email authentication guide for the full walkthrough. 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.

Frequently Asked Questions

Should I use Python's built-in smtplib or a dedicated email API?

Use a dedicated API for production. Python's smtplib connects directly to SMTP and requires you to handle authentication, TLS, retries, and error codes yourself. Dedicated providers like Sequenzy, Resend, or SendGrid wrap all of this in a clean SDK and add deliverability tracking.

How do I send HTML emails from Django?

Use Django's send_mail() with the html_message parameter, or use EmailMultiAlternatives to send both HTML and plain text versions. For better templates, use Django's template engine to render HTML from .html template files before passing to the send function.

How do I send emails asynchronously in Python?

Use Celery with Redis or RabbitMQ as a broker. Decorate your email-sending function with @celery.task and call it with .delay(). This offloads email sending to a background worker so your API response isn't blocked. For simpler setups, use asyncio with an async-compatible email SDK.

What's the best way to handle email templates in Python?

Use Jinja2 templates for dynamic HTML emails. Store templates as .html files, render them with variables at send time. This separates email content from logic and makes templates easy to update. Django's built-in template engine works similarly.

How do I avoid emails going to spam when sending from Python?

Configure SPF, DKIM, and DMARC DNS records for your sending domain. Our deliverability guide covers this in detail. Use a dedicated email provider instead of sending directly from your server. Send from a consistent "from" address, and include a plain text version alongside HTML.

Can I send bulk emails from a Flask or FastAPI application?

Don't send bulk emails in the request/response cycle. Use a background task queue (Celery, Dramatiq, or Huey) to process bulk sends. This prevents request timeouts and gives you retry handling. Most providers also offer batch endpoints for sending to multiple recipients in a single API call.

How do I test email sending in Python without sending real emails?

Use unittest.mock to mock the email provider's SDK in tests. Django has a built-in locmem email backend that captures emails in memory. For integration testing, use your provider's sandbox mode or tools like Mailpit to capture emails locally.

How do I handle email attachments in Python?

Most email SDKs accept attachments as base64-encoded strings or file paths. Read the file, encode it with base64.b64encode(), and pass it with the MIME type and filename. Keep attachments under 10MB to avoid deliverability issues.

Is asyncio better than Celery for email sending?

Asyncio is lighter and works well for sending a few emails without blocking. Celery is better for production systems where you need retry logic, rate limiting, scheduled sends, and monitoring. Use asyncio for simple transactional emails and Celery for anything at scale.

How do I store email API keys in Python securely?

Use environment variables loaded with python-dotenv or your framework's settings. Never hardcode keys in source files. In production, use your platform's secrets manager (AWS Secrets Manager, GCP Secret Manager, or your PaaS's environment config). Add .env to .gitignore.