The email API, and the backend underneath it
coreintermediateDjango ships one email API with two entry points. `send_mail(subject, message, from_email, recipient_list)` is the one-liner, and it returns the number of messages delivered — `1` or `0`, because it can only send one. `EmailMessage` is the object you build when you need more than that: cc, bcc, `reply_to`, extra headers, attachments. `EmailMultiAlternatives` adds `attach_alternative(html, "text/html")`, which is how you send an HTML email that still has a plain-text body. Underneath both sits a **backend**, chosen by the `EMAIL_BACKEND` setting, and swapping it is how the same code writes to your console in development and to an SMTP server in production.
Think of it as
Think of it as three layers you can substitute independently. At the top is a message — a subject, a body, some recipients, maybe an HTML alternative and some attachments — and that object knows nothing about how it travels. In the middle is a connection, an open channel to whatever actually accepts mail. At the bottom is the backend, the code that implements that channel: SMTP in production, console or file-based while you are building, `locmem` in tests where every message lands in `django.core.mail.outbox` instead of a network. Because the message does not know which backend it will meet, none of your application code changes between those environments — one setting does. The layer that catches people out is the connection. `send_messages()` will open a connection if one is not open and close it afterwards, so a loop that calls `send_mail()` five hundred times performs five hundred SMTP handshakes. Open one connection with `get_connection()`, pass it to every message, and the documented behaviour changes: a connection you opened manually is left open, so the handshake happens once. The other thing worth internalising early is that "HTML email" is not a separate API. It is a plain-text message with an HTML *alternative* attached, and the two-part shape is deliberate — a client that will not or cannot render HTML still has something to show, and a mail body with no text part is a well-known spam signal.
What we're doing: Send a receipt as a two-part email — plain text and HTML rendered from templates — with the PDF attached, over a single connection.
- 9–10
- `body` stays plain text. The HTML is an *alternative* to it, not a replacement — a client that will not render HTML still has something to show.
- 16
- `attach_alternative(content, mimetype)` is the whole of "HTML email". There is no separate HTML message class.
- 19
- `attach(filename, content, mimetype)` takes bytes already in memory. `attach_file(path)` is the variant that reads from disk. Omit the mimetype and Django guesses it from the filename.
- 24
- Opening the connection yourself is the difference between one SMTP handshake and one per message — `send_messages()` only opens and closes implicitly when it finds the connection closed.
- 30
- The return value is a count, not a boolean. Comparing it against `len(messages)` is how you notice a partial failure at all.
Why this works: One handshake carries the whole batch, every recipient gets a body their client can render, and the count that comes back is the only evidence you have that anything was accepted.
Sending HTML as the body instead of as an alternative
Wrong
Better
What you see: Some recipients see raw `<table>` markup instead of a message, and delivery rates to strict providers drop without any error being raised.
Why: A mail body is plain text unless the message declares an HTML alternative, so passing markup as the body sends the markup itself as the text a client displays. `send_mail` already has `html_message=` for this, and `EmailMultiAlternatives` is the general form. Keeping a real text part is not only about old clients: a multipart message whose text half is missing is a routine spam heuristic, so the two-part shape protects deliverability as well as readability.
- Your code — builds a message: subject, text body, HTML alternative, attachments
- EmailMessage / EmailMultiAlternatives — knows nothing about how it will travel — that is the point
- Connection — get_connection() — open it yourself and one handshake serves the whole batch
- Backend — EMAIL_BACKEND — smtp in production · console/filebased in dev · locmem in tests · dummy for never
- An SMTP server, stdout, a file, or a list — the only layer that differs between your laptop and production
The backends, and what each one is actually for
Together
Which class to reach for
Together
Remember: `send_mail` returns a count (1 or 0), not a boolean; `EmailMessage` is the object form; `EmailMultiAlternatives.attach_alternative(html, "text/html")` is all "HTML email" means — the plain-text body stays. `EMAIL_BACKEND` swaps SMTP for console, filebased, locmem or dummy without touching a single call site, so tests assert on `mail.outbox`. Open a connection with `get_connection()` before a batch, or you pay one SMTP handshake per message. Treat `fail_silently=True` as data loss you agreed to.
See also: delivery belongs in the background · bounces and delivery status · json logs and context fields

