Django

Sending email — send_mail, EmailMessage, and the console backend

Django's email API is deliberately backend-agnostic — the same send_mail() call works against a real SMTP server in production and a backend that just prints to the console in development, and getting that swap right is most of what matters here.

Beginner

3 min read

send_mail: the simple case

from django.core.mail import send_mail
 
send_mail(
    subject="Welcome to the app",
    message="Thanks for signing up.",
    from_email="noreply@example.com",
    recipient_list=["ada@example.com"],
)

send_mail covers the common case: one plain-text email, one or more recipients. It returns the number of successfully delivered messages (0 or 1 here), and raises on connection failure unless fail_silently=True is passed — which is almost never what's actually wanted, since silently swallowing a failed signup-confirmation email is a real, hard-to-notice bug.

The backend setting: where the email actually goes

# settings.py — development: prints the full email to the terminal, sends NOTHING
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
 
# settings.py — production: an actual SMTP connection
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.sendgrid.net"
EMAIL_PORT = 587
EMAIL_HOST_USER = "apikey"
EMAIL_HOST_PASSWORD = os.environ["SENDGRID_API_KEY"]
EMAIL_USE_TLS = True

EMAIL_BACKEND is what makes the exact same send_mail() call behave completely differently depending on environment — the console backend prints the full email (subject, body, headers) to stdout instead of sending anything, which is the standard local-dev setup: no real SMTP credentials needed, no risk of accidentally emailing a real address while testing, and immediate visibility into exactly what would have been sent. Swapping to smtp.EmailBackend in production is a settings-only change; application code calling send_mail never needs to know which backend is active.

locmem: the backend tests actually use

# settings.py (test settings, or set automatically by Django's test runner)
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
from django.core import mail
 
def test_signup_sends_welcome_email():
    signup(email="ada@example.com")
    assert len(mail.outbox) == 1
    assert mail.outbox[0].subject == "Welcome to the app"
    assert "ada@example.com" in mail.outbox[0].to

Django's test runner automatically switches to the locmem backend during tests — sent messages accumulate in django.core.mail.outbox, a plain list, instead of going anywhere real. This is what makes asserting "the right email was sent, to the right person, with the right content" possible in a test without an actual mail server, a network call, or any risk of really emailing someone during a test run.

EmailMessage: when send_mail isn't flexible enough

from django.core.mail import EmailMessage
 
email = EmailMessage(
    subject="Your invoice",
    body="See attached.",
    from_email="billing@example.com",
    to=["ada@example.com"],
    cc=["accounting@example.com"],
    reply_to=["support@example.com"],
)
email.attach_file("invoice.pdf")
email.send()

send_mail has no parameters for CC, BCC, reply-to, custom headers, or attachments — EmailMessage is the lower-level class that supports all of them, at the cost of being a bit more verbose to construct. send_mail is really a thin convenience wrapper around building and sending an EmailMessage; reaching for EmailMessage directly is the right move the moment an email needs anything beyond "plain text, some recipients."

HTML email: EmailMultiAlternatives

from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
 
text_body = render_to_string("emails/welcome.txt", {"user": user})
html_body = render_to_string("emails/welcome.html", {"user": user})
 
email = EmailMultiAlternatives(
    subject="Welcome",
    body=text_body,             # the PLAIN-TEXT fallback
    from_email="noreply@example.com",
    to=[user.email],
)
email.attach_alternative(html_body, "text/html")   # the HTML version, attached ON TOP
email.send()

A proper HTML email includes both an HTML version and a plain-text fallback — for email clients that don't render HTML, and for spam filters that penalize HTML-only mail. EmailMultiAlternatives is built specifically for this: the plain-text body is the base message, and .attach_alternative(html, "text/html") adds the HTML version as an alternative representation of the same email, rendered from a template exactly like a webpage would be (covered in the templates lesson).

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What does EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' do?

2. What does django.core.mail.outbox do during tests?

3. Why reach for EmailMessage instead of send_mail?

4. Why does a proper HTML email include both an HTML body AND a plain-text fallback via EmailMultiAlternatives?