Back to Blog

How to Send Emails in Django (2026 Guide)

18 min read

Most "how to send email in Django" tutorials configure Gmail SMTP and call send_mail(). That's fine for a contact form. It's not fine when you need to send welcome emails, password resets, payment receipts, and onboarding sequences to real users who expect emails to actually arrive.

This guide covers the full picture: building a custom email backend so Django's built-in email system works with any API provider, building HTML templates with Django's template engine, offloading to Celery, sending emails from signals, handling Stripe webhooks, testing with locmem, and shipping to production. For a broader Python overview, see our Python email guide. All code examples use Django with type hints.

Pick an Email Provider

You have three solid options. Each code example below lets you switch between them.

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one API. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. Native Stripe integration and built-in retries.
  • Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface.

Install

Terminal
pip install requests django-environ
Terminal
pip install requests django-environ
Terminal
pip install requests django-environ

We're using requests directly for all providers instead of their Python SDKs. Every email provider is just an HTTP API. Using requests keeps your code consistent and avoids SDK-specific quirks.

Add your API key to .env:

.env
SEQUENZY_API_KEY=sq_your_api_key_here
DEFAULT_FROM_EMAIL=Your App <noreply@yourdomain.com>
.env
RESEND_API_KEY=re_your_api_key_here
DEFAULT_FROM_EMAIL=Your App <noreply@yourdomain.com>
.env
SENDGRID_API_KEY=SG.your_api_key_here
DEFAULT_FROM_EMAIL=noreply@yourdomain.com

Configure Settings

Use django-environ to load environment variables:

settings.py
import environ

env = environ.Env()
environ.Env.read_env(".env")

EMAIL_BACKEND = "myapp.backends.SequenzyBackend"
SEQUENZY_API_KEY = env("SEQUENZY_API_KEY")
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL", default="noreply@yourdomain.com")
APP_URL = env("APP_URL", default="https://yourapp.com")
settings.py
import environ

env = environ.Env()
environ.Env.read_env(".env")

EMAIL_BACKEND = "myapp.backends.ResendBackend"
RESEND_API_KEY = env("RESEND_API_KEY")
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL", default="Your App <noreply@yourdomain.com>")
APP_URL = env("APP_URL", default="https://yourapp.com")
settings.py
import environ

env = environ.Env()
environ.Env.read_env(".env")

EMAIL_BACKEND = "myapp.backends.SendGridBackend"
SENDGRID_API_KEY = env("SENDGRID_API_KEY")
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL", default="noreply@yourdomain.com")
APP_URL = env("APP_URL", default="https://yourapp.com")

Build a Custom Email Backend

This is the Django way. Create a backend that plugs into Django's email system, and all your existing send_mail() calls automatically use your API provider.

myapp/backends.py
import logging
import requests
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend

logger = logging.getLogger(__name__)


class SequenzyBackend(BaseEmailBackend):
  def __init__(self, **kwargs):
      super().__init__(**kwargs)
      self.session = requests.Session()
      self.session.headers.update({
          "Authorization": f"Bearer {settings.SEQUENZY_API_KEY}",
          "Content-Type": "application/json",
      })
      self.base_url = "https://api.sequenzy.com/v1"

  def send_messages(self, email_messages):
      sent = 0
      for message in email_messages:
          try:
              # Get HTML content from alternatives, fall back to plain text
              html = message.body
              if hasattr(message, "alternatives") and message.alternatives:
                  for content, mimetype in message.alternatives:
                      if mimetype == "text/html":
                          html = content
                          break

              for recipient in message.to:
                  response = self.session.post(
                      f"{self.base_url}/transactional/send",
                      json={
                          "to": recipient,
                          "subject": message.subject,
                          "body": html,
                      },
                      timeout=30,
                  )
                  response.raise_for_status()

              sent += 1
              logger.info(f"Email sent: {message.subject} -> {message.to}")
          except requests.RequestException as e:
              logger.error(f"Email send failed: {message.subject} -> {message.to}: {e}")
              if not self.fail_silently:
                  raise
      return sent
myapp/backends.py
import logging
import requests
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend

logger = logging.getLogger(__name__)


class ResendBackend(BaseEmailBackend):
  def __init__(self, **kwargs):
      super().__init__(**kwargs)
      self.session = requests.Session()
      self.session.headers.update({
          "Authorization": f"Bearer {settings.RESEND_API_KEY}",
          "Content-Type": "application/json",
      })
      self.base_url = "https://api.resend.com"

  def send_messages(self, email_messages):
      sent = 0
      for message in email_messages:
          try:
              html = message.body
              if hasattr(message, "alternatives") and message.alternatives:
                  for content, mimetype in message.alternatives:
                      if mimetype == "text/html":
                          html = content
                          break

              from_email = message.from_email or settings.DEFAULT_FROM_EMAIL

              for recipient in message.to:
                  response = self.session.post(
                      f"{self.base_url}/emails",
                      json={
                          "from": from_email,
                          "to": recipient,
                          "subject": message.subject,
                          "html": html,
                      },
                      timeout=30,
                  )
                  response.raise_for_status()

              sent += 1
              logger.info(f"Email sent: {message.subject} -> {message.to}")
          except requests.RequestException as e:
              logger.error(f"Email send failed: {message.subject} -> {message.to}: {e}")
              if not self.fail_silently:
                  raise
      return sent
myapp/backends.py
import logging
import requests
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend

logger = logging.getLogger(__name__)


class SendGridBackend(BaseEmailBackend):
  def __init__(self, **kwargs):
      super().__init__(**kwargs)
      self.session = requests.Session()
      self.session.headers.update({
          "Authorization": f"Bearer {settings.SENDGRID_API_KEY}",
          "Content-Type": "application/json",
      })
      self.base_url = "https://api.sendgrid.com/v3"

  def send_messages(self, email_messages):
      sent = 0
      for message in email_messages:
          try:
              html = message.body
              if hasattr(message, "alternatives") and message.alternatives:
                  for content, mimetype in message.alternatives:
                      if mimetype == "text/html":
                          html = content
                          break

              from_email = message.from_email or settings.DEFAULT_FROM_EMAIL

              response = self.session.post(
                  f"{self.base_url}/mail/send",
                  json={
                      "personalizations": [{"to": [{"email": r} for r in message.to]}],
                      "from": {"email": from_email},
                      "subject": message.subject,
                      "content": [{"type": "text/html", "value": html}],
                  },
                  timeout=30,
              )
              response.raise_for_status()

              sent += 1
              logger.info(f"Email sent: {message.subject} -> {message.to}")
          except requests.RequestException as e:
              logger.error(f"Email send failed: {message.subject} -> {message.to}: {e}")
              if not self.fail_silently:
                  raise
      return sent

Now all of Django's email functions work through your provider:

from django.core.mail import send_mail, EmailMultiAlternatives
 
# Simple text email
send_mail("Subject", "Body text", None, ["user@example.com"])
 
# HTML email (the Django way)
msg = EmailMultiAlternatives(
    subject="Welcome!",
    body="Welcome! Your account is ready.",  # plain text fallback
    to=["user@example.com"],
)
msg.attach_alternative("<h1>Welcome!</h1><p>Your account is ready.</p>", "text/html")
msg.send()

The backend pattern is powerful because your application code doesn't need to know which provider you're using. Swap providers by changing one line in settings.py.

Build Email Templates

Use Django's template engine with render_to_string. Start with a base template:

<!-- templates/emails/base.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:sans-serif;">
  <div style="max-width:480px;margin:40px auto;background:#fff;padding:40px;border-radius:8px;">
    {% block content %}{% endblock %}
    <hr style="border:none;border-top:1px solid #e5e7eb;margin:32px 0 16px;" />
    <p style="font-size:12px;color:#9ca3af;">
      &copy; {% now "Y" %} Your App. All rights reserved.
    </p>
  </div>
</body>
</html>

Django's {% now "Y" %} tag gives you the current year automatically.

Extend it for each email type:

<!-- templates/emails/welcome.html -->
{% extends "emails/base.html" %}
 
{% block content %}
<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. You can log in and start exploring.
</p>
<a href="{{ login_url }}"
   style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-top:16px;">
  Go to Dashboard
</a>
{% endblock %}

Create a reusable email-sending module:

# myapp/emails.py
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
 
 
def send_templated_email(
    to: str,
    subject: str,
    template: str,
    context: dict | None = None,
):
    """Send an HTML email using a Django template."""
    html = render_to_string(template, context or {})
 
    msg = EmailMultiAlternatives(
        subject=subject,
        body=subject,  # plain text fallback
        to=[to],
    )
    msg.attach_alternative(html, "text/html")
    msg.send()
 
 
def send_welcome_email(user):
    send_templated_email(
        to=user.email,
        subject=f"Welcome, {user.first_name}",
        template="emails/welcome.html",
        context={
            "name": user.first_name or user.username,
            "login_url": f"{settings.APP_URL}/dashboard",
        },
    )

Send from Views

Function-Based View

# myapp/views.py
import json
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt
from myapp.emails import send_welcome_email
 
 
@require_POST
def signup(request):
    data = json.loads(request.body)
 
    # Create user...
    user = create_user(data)
 
    send_welcome_email(user)
 
    return JsonResponse({"user": {"id": user.id, "email": user.email}})

Class-Based View

# myapp/views.py
from django.http import JsonResponse
from django.views import View
from myapp.emails import send_welcome_email
 
 
class SignupView(View):
    def post(self, request):
        data = json.loads(request.body)
        user = create_user(data)
        send_welcome_email(user)
        return JsonResponse({"user": {"id": user.id}})

Django REST Framework

If you're using DRF, integrate with serializers:

# myapp/serializers.py
from rest_framework import serializers
 
class SignupSerializer(serializers.Serializer):
    email = serializers.EmailField()
    name = serializers.CharField(max_length=100)
    password = serializers.CharField(min_length=8, write_only=True)
# myapp/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from myapp.serializers import SignupSerializer
from myapp.emails import send_welcome_email
 
 
class SignupView(APIView):
    def post(self, request):
        serializer = SignupSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
 
        user = create_user(serializer.validated_data)
        send_welcome_email(user)
 
        return Response({"user": {"id": user.id}}, status=status.HTTP_201_CREATED)

Background Sending with Celery

Sending emails in the request cycle blocks the response. Use Celery to offload email sends:

pip install celery redis
# myproject/celery.py
import os
from celery import Celery
 
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
 
app = Celery("myproject")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
# settings.py
CELERY_BROKER_URL = "redis://localhost:6379/0"
# myapp/tasks.py
from celery import shared_task
from django.contrib.auth import get_user_model
 
User = get_user_model()
 
 
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_welcome_email_task(self, user_id: int):
    try:
        user = User.objects.get(id=user_id)
 
        from myapp.emails import send_welcome_email
        send_welcome_email(user)
    except User.DoesNotExist:
        pass  # User was deleted between queueing and execution
    except Exception as exc:
        self.retry(exc=exc)
 
 
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_templated_email_task(self, to: str, subject: str, template: str, context: dict):
    try:
        from myapp.emails import send_templated_email
        send_templated_email(to=to, subject=subject, template=template, context=context)
    except Exception as exc:
        self.retry(exc=exc)

Use in your views:

from myapp.tasks import send_welcome_email_task
 
def signup(request):
    user = create_user(data)
    send_welcome_email_task.delay(user.id)  # Non-blocking
    return JsonResponse({"user": {"id": user.id}})

Important: pass the user_id, not the user object. Celery serializes task arguments to JSON, so pass IDs and re-fetch from the database in the task.

Start the worker:

celery -A myproject worker --loglevel=info

Send Emails from Signals

Django signals are great for sending emails when something happens in your models without coupling the logic to your views:

# myapp/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from myapp.tasks import send_welcome_email_task
 
User = get_user_model()
 
 
@receiver(post_save, sender=User)
def on_user_created(sender, instance, created, **kwargs):
    if created:
        send_welcome_email_task.delay(instance.id)
# myapp/apps.py
from django.apps import AppConfig
 
 
class MyappConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "myapp"
 
    def ready(self):
        import myapp.signals  # noqa: F401

Now every new user automatically gets a welcome email, regardless of whether they signed up from a view, the admin, or a management command.

Common Email Patterns for SaaS

Password Reset

<!-- templates/emails/password-reset.html -->
{% extends "emails/base.html" %}
 
{% block content %}
<h2 style="font-size:20px;margin-bottom:16px;">Password Reset</h2>
<p style="font-size:16px;line-height:1.6;color:#374151;">
  Click the button below to reset your password. This link expires in 1 hour.
</p>
<a href="{{ reset_url }}"
   style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;">
  Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
  If you didn't request this, ignore this email.
</p>
{% endblock %}
# myapp/emails.py
def send_password_reset_email(user, reset_token: str):
    reset_url = f"{settings.APP_URL}/reset-password?token={reset_token}"
 
    send_templated_email(
        to=user.email,
        subject="Reset your password",
        template="emails/password-reset.html",
        context={"reset_url": reset_url},
    )

Payment Receipt

<!-- templates/emails/receipt.html -->
{% extends "emails/base.html" %}
 
{% block content %}
<h2 style="font-size:20px;margin-bottom:16px;">Payment Received</h2>
<p style="font-size:16px;color:#374151;">Thanks for your payment. Here's your receipt:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
  <tr>
    <td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
    <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">{{ plan }}</td>
  </tr>
  <tr>
    <td style="padding:8px;font-weight:600;">Total</td>
    <td style="padding:8px;text-align:right;font-weight:600;">{{ amount }}</td>
  </tr>
</table>
<a href="{{ invoice_url }}" style="color:#f97316;">View full invoice</a>
{% endblock %}
# myapp/emails.py
def send_receipt_email(email: str, amount: int, plan: str, invoice_url: str):
    formatted = f"${amount / 100:.2f}"
 
    send_templated_email(
        to=email,
        subject=f"Payment receipt - {formatted}",
        template="emails/receipt.html",
        context={
            "amount": formatted,
            "plan": plan,
            "invoice_url": invoice_url,
        },
    )

Stripe Webhook Handler

myapp/views.py
import hmac
import hashlib
import json
import time
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from myapp.emails import send_receipt_email
from myapp.emails import send_templated_email


def verify_stripe_signature(payload: bytes, signature: str, secret: str) -> bool:
  parts = dict(item.split("=", 1) for item in signature.split(","))
  timestamp = parts.get("t", "")
  expected_sig = parts.get("v1", "")

  if not timestamp or not expected_sig:
      return False

  if abs(time.time() - int(timestamp)) > 300:
      return False

  signed_payload = f"{timestamp}.{payload.decode()}"
  computed = hmac.new(
      secret.encode(),
      signed_payload.encode(),
      hashlib.sha256,
  ).hexdigest()

  return hmac.compare_digest(computed, expected_sig)


@csrf_exempt
@require_POST
def stripe_webhook(request):
  payload = request.body
  signature = request.headers.get("Stripe-Signature", "")

  if not verify_stripe_signature(payload, signature, settings.STRIPE_WEBHOOK_SECRET):
      return JsonResponse({"error": "Invalid signature"}, status=400)

  event = json.loads(payload)

  if event["type"] == "checkout.session.completed":
      session = event["data"]["object"]
      send_receipt_email(
          email=session["customer_email"],
          amount=session["amount_total"],
          plan=session.get("metadata", {}).get("plan", "Pro"),
          invoice_url=session.get("invoice_url", f"{settings.APP_URL}/billing"),
      )

  if event["type"] == "invoice.payment_failed":
      invoice = event["data"]["object"]
      send_templated_email(
          to=invoice["customer_email"],
          subject="Payment failed - action needed",
          template="emails/payment-failed.html",
          context={"billing_url": f"{settings.APP_URL}/billing"},
      )

  return JsonResponse({"received": True})
myapp/views.py
import hmac
import hashlib
import json
import time
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from myapp.emails import send_receipt_email
from myapp.emails import send_templated_email


def verify_stripe_signature(payload: bytes, signature: str, secret: str) -> bool:
  parts = dict(item.split("=", 1) for item in signature.split(","))
  timestamp = parts.get("t", "")
  expected_sig = parts.get("v1", "")

  if not timestamp or not expected_sig:
      return False

  if abs(time.time() - int(timestamp)) > 300:
      return False

  signed_payload = f"{timestamp}.{payload.decode()}"
  computed = hmac.new(
      secret.encode(),
      signed_payload.encode(),
      hashlib.sha256,
  ).hexdigest()

  return hmac.compare_digest(computed, expected_sig)


@csrf_exempt
@require_POST
def stripe_webhook(request):
  payload = request.body
  signature = request.headers.get("Stripe-Signature", "")

  if not verify_stripe_signature(payload, signature, settings.STRIPE_WEBHOOK_SECRET):
      return JsonResponse({"error": "Invalid signature"}, status=400)

  event = json.loads(payload)

  if event["type"] == "checkout.session.completed":
      session = event["data"]["object"]
      send_receipt_email(
          email=session["customer_email"],
          amount=session["amount_total"],
          plan=session.get("metadata", {}).get("plan", "Pro"),
          invoice_url=session.get("invoice_url", f"{settings.APP_URL}/billing"),
      )

  if event["type"] == "invoice.payment_failed":
      invoice = event["data"]["object"]
      send_templated_email(
          to=invoice["customer_email"],
          subject="Payment failed - action needed",
          template="emails/payment-failed.html",
          context={"billing_url": f"{settings.APP_URL}/billing"},
      )

  return JsonResponse({"received": True})
myapp/views.py
import hmac
import hashlib
import json
import time
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from myapp.emails import send_receipt_email
from myapp.emails import send_templated_email


def verify_stripe_signature(payload: bytes, signature: str, secret: str) -> bool:
  parts = dict(item.split("=", 1) for item in signature.split(","))
  timestamp = parts.get("t", "")
  expected_sig = parts.get("v1", "")

  if not timestamp or not expected_sig:
      return False

  if abs(time.time() - int(timestamp)) > 300:
      return False

  signed_payload = f"{timestamp}.{payload.decode()}"
  computed = hmac.new(
      secret.encode(),
      signed_payload.encode(),
      hashlib.sha256,
  ).hexdigest()

  return hmac.compare_digest(computed, expected_sig)


@csrf_exempt
@require_POST
def stripe_webhook(request):
  payload = request.body
  signature = request.headers.get("Stripe-Signature", "")

  if not verify_stripe_signature(payload, signature, settings.STRIPE_WEBHOOK_SECRET):
      return JsonResponse({"error": "Invalid signature"}, status=400)

  event = json.loads(payload)

  if event["type"] == "checkout.session.completed":
      session = event["data"]["object"]
      send_receipt_email(
          email=session["customer_email"],
          amount=session["amount_total"],
          plan=session.get("metadata", {}).get("plan", "Pro"),
          invoice_url=session.get("invoice_url", f"{settings.APP_URL}/billing"),
      )

  if event["type"] == "invoice.payment_failed":
      invoice = event["data"]["object"]
      send_templated_email(
          to=invoice["customer_email"],
          subject="Payment failed - action needed",
          template="emails/payment-failed.html",
          context={"billing_url": f"{settings.APP_URL}/billing"},
      )

  return JsonResponse({"received": True})

For more on Stripe-triggered email patterns, see our Stripe email integration guide. Note the @csrf_exempt decorator -- Stripe webhooks are external requests that don't have a CSRF token. The request.body gives you the raw bytes needed for signature verification.

Testing Emails

Django has a built-in test backend that captures emails in memory. No mocking needed.

# settings/test.py (or in your test configuration)
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
# tests/test_emails.py
from django.core import mail
from django.test import TestCase, override_settings
from django.contrib.auth import get_user_model
from myapp.emails import send_welcome_email, send_password_reset_email
 
User = get_user_model()
 
 
@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
class EmailTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="alice",
            email="alice@example.com",
            first_name="Alice",
            password="testpass123",
        )
 
    def test_welcome_email_sent(self):
        send_welcome_email(self.user)
 
        self.assertEqual(len(mail.outbox), 1)
        self.assertEqual(mail.outbox[0].subject, "Welcome, Alice")
        self.assertIn("alice@example.com", mail.outbox[0].to)
 
    def test_welcome_email_has_html(self):
        send_welcome_email(self.user)
 
        msg = mail.outbox[0]
        self.assertEqual(len(msg.alternatives), 1)
        html, mimetype = msg.alternatives[0]
        self.assertEqual(mimetype, "text/html")
        self.assertIn("Welcome, Alice", html)
        self.assertIn("/dashboard", html)
 
    def test_password_reset_email(self):
        send_password_reset_email(self.user, "abc123token")
 
        self.assertEqual(len(mail.outbox), 1)
        msg = mail.outbox[0]
        html = msg.alternatives[0][0]
        self.assertIn("abc123token", html)
 
    def test_no_email_sent_for_empty_recipient(self):
        # Verify your email functions validate inputs
        mail.outbox.clear()
        self.assertEqual(len(mail.outbox), 0)

Use per-environment backends:

# settings/development.py
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"  # Prints to terminal
 
# settings/production.py
EMAIL_BACKEND = "myapp.backends.SequenzyBackend"
 
# settings/test.py
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"

Management Command for Testing

Create a management command to test email sending without going through the UI:

# myapp/management/commands/test_email.py
from django.core.management.base import BaseCommand
from myapp.emails import send_templated_email
 
 
class Command(BaseCommand):
    help = "Send a test email"
 
    def add_arguments(self, parser):
        parser.add_argument("to", type=str, help="Recipient email address")
 
    def handle(self, *args, **options):
        to = options["to"]
 
        send_templated_email(
            to=to,
            subject="Test email from Django",
            template="emails/welcome.html",
            context={"name": "Test User", "login_url": "https://yourapp.com"},
        )
 
        self.stdout.write(self.style.SUCCESS(f"Test email sent to {to}"))
python manage.py test_email user@example.com

Going to Production

1. Verify Your Domain

Every email provider requires domain verification. Add SPF, DKIM, and DMARC DNS records through your provider's dashboard. Our email authentication guide walks through the full process. Without this, your emails go straight to spam.

2. Use a Dedicated Sending Domain

Send from mail.yourapp.com instead of your root domain. Protects your main domain's reputation.

3. Use Gunicorn

gunicorn myproject.wsgi:application -w 4 --bind 0.0.0.0:8000

4. Always Use Celery in Production

Emails should never block HTTP responses. Queue everything with Celery.

5. Use Per-Environment Settings

# settings/base.py - shared settings
# settings/development.py - console backend, DEBUG=True
# settings/production.py - API backend, DEBUG=False
# settings/test.py - locmem backend

6. Set Up Logging

# settings.py
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {
        "console": {"class": "logging.StreamHandler"},
    },
    "loggers": {
        "myapp": {"handlers": ["console"], "level": "INFO"},
    },
}

Beyond Transactional: Marketing Emails and Sequences

At some point, you'll want more than one-off transactional emails. You'll want to:

  • Send onboarding sequences that guide new users through your product over several days
  • Run marketing campaigns to announce features or share updates
  • Automate lifecycle emails based on what users do (or don't do) in your app
  • Track engagement to see which emails get opened and clicked

Most teams stitch together a transactional provider (Resend, SendGrid) with a separate marketing tool (Mailchimp, ConvertKit). That means two dashboards, two billing systems, and keeping subscriber lists in sync.

Sequenzy handles both from one platform. Same API, same dashboard. You get transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS-specific automations like trial conversion and churn prevention.

Here's what subscriber management looks like:

import requests
 
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
 
# Add a subscriber when they sign up
requests.post("https://api.sequenzy.com/v1/subscribers", headers=headers, json={
    "email": "user@example.com",
    "firstName": "Jane",
    "tags": ["signed-up"],
    "customAttributes": {"plan": "free", "source": "organic"},
})
 
# Tag them when they upgrade
requests.post("https://api.sequenzy.com/v1/subscribers/tags", headers=headers, json={
    "email": "user@example.com",
    "tag": "customer",
})
 
# Track events to trigger automated sequences
requests.post("https://api.sequenzy.com/v1/subscribers/events", headers=headers, json={
    "email": "user@example.com",
    "event": "onboarding.completed",
    "properties": {"completedSteps": 5},
})

FAQ

Should I use Django's built-in email or call the API directly?

Use Django's built-in email system with a custom backend. The backend pattern decouples your application code from the provider. You can swap providers by changing one line in settings.py, use locmem for tests, and console for development — all without touching your email-sending code.

What about Flask-style direct requests calls?

You can call requests.post() directly in Django views, but you lose the benefits of Django's email abstraction: testability with locmem, mail.outbox in tests, per-environment backends, and the EmailMultiAlternatives API for HTML + plain text fallbacks.

Why Celery instead of threads?

Django typically runs under Gunicorn or uWSGI with multiple worker processes. Background threads work but aren't reliable across worker restarts. Celery gives you: persistent task queues that survive restarts, configurable retries with exponential backoff, task monitoring with Flower, and scheduled tasks with Celery Beat.

Can I use Django's send_mail with Celery?

Yes, but don't pass Django model instances to Celery tasks. Pass IDs and re-fetch:

# Good
send_welcome_email_task.delay(user.id)
 
# Bad - model instances can't be serialized to JSON
send_welcome_email_task.delay(user)

How do I handle email bounces?

Set up a webhook endpoint to receive delivery notifications from your provider. Mark bounced subscribers in your database and stop sending to them. With the custom backend pattern, your bounce-handling logic stays separate from your email-sending logic.

What's the best way to preview email templates?

Add a development-only view:

from django.conf import settings
from django.http import HttpResponse
from django.template.loader import render_to_string
 
if settings.DEBUG:
    def preview_email(request, template_name):
        html = render_to_string(f"emails/{template_name}.html", {
            "name": "Alice",
            "login_url": "#",
            "reset_url": "#",
        })
        return HttpResponse(html)

Can I use Django allauth with this?

Yes. Django allauth uses Django's email system internally. Set up the custom backend in settings.py and allauth's password reset, email verification, and other emails will automatically go through your API provider.

How do I send to multiple recipients?

For transactional emails, send one per recipient. For marketing-style sends, use your provider's batch API. Never put multiple recipients in the to list for transactional emails — they'll all see each other's addresses.

What about django-post-office?

django-post-office adds email queueing, scheduling, and logging to Django. It's useful if you want to manage email queues in Django admin. However, if you're already using Celery, django-post-office adds redundancy. Pick one or the other, not both.

How do I test Celery tasks that send emails?

Use CELERY_TASK_ALWAYS_EAGER = True in test settings. This makes Celery execute tasks synchronously, so mail.outbox works:

# settings/test.py
CELERY_TASK_ALWAYS_EAGER = True
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"

Wrapping Up

Here's what we covered:

  1. Custom email backend that plugs any provider into Django's email system
  2. Django templates with render_to_string for maintainable email HTML
  3. Celery background tasks for non-blocking email sends with retries
  4. Django signals for event-driven emails on model changes
  5. Stripe webhooks with @csrf_exempt and hmac verification
  6. Testing with Django's locmem backend and mail.outbox
  7. Management commands for manual email testing
  8. Per-environment backends: console for dev, locmem for tests, API for production

The code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start sending.

Frequently Asked Questions

Should I use Django's built-in email backend or a dedicated SDK?

For production, use a dedicated email provider's SDK or configure Django's email backend to use their SMTP/API. Django's built-in send_mail() is convenient but connects to SMTP directly, lacking deliverability tracking and analytics. Many providers offer Django-specific backends.

How do I send HTML emails in Django?

Use EmailMultiAlternatives to send both HTML and plain text versions. Create HTML templates in your templates directory and render them with render_to_string(). Always include a plain text alternative—some email clients don't render HTML.

How do I send emails asynchronously in Django?

Use Celery with Redis or RabbitMQ. Create a Celery task decorated with @shared_task that calls your email function, then trigger it with .delay() from your view. Django Q and Huey are lighter alternatives if Celery feels heavy for your project.

What's the best email backend for Django testing?

Use django.core.mail.backends.locmem.EmailBackend in your test settings. It captures all emails in django.core.mail.outbox so you can assert on recipients, subject lines, and content without sending anything. Set it in settings_test.py or as a test fixture.

How do I use Django templates for email content?

Create templates in templates/emails/ and render them with context: html = render_to_string('emails/welcome.html', {'name': user.name}). Use Django's template language for conditionals, loops, and variable interpolation. Keep email templates simple since email clients have limited CSS support.

How do I handle email failures in Django views?

Wrap send_mail() in a try/except block. For critical emails, use Celery's retry mechanism (self.retry(exc=exc, countdown=60)). Log failures with Django's logging framework. Never silently swallow email errors in production—you need visibility into delivery issues.

Should I use Django signals to trigger emails?

Signals work well for emails triggered by model changes (user created, order placed). Use post_save signals to send emails after the database transaction commits. However, don't overuse signals for complex workflows—explicit function calls in views are easier to debug and test.

How do I store email API keys in Django?

Use environment variables loaded via django-environ or python-decouple. Access them in settings.py with env('SEQUENZY_API_KEY'). For production, use your hosting platform's secrets management. Never put API keys directly in settings files.

How do I preview email templates during Django development?

Create a view that renders the email template with sample data and returns it as an HTTP response. Add it to your URL patterns in development only. This lets you iterate on email design in the browser without sending actual emails.

Can I send emails from Django management commands?

Yes. Management commands run with full Django context, so you can import your email functions and call them directly. This is useful for scheduled emails, batch sends, and one-off notifications. Use --dry-run flags during development to preview without sending.