All posts
4 min readPigeonify

How to send email in Django

Configure Django's email backend for a real provider, move sending to Celery, and use templates properly — without the defaults that send mail inside the request.

djangopythonsmtptutorial

Django's email layer works out of the box, which is exactly why so many Django apps ship with mail sent synchronously inside the request and no idea whether it arrives.

Before you start

You need three things, and they take about five minutes:

  1. An account. Sign up free — 5,000 emails a month, no card.
  2. A verified sending domain. Add your domain in the dashboard and publish the DKIM and SPF records it gives you. You cannot send from a domain you have not proved you control, and neither can anyone pretending to be you — that is the whole point. If the terms are unfamiliar, SPF, DKIM and DMARC explained covers them properly.
  3. An API key. Create one under API Keys. Treat it like a password: it can send mail as you.

Put the key in your environment rather than your source. Django reads it from there in every example below.

Settings

# settings.py
import os

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.pigeonify.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = os.environ["PIGEONIFY_SMTP_USER"]
EMAIL_HOST_PASSWORD = os.environ["PIGEONIFY_SMTP_PASS"]
DEFAULT_FROM_EMAIL = "Your Product <you@yourdomain.com>"
SERVER_EMAIL = "errors@yourdomain.com"   # used for 500 error mail

Set SERVER_EMAIL separately. Django sends exception reports from it, and if that address is not on a verified domain the error mail silently fails — precisely when you need it.

In development, skip the network entirely:

if DEBUG:
    EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"

Templated HTML with a text fallback

from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags

def send_welcome(user):
    context = {"name": user.first_name, "login_url": "https://yourdomain.com/login"}
    html = render_to_string("emails/welcome.html", context)

    message = EmailMultiAlternatives(
        subject="Welcome aboard",
        body=strip_tags(html),      # the plain-text part
        to=[user.email],
    )
    message.attach_alternative(html, "text/html")
    message.send()

strip_tags is a lazy but honest fallback. If the HTML is table-heavy it produces something ugly; writing a real text template is better, and takes ten minutes.

Move it off the request

send() blocks until the SMTP conversation finishes. On a signup endpoint that is a second of latency on every registration, and an outage if the provider is slow:

# tasks.py
from celery import shared_task
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags

@shared_task(
    bind=True,
    autoretry_for=(Exception,),
    retry_backoff=True,
    retry_kwargs={"max_retries": 3},
)
def send_welcome_email(self, email, name):
    html = render_to_string("emails/welcome.html", {"name": name})
    message = EmailMultiAlternatives("Welcome aboard", strip_tags(html), to=[email])
    message.attach_alternative(html, "text/html")
    message.send()

retry_backoff=True spaces the retries out. Without it three retries happen almost immediately, which helps with nothing — a provider that is rate limiting you needs time, not three more requests.

Call it with .delay():

send_welcome_email.delay(user.email, user.first_name)

Password resets

Django's auth views send mail through the same backend, so they work once the settings are right. Override the templates rather than the view:

templates/registration/password_reset_email.html
templates/registration/password_reset_subject.txt

Keep the subject on one line — Django strips newlines, and a wrapped subject becomes a malformed header.

Testing

from django.core import mail
from django.test import TestCase

class WelcomeEmailTests(TestCase):
    def test_sends_to_the_user(self):
        send_welcome(self.user)
        self.assertEqual(len(mail.outbox), 1)
        self.assertEqual(mail.outbox[0].to, [self.user.email])
        self.assertIn("Welcome", mail.outbox[0].subject)

Django swaps in the locmem backend during tests automatically, so mail.outbox works with no configuration.

What to get right before production

Do not send from a request handler and wait. An HTTP call to any email provider can take a second or more, and a slow provider becomes a slow checkout page. Queue the send and return.

Handle the failure. A send can fail because the address is wrong, because you are rate limited, or because the provider is having a bad minute. The first is permanent and the other two are not — retrying a dead address forever is how a sender's reputation degrades. See how to handle bounces.

Use a subdomain. Send transactional mail from mail.yourdomain.com rather than the domain that carries your business correspondence. If sending reputation is ever damaged, the damage stays contained.

Never send to an address that has bounced or unsubscribed. Suppression should be automatic, not a thing you remember. Pigeonify suppresses permanent bounces and complaints for you and refuses to send to them afterwards.

Where to go next

If you are weighing providers, we publish honest comparisons that say where the other product is better.

Send email that actually arrives

Authentication, suppression and bounce handling built in. Start free, no card required.

Start free