Django ships with a working email layer — django.core.mail — so sending email from a Django app is a matter of pointing settings.py at an SMTP server and calling send_mail(). This guide configures Postwing as that server and then covers the sends you actually need in production: HTML email, Django templates, attachments, bulk sending and moving the whole thing into a Celery worker.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
Add the following to settings.py. Credentials come from the environment rather than the file itself, so they never reach your repository:
# settings.py
import os
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.postwing.app"
EMAIL_PORT = 587
EMAIL_USE_TLS = True # STARTTLS on port 587
EMAIL_USE_SSL = False # mutually exclusive with EMAIL_USE_TLS
EMAIL_HOST_USER = os.environ["EMAIL_HOST_USER"]
EMAIL_HOST_PASSWORD = os.environ["EMAIL_HOST_PASSWORD"]
EMAIL_TIMEOUT = 10
DEFAULT_FROM_EMAIL = "Acme <noreply@your-domain.com>"
SERVER_EMAIL = DEFAULT_FROM_EMAIL # used for Django's error mailsAlongside it, in your .env or your process manager:
EMAIL_HOST_USER=token-login@your-domain.com
EMAIL_HOST_PASSWORD=your-token-password To use implicit SSL/TLS on port 465 instead of STARTTLS, swap the three port-related settings. EMAIL_USE_TLS and EMAIL_USE_SSL are mutually exclusive — setting both to True makes Django raise an error at startup:
EMAIL_PORT = 465
EMAIL_USE_TLS = False
EMAIL_USE_SSL = TrueFrom: address has to be on a domain you have verified, so that DKIM signing and SPF alignment apply to it. Sending as @gmail.com or @yandex.ru through a third-party relay fails DMARC and goes straight to spam. send_mail() is the shortest path — subject, body, sender, recipients:
from django.core.mail import send_mail
send_mail(
subject="Your order #4417 is confirmed",
message="Thanks! Your order ships tomorrow.",
from_email=None, # falls back to DEFAULT_FROM_EMAIL
recipient_list=["customer@example.com"],
fail_silently=False,
) Keep fail_silently=False. With True, Django swallows every SMTP error and a broken configuration looks exactly like a working one. To check the setup end to end without writing a view:
python manage.py shell -c "from django.core.mail import send_mail; \
send_mail('Test', 'It works.', None, ['you@example.com'], fail_silently=False)"send_mail() only sends plain text. For HTML use EmailMultiAlternatives, which produces a multipart/alternative message with both parts. Always include the plain-text alternative — a message with no text part is a strong spam signal, and some clients will not render anything else:
from django.core.mail import EmailMultiAlternatives
text = "Thanks! Your order ships tomorrow."
html = "<h1>Order confirmed</h1><p>Your order ships tomorrow.</p>"
msg = EmailMultiAlternatives(
subject="Your order #4417 is confirmed",
body=text, # plain-text part, always include one
to=["customer@example.com"],
)
msg.attach_alternative(html, "text/html")
msg.send()Hardcoded HTML in a view gets unmaintainable fast. Render a normal Django template instead and derive the text part from it:
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
html = render_to_string("email/order_confirmed.html", {"order": order})
msg = EmailMultiAlternatives(
subject=f"Your order #{order.id} is confirmed",
body=strip_tags(html),
to=[order.customer.email],
)
msg.attach_alternative(html, "text/html")
msg.send() Passing several addresses in recipient_list puts them all in one To: header, where every recipient sees the others. For anything user-facing, send one message per recipient and reuse a single SMTP connection so you are not reconnecting in a loop:
from django.core.mail import EmailMultiAlternatives, get_connection
# One SMTP connection, one message per recipient.
connection = get_connection()
connection.open()
for user in users:
EmailMultiAlternatives(
subject="Your weekly report",
body=render_report(user),
to=[user.email], # exactly one recipient per message
connection=connection,
).send()
connection.close()EmailMessage attaches files either from disk or from bytes you built in memory:
from django.core.mail import EmailMessage
msg = EmailMessage(
subject="Your invoice",
body="The invoice for March is attached.",
to=["customer@example.com"],
)
# From a file on disk
msg.attach_file("/srv/invoices/2026-03.pdf")
# Or from bytes you generated in memory
msg.attach("invoice.pdf", pdf_bytes, "application/pdf")
msg.send()Django's SMTP backend is blocking: the request waits for the connection, the handshake and the server's reply. Move the send into a Celery task so the user gets their response immediately and a transient SMTP failure becomes a retry instead of a 500:
# tasks.py
from celery import shared_task
from django.core.mail import EmailMultiAlternatives
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_order_confirmation(self, order_id: int) -> None:
order = Order.objects.get(pk=order_id)
msg = EmailMultiAlternatives(
subject=f"Your order #{order.id} is confirmed",
body=render_confirmation(order),
to=[order.customer.email],
)
try:
msg.send()
except Exception as exc: # SMTP hiccup — retry with backoff
raise self.retry(exc=exc)# views.py — returns immediately, the email goes out in a worker
send_order_confirmation.delay(order.id)In development, swap the backend so messages are printed instead of delivered:
# settings/dev.py — print emails to the console instead of sending
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" In tests you do not have to change anything — Django substitutes the in-memory backend and collects messages in django.core.mail.outbox:
from django.core import mail
from django.test import TestCase
class OrderEmailTests(TestCase):
def test_confirmation_is_sent(self):
place_order(self.customer)
self.assertEqual(len(mail.outbox), 1)
self.assertIn("confirmed", mail.outbox[0].subject)| Error | Cause and fix |
|---|---|
SMTPAuthenticationError | Wrong token login or password. Both come from the token page — the password is displayed once, at creation. |
SMTPServerDisconnected / connection timeout | Your host blocks outbound mail ports. Change EMAIL_PORT to 8587 (STARTTLS) or 8465 (SSL/TLS). |
SMTPNotSupportedError: STARTTLS extension not supported | You set EMAIL_USE_TLS = True on port 465. That port is implicit TLS — use EMAIL_USE_SSL = True instead. |
SMTPSenderRefused (550) | The From: domain is not verified, or the address is on a domain your token does not cover. |
| Mail is accepted but never arrives | Check the delivery log in the dashboard for a bounce, and confirm your DNS records are still published. |
SMTPRecipientsRefused | The recipient address is malformed or on your suppression list after an earlier hard bounce. |
Use 587 with EMAIL_USE_TLS = True — that is STARTTLS, the mode Django's SMTP backend is most commonly configured for. Port 465 also works; set EMAIL_USE_SSL = True and EMAIL_USE_TLS = False instead. Never set both flags to True, Django raises an error if you do.
The username or password is wrong. EMAIL_HOST_USER must be the full login of an SMTP token for your domain, and EMAIL_HOST_PASSWORD the password shown once when that token was created. If you lost the password, create a new token rather than guessing — it is not stored in recoverable form.
Most cloud providers block outbound connections on ports 25, 465 and 587. Switch EMAIL_PORT to the matching high port — 8587 for STARTTLS or 8465 for SSL/TLS — and the connection will go through.
No. Django's send_mail is blocking, so a slow SMTP handshake becomes a slow page. Hand the send to Celery, Django-Q or django-mailer and return the response immediately. Sending in a background task also gives you retries when the SMTP server is briefly unavailable.
Send from a domain you have verified, so DKIM and SPF are published and DMARC aligns. Set DEFAULT_FROM_EMAIL to an address on that domain — not to a Gmail or Yandex address — and always include a plain-text part alongside the HTML.
Yes. Set EMAIL_BACKEND to django.core.mail.backends.console.EmailBackend in development to print messages to stdout. In tests Django swaps in the locmem backend automatically and collects everything in django.core.mail.outbox.