Email Engineering Guide

Django Email Configuration for Production Applications

Django application sending email through Celery and an SMTP relay to the recipient inbox

Your Django app sends email perfectly in development. You deploy it, and the emails stop arriving. Password reset links never reach users, signup confirmations vanish, and send_mail() keeps returning 1 as though everything worked.

Django is doing exactly what you configured it to do. The problem is that the settings which work on a laptop are not the settings that work on a server, and Django’s defaults are deliberately designed for development rather than production.

This guide covers the full production setup: the settings.py values that matter, connection handling, asynchronous sending with Celery, the password reset flow, and the specific configuration mistakes that let Django email fail without raising anything.

Quick Answer: How Do You Configure Email in Django?

Set the SMTP backend and connection details in settings.py, reading credentials from environment variables:

import os

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp.photonrelay.com'
EMAIL_PORT = 587
EMAIL_HOST_USER = os.environ['EMAIL_HOST_USER']
EMAIL_HOST_PASSWORD = os.environ['EMAIL_HOST_PASSWORD']
EMAIL_USE_TLS = True
EMAIL_TIMEOUT = 10
DEFAULT_FROM_EMAIL = 'Your App <noreply@yourdomain.com>'
SERVER_EMAIL = 'errors@yourdomain.com'
from django.core.mail import send_mail

send_mail(
    subject='Your verification code',
    message='Your code is 492019.',
    from_email=None,          # falls back to DEFAULT_FROM_EMAIL
    recipient_list=['user@example.com'],
    fail_silently=False,
)

The application code is rarely the problem. What decides whether a message reaches an inbox is the backend you selected, the host you authenticate against, and whether your domain is authorised to send. An authenticated relay such as PhotonConsole on port 587 removes most of the failure modes covered below.

How Django’s Email Layer Works

Django routes every outgoing message through a configurable backend. The backend decides what actually happens to the message: sent over SMTP, printed to the console, written to a file, or held in memory for tests.

The official Django email documentation lists five built-in backends. Only one of them sends anything.

BackendWhat it doesUse in
smtp.EmailBackendSends over SMTPProduction
console.EmailBackendPrints the message to stdoutLocal development
filebased.EmailBackendWrites each message to a fileLocal inspection
locmem.EmailBackendStores messages in memoryAutomated tests
dummy.EmailBackendDiscards everything silentlyDisabling mail entirely

Django does not deliver email itself. It hands a correctly formatted message to whichever backend is configured, and the SMTP backend hands it to whatever host you named. If you want the underlying protocol explained first, see our guide to what SMTP is.

Why Django Email Fails in Production

	Diagram of the four Django settings that cause email to fail silently: console backend, mismatched TLS and SSL, default from address and missing DNS records
Four settings that let Django report a successful send while nothing is delivered.

Most email failures in Django come from configuration or authentication rather than defects in the framework. These five account for the majority.

1. The Console Backend Is Still Active

Most tutorials and starter templates set EMAIL_BACKEND to the console backend so development does not send real mail. If that setting reaches production, every message is printed to your application logs and nothing is sent. send_mail() still returns 1, because from Django’s point of view the send succeeded.

This is the most common cause of “Django says it sent the email but nothing arrived”.

2. EMAIL_USE_TLS and EMAIL_USE_SSL Are Both Set

These two settings are mutually exclusive, and Django raises an error if both are True. More commonly, one is set to match the wrong port. Use TLS with port 587, or SSL with port 465, and never mix the pairings.

3. DEFAULT_FROM_EMAIL Was Never Changed

Django’s default from address is webmaster@localhost. That address does not exist on your domain, so it fails DMARC alignment and gives bounces nowhere to go. Mailbox providers treat mail from a non-existent sender with suspicion, in line with Google’s sender guidelines.

4. No Authentication Records on Your Domain

Without SPF and DKIM, receiving servers cannot verify your application is allowed to send as your domain. Our guide to SPF, DKIM and DMARC covers the records, and the free email deliverability checker shows what your domain currently publishes.

5. Your Host Blocks Outbound SMTP

AWS, Google Cloud, DigitalOcean and Azure restrict outbound port 25 by default. AWS documents its port 25 throttle removal process, though removal does not solve the reputation problem underneath. Our breakdown of SMTP connection timeouts covers the diagnosis.

Quick Fix

Django Reports Success but No Email Arrives

  • Print settings.EMAIL_BACKEND in production and confirm it ends in smtp.EmailBackend
  • Confirm only one of EMAIL_USE_TLS or EMAIL_USE_SSL is True
  • Check DEFAULT_FROM_EMAIL is a real address on your own domain
  • Set fail_silently=False so exceptions surface instead of being swallowed
  • Run a test send from manage.py shell to see the actual SMTP error

Step-by-Step: Production Email Setup

Step 1: Keep Credentials Out of settings.py

Read every credential from the environment. Never commit an SMTP password to version control.

# settings.py
import os

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.photonrelay.com')
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
EMAIL_HOST_USER = os.environ['EMAIL_HOST_USER']
EMAIL_HOST_PASSWORD = os.environ['EMAIL_HOST_PASSWORD']
EMAIL_USE_TLS = True
EMAIL_USE_SSL = False
EMAIL_TIMEOUT = 10

DEFAULT_FROM_EMAIL = 'Your App <noreply@yourdomain.com>'
SERVER_EMAIL = 'errors@yourdomain.com'

DEFAULT_FROM_EMAIL is used for ordinary application mail. SERVER_EMAIL is used for error reports sent to ADMINS. Setting only the first leaves your error emails coming from root@localhost.

Common Mistake

Setting EMAIL_PORT = 465 while leaving EMAIL_USE_TLS = True. Port 465 expects the connection to be encrypted from the first byte, which is EMAIL_USE_SSL. The mismatch produces a connection that hangs until EMAIL_TIMEOUT expires, with no error explaining why. Use 587 with TLS, or 465 with SSL, and never set both flags.

Step 2: Always Set EMAIL_TIMEOUT

Without a timeout, a slow or unresponsive mail server can block a worker process indefinitely. Ten seconds is a reasonable default for a web request.

Step 3: Verify the Connection Before Trusting It

Test from the shell on the production server, not from your laptop.

python manage.py shell
from django.core.mail import send_mail
from django.conf import settings

print(settings.EMAIL_BACKEND)   # confirm this is the SMTP backend
print(settings.EMAIL_HOST, settings.EMAIL_PORT)

send_mail(
    'SMTP connection test',
    'If you received this, the connection works.',
    None,
    ['you@yourdomain.com'],
    fail_silently=False,
)

If this raises an exception, the problem is configuration or connectivity. If it returns 1 and nothing arrives, the problem is delivery — authentication records or filtering rather than Django.

Step 4: Reuse One Connection for Multiple Messages

Calling send_mail() in a loop opens a new SMTP connection for every message. Open one connection and send through it instead.

from django.core.mail import get_connection, EmailMultiAlternatives

def send_batch(messages):
    connection = get_connection()
    connection.open()

    emails = []
    for msg in messages:
        email = EmailMultiAlternatives(
            subject=msg['subject'],
            body=msg['text'],
            to=[msg['to']],
            connection=connection,
        )
        email.attach_alternative(msg['html'], 'text/html')
        emails.append(email)

    connection.send_messages(emails)
    connection.close()

Step 5: Send HTML With a Plain Text Alternative

HTML-only messages score worse with spam filters. EmailMultiAlternatives lets you send both from a rendered template.

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):
    html = render_to_string('emails/welcome.html', {'user': user})
    text = strip_tags(html)

    email = EmailMultiAlternatives(
        subject='Welcome to Your App',
        body=text,
        to=[user.email],
    )
    email.attach_alternative(html, 'text/html')
    email.send(fail_silently=False)

Step 6: Move Sending Off the Request Cycle

	Django Celery email flow showing the request returning immediately while a worker delivers the message through an SMTP relay
Celery returns the response immediately while a worker handles delivery separately.

Never make a user wait for an SMTP handshake during a web request. Push the send into Celery so the response returns immediately and delivery is retried independently.

# 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_email_task(self, subject, text, html, to):
    try:
        email = EmailMultiAlternatives(subject, text, to=[to])
        email.attach_alternative(html, 'text/html')
        email.send(fail_silently=False)
    except Exception as exc:
        raise self.retry(exc=exc)
# views.py
send_email_task.delay(subject, text, html, user.email)

The Celery documentation covers worker configuration in depth. Our guide to transactional email queue architecture covers the wider pattern, and SMTP retry logic explains which failures are worth retrying.

Quick Fix

Celery Tasks Queue but Never Send

  • Confirm a worker is actually running with celery -A yourproject worker -l info
  • Check the broker connection — a task queued to an unreachable broker fails silently
  • Confirm the worker process has the same environment variables as the web process
  • Restart workers after every deploy, as they hold old code and settings in memory
  • Inspect failed tasks rather than assuming the SMTP connection is at fault

Fixing the Password Reset Flow

Django’s built-in password reset uses the same email configuration, so everything above applies. Three things specific to this flow cause failures.

The default template is plain and unbranded. Override registration/password_reset_email.html to control what users receive. An unbranded reset email is more likely to be ignored or reported as suspicious.

The reset link uses the site domain. Django builds the URL from the Sites framework or the request. If ALLOWED_HOSTS or the Site record is wrong, users receive links pointing at example.com.

Reset emails are time critical. Tokens expire, so a message delayed by an hour in a queue is useless by the time it arrives. Password resets should go through a priority path rather than sitting behind a bulk sending job.

Port and Encryption Reference

EMAIL_PORTSettingWhen to use
587EMAIL_USE_TLS = TrueRecommended default
465EMAIL_USE_SSL = TrueWhen the provider requires implicit SSL
2525EMAIL_USE_TLS = TrueWhen the host blocks 587 and 465
25NeitherAvoid — blocked by most hosting providers

Configuring Django With PhotonConsole

Moving Django onto a dedicated relay is a settings change plus DNS. No application code changes, and your templates and tasks stay exactly as they are.

DNS Records

TXT    @                    v=spf1 include:relay.photonconsole.com ~all
CNAME  photon._domainkey    dkim.photonconsole.com

Note

DNS changes are not instant. SPF and DKIM records can take from a few minutes to 24-48 hours to propagate depending on your provider and TTL settings. Check you do not already publish a second SPF record — two separate TXT records starting with v=spf1 break authentication entirely.

Settings

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp.photonrelay.com'
EMAIL_PORT = 587          # 465 for implicit SSL, 2525 if 587 is blocked
EMAIL_HOST_USER = os.environ['PHOTON_USER']
EMAIL_HOST_PASSWORD = os.environ['PHOTON_PASS']
EMAIL_USE_TLS = True
EMAIL_TIMEOUT = 10
DEFAULT_FROM_EMAIL = 'Your App <noreply@yourdomain.com>'

Port 2525 exists specifically for hosting environments that block the standard SMTP ports, which resolves the AWS and DigitalOcean problem described earlier. Every PhotonConsole account includes 5,000 free emails per month, enough to validate the full setup — DNS records, Celery tasks and retry handling — before any spend. Details are on the PhotonRelay page, and pricing has no monthly minimum.

If you are still comparing options, our analysis of free SMTP servers covers where each one breaks down under production load.

Environment-Specific Notes

Local Development

Use the console backend or a mail catcher such as Mailpit. Keep the SMTP backend configured in a separate settings module so the production code path is exercised before deploy day rather than discovered on it.

Docker and Kubernetes

Containers have no local mail transfer agent, so an external relay is mandatory. Inject credentials through secrets rather than baking them into the image, and confirm the Celery worker container receives the same environment variables as the web container.

Heroku and Platform Hosting

Config vars must be set for every process type. A web dyno with correct mail settings and a worker dyno without them produces email that works synchronously and fails through Celery.

Serverless Deployments

Persistent SMTP connections are impractical when the container is destroyed after each invocation. Keep function timeouts above EMAIL_TIMEOUT so a send is never cut off mid-handshake.

Pro Tips for Reliable Django Email

  • Never use fail_silently=True in production. It suppresses the exception that tells you what went wrong, turning a fixable error into a mystery.
  • Configure ADMINS and SERVER_EMAIL. Django emails unhandled exceptions to ADMINS, which is useless if the mail configuration is what broke.
  • Use a subdomain for application mail. Sending from mail.yourdomain.com isolates transactional reputation from your business email.
  • Verify DNS after any change. MXToolbox confirms SPF, DKIM and blocklist status in a single lookup.
  • Score a real send before launch. Mail Tester flags authentication problems before users encounter them.
  • Separate transactional and bulk queues. A newsletter batch that gets throttled should never delay a password reset.
  • Never log the full settings object. Log the exception and SMTP response, not the values containing your password.

Related Issues You May Hit Next

Frequently Asked Questions

Why does send_mail return 1 when no email arrives?

The return value counts messages the backend accepted, not messages delivered. With the console backend it counts messages printed to stdout. Check EMAIL_BACKEND first.

Should I use EMAIL_USE_TLS or EMAIL_USE_SSL?

Use EMAIL_USE_TLS = True with port 587 for most applications. Use EMAIL_USE_SSL = True with port 465 only when the provider requires implicit SSL. Setting both raises an error.

How do I send email asynchronously in Django?

Use Celery with a broker such as Redis or RabbitMQ. Django has no built-in background task queue, so a synchronous send() call blocks the request until the SMTP conversation finishes.

Can I use Gmail SMTP with Django?

For local testing, yes. For production, no. Google enforces low sending caps, throttles automated traffic, and can lock the account, which takes your password reset emails down with it.

How do I test email without sending real messages?

Use locmem.EmailBackend in tests and inspect django.core.mail.outbox. For manual checks, use the file-based backend or a mail catcher rather than sending to real addresses.

Why do my Django emails go to spam?

Delivery and inbox placement are separate. A successful send only means the relay accepted the message. Missing SPF or DKIM records, or a from address that does not exist, are the usual causes.

Do I still need SPF and DKIM when using a relay?

Yes. The relay delivers the message, but authentication records prove your domain authorised it. Without them, delivery is far less reliable regardless of which relay you use.

Conclusion

Django’s email layer is dependable. Nearly every production failure traces to one of four things: the console backend left active, mismatched TLS and SSL settings, a default from address that does not exist, or a domain with no authentication records.

Fix those four and Django email works predictably. Add a timeout, reuse connections for batches, and move sending into Celery, and it stays reliable as traffic grows. What remains is infrastructure rather than framework: whether your host permits outbound SMTP, and whether receiving providers trust the domain you send from.

If messages are blocked by port restrictions, throttled by a mailbox provider, or filtered because your server has no sending reputation, no change to settings.py will fix it. A dedicated transactional email solution handles authentication, routing and reputation, using the same configuration shown above. Developers working across stacks may also want our guides to sending email in Python with smtplib and sending email in Node.js.

Read More

phoconadmin

About Author

Leave a comment

Your email address will not be published. Required fields are marked *

You may also like

Email Engineering Guide

Email IP Warming Explained: The Complete Engineering Guide for Transactional Email Infrastructure

IP warming isn't about gradually sending more emails—it's about building measurable trust with mailbox providers. This engineering guide explains how
Transactional Email API architecture showing API requests, webhook events, email delivery pipeline, and provider evaluation for modern SaaS infrastructure.
Email Engineering Guide

Transactional Email API: The Complete Engineering Guide to Choosing the Right Email API in 2026

Choosing a transactional email API isn't just about sending emails. It's about reliability, observability, scalability, security, and long-term infrastructure decisions.