skip to content

In Django, when is send_mail() enough, and when do you build an EmailMessage or EmailMultiAlternatives instead?

level: juniorimportance: must knowfreq 62%

answer

  1. frozen convenience wrapper
  2. everyone sees everyone in To
  3. bcc, reply_to, headers, attachments
  4. attach_alternative for the HTML part

basics

~20 s

send_mail() sends one message, optionally with an html_message alternative, to a recipient list that all appear in To. For CC, BCC, Reply-To, custom headers or attachments, build an EmailMessage, or an EmailMultiAlternatives to add body versions.

solid answer

~30 s

`send_mail(subject, message, from_email, recipient_list)` is a thin wrapper: it builds an `EmailMultiAlternatives` and calls `send()`. `from_email=None` falls back to `DEFAULT_FROM_EMAIL`, every address in `recipient_list` appears in the same `To:` header, and `html_message=` adds a `text/html` alternative. It returns the number of messages delivered, so `0` or `1`. Its signature is deliberately frozen, so anything beyond that needs an `EmailMessage` built directly: `cc`, `bcc`, `reply_to`, `headers`, `attachments` or `attach_file()`. `EmailMultiAlternatives` subclasses it and adds `attach_alternative(content, mimetype)`, which is how you send a plain-text body plus an HTML version. In Django 6.1 all of them take `using=` to pick a `MAILERS` alias.

code

python · 19 lines
python
from email.headerregistry import Address

from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string


def send_order_receipt(order):
    context = {"order": order}
    msg = EmailMultiAlternatives(
        subject=f"Receipt for order {order.number}",
        body=render_to_string("emails/receipt.txt", context),
        from_email=None,  # DEFAULT_FROM_EMAIL
        to=[Address(display_name=order.customer_name, addr_spec=order.email)],
        reply_to=["[email protected]"],
        bcc=["[email protected]"],
    )
    msg.attach_alternative(render_to_string("emails/receipt.html", context), "text/html")
    msg.attach_file(order.invoice_pdf.path)
    return msg.send()  # 1 on success

go deeper

for a junior

Recall the four required send_mail() arguments, that recipients share one To header, and that html_message adds an HTML part.

for a middle

Explain that send_mail() wraps EmailMultiAlternatives, list what only EmailMessage offers, and contrast attach_alternative with content_subtype.

for a senior

Show you build addresses with Address rather than string formatting and know which 6.0 and 6.1 argument changes will break old call sites.

for a principal

Argue for one shared message-building helper per email type so templates, headers and mailer choice stay consistent across the codebase.

## The one-call helper: `send_mail()` `django.core.mail.send_mail()` is the shortest path from a view or a task to an outgoing message. It takes four required arguments, `subject`, `message`, `from_email` and `recipient_list`, and a handful of keyword-only options: - **`from_email=None`** falls back to the `DEFAULT_FROM_EMAIL` setting (`"webmaster@localhost"` by default, which production projects override). - **`recipient_list`** is a list of addresses that all land in **one** message's `To:` header, so every recipient sees the others. - **`html_message=`** turns the message into `multipart/alternative`, with `message` as `text/plain` and the HTML as `text/html`. - **`using=`** (new in **Django 6.1**) names a `MAILERS` alias. Without it the `"default"` mailer sends. It returns the number of messages successfully delivered, which can only be `0` or `1`. Internally it builds an `EmailMultiAlternatives` and calls `send()` on it. Django's docs call its API **frozen**: new features go into the class, not into more keyword arguments. ## `EmailMessage`: the object you configure `EmailMessage` is the real model. The first four constructor parameters (`subject`, `body`, `from_email`, `to`) can be positional. Everything else is keyword-only, and passing it positionally has been deprecated since 6.0: - **`cc`**, **`bcc`** and **`reply_to`**: lists of addresses. BCC addresses go into the SMTP envelope through `recipients()` but never into a header. Since 6.1, putting `Bcc` in `headers=` raises `ValueError`. - **`headers`**: a dict of extra headers, for example `List-Unsubscribe`. - **`attachments`**: tuples of `(filename, content, mimetype)`, `EmailAttachment` named tuples, or (since 6.0) Python `MIMEPart` objects. You can also call `attach()` or `attach_file(path)` later. `send(using=None)` delivers it and returns `1` or `0`. An empty recipient list returns `0` without opening a connection. `message()` returns the Python `email.message.EmailMessage` that would go on the wire. Since Django 6.0 that is Python's **modern email API** object, not the old `SafeMIMEText`/`SafeMIMEMultipart` classes, which are deprecated. ## Text plus HTML: `EmailMultiAlternatives` A well-formed marketing or notification email usually carries a plain-text body and an HTML version of the same content. `EmailMultiAlternatives` subclasses `EmailMessage` and adds: 1. `attach_alternative(content, mimetype)`, which appends an `EmailAlternative(content, mimetype)` to `.alternatives`; 2. `body_contains(text)`, which checks the body and every `text/*` alternative. Do not confuse this with `content_subtype = "html"` on a plain `EmailMessage`. That changes the **main** body's MIME subtype to `text/html` and adds no plain-text fallback. Django's docs recommend leaving the main body as `text/plain`. ## Choosing between them | Need | `send_mail()` | `EmailMessage` | `EmailMultiAlternatives` | |---|---|---|---| | One message, shared `To:` | yes | yes | yes | | Plain text plus HTML | via `html_message=` | no (`content_subtype` replaces the body) | `attach_alternative()` | | CC, BCC, Reply-To | no | yes | yes | | Custom headers | no | `headers=` | `headers=` | | Attachments | no | `attach()`, `attach_file()` | same | | Pick a mailer (6.1) | `using=` | `send(using=)` | `send(using=)` | `send_mass_mail(datatuple)` is a separate helper. Each tuple becomes its **own** message and they share one connection, which is how you give recipients individual `To:` headers without a loop of `send_mail()` calls. ## Rendering bodies from templates Real emails are rarely string literals. The common pattern keeps two templates per email type and renders both with the same context: - `render_to_string("emails/welcome.txt", context)` for the plain-text `body`; - `render_to_string("emails/welcome.html", context)` for the `text/html` alternative; - one small function per email type that builds and returns the message, so views, tasks and management commands all send the same thing. A few details matter here: - Template autoescaping is on for both templates. That is right for the HTML version, but in the text template a user's name containing `&` would come out as `&amp;`. Wrap the text template in `{% autoescape off %}`. - Build absolute URLs (for example with `request.build_absolute_uri()`), because relative links mean nothing inside a mail client. - Keep subjects on one line. Django raises `ValueError` rather than sending a subject containing a newline. ## Safety details interviewers probe - **Header injection:** a CR or LF in any header raises `ValueError` when the message is built. Before 6.0 Django raised `BadHeaderError`, which is now deprecated. - **Display names:** never build `f'"{name}" <{email}>'` from user input, because the name can smuggle extra addresses. Use `email.headerregistry.Address(display_name=..., addr_spec=...)`. Django's built-in backends accept `Address` objects in any address field. - **Errors surface by default.** The `fail_silently` argument still exists but is deprecated in 6.1, and the migration guide recommends dropping it unless you have a specific reason. ## Version notes - **6.0:** the modern email API, keyword-only optional arguments, and `ValueError` replacing `BadHeaderError`. - **6.1:** `using=` on every sender and `MAILERS`. `connection=`, `get_connection()`, `fail_silently`, `auth_user` and `auth_password` are deprecated ahead of removal in 7.0.

  • How do you include a user's full name in the To: address without opening a header-injection hole?
    Build the address with Python's `email.headerregistry.Address(display_name=user.get_full_name(), addr_spec=user.email)` and pass it in the `to` list; Django's built-in backends accept `Address` objects. Never format `f'"{name}" <{email}>'`, because a crafted name can inject extra recipients. CR or LF characters in any header make Django raise `ValueError` when the message is sent.
  • How does send_mass_mail() differ from calling send_mail() in a loop?
    `send_mass_mail(datatuple)` turns each `(subject, message, from_email, recipient_list)` tuple into a separate message and sends them all over one backend connection, returning the count delivered. A loop of `send_mail()` calls opens and closes a connection per call. Either way, recipients inside one tuple's list still share a `To:` header.

saying these in an interview costs you the question

  • send_mail() sends a separate email to each address in recipient_list.
  • send_mail() accepts cc, bcc and attachment keyword arguments.
  • Setting content_subtype to html adds an HTML version next to the plain text.
  • Building the To header with an f-string from the user's name is safe.
  • Django still raises BadHeaderError for a newline injected into a header.