Troubleshooting
Email delivery

Fix Django Emails Not Sending in Production

Diagnose Django emails that never arrive: backend settings, fail_silently, blocked SMTP ports, provider rejections, Celery tasks, and domain authentication.

Problem statement

Password reset, signup confirmation, or notification emails work locally but never arrive in production. Sometimes there is an exception; often there is nothing at all, because the code path swallows errors or the message was accepted by a provider and then filtered by the recipient's mail system.

Email delivery has several independent stages, and "not sending" can mean a failure at any of them:

  1. Django never tries to send (wrong backend, code path not reached, task never runs).
  2. Django tries, but the connection to the SMTP server fails (blocked port, wrong host, TLS mismatch).
  3. The SMTP server rejects the message (authentication, unverified sender, sandbox restrictions).
  4. The provider accepts it, but the recipient's server rejects or spam-filters it (missing SPF, DKIM, DMARC, poor reputation).

Each stage leaves different evidence. This guide walks through them in order.

Stages of email deliveryDjangobackend configuredSMTP relayconnection, authProvideraccepted or rejectedRecipientinbox or spamEach stage leaves evidence in a different place: logs, provider dashboard, headers.

Quick answer

  1. Check EMAIL_BACKEND in production; the console, file, locmem, or dummy backends never deliver mail.
  2. Run python manage.py sendtestemail you@example.com with production settings and read any exception.
  3. Search code for fail_silently=True, which hides errors.
  4. Test network access to the SMTP host and port from the server (nc -vz host 587).
  5. Match port and TLS settings: 587 with EMAIL_USE_TLS, 465 with EMAIL_USE_SSL.
  6. Check the email provider's activity or suppression logs for rejections and bounces.
  7. If emails are sent from Celery, confirm the task ran.
  8. If messages are sent but land in spam or disappear, fix SPF, DKIM, and DMARC for the sender domain.

Step-by-step solution

1. Confirm the backend in production

python manage.py shell -c "
from django.conf import settings
print(settings.EMAIL_BACKEND)
print(settings.EMAIL_HOST, settings.EMAIL_PORT, settings.EMAIL_USE_TLS, settings.EMAIL_USE_SSL)
print(settings.DEFAULT_FROM_EMAIL, settings.SERVER_EMAIL)
"
which backend is really active?Message goes toconsole!stdout: nobody receives itlocmemmemory, for testsdummy✕nowheresmtp✓your SMTP serverprovider API✓provider over HTTPS (e.g. anymail)A development backend left in production sends nothing, silentlydjango-deployment.com
Print settings.EMAIL_BACKEND in a production shell to be sure.

Run it with the same environment as the web process. If the backend is django.core.mail.backends.console.EmailBackend, messages are printed to the process's stdout (visible in Gunicorn logs) and never delivered. A settings module that sets the console backend for development and is accidentally used in production is a common cause.

2. Send a test email

python manage.py sendtestemail you@example.com

Possible outcomes:

  • Exception: you have a stage 2 or 3 problem; the exception type guides you (steps 4 and 5).
  • No exception, email arrives: sending works; the problem is in the code path that should send (step 3) or in background task execution (step 6).
  • No exception, email never arrives: stage 3 or 4; check the provider logs (step 7) and domain authentication (step 8).

3. Look for swallowed errors

grep -rn "fail_silently=True" --include="*.py" .
errors you never seefail_silently=True✕SMTP errors discarded✕send_mail returns 0✕users report missing mailErrors surfaced✓fail_silently=False✓exception logged with context✓task retries on failureCheck the return value: 0 means nothing was sentdjango-deployment.com
Search the code for fail_silently=True; third-party packages set it too.

send_mail(..., fail_silently=True) suppresses SMTPException and connection errors. Some third-party apps and older code use it widely. For debugging, temporarily set it to False, or wrap sends with logging.

Django's PasswordResetForm does not raise if the email address has no active user with a usable password, by design, to avoid revealing which addresses have accounts. Test password resets with an existing, active account.

4. Check network access to the SMTP server

nc -vz smtp.provider.example 587
nc -vz smtp.provider.example 465
  • succeeded: the port is reachable.
  • Timeout: outbound traffic is blocked. Many cloud providers block outbound port 25 by default, and some restrict other ports on new accounts. Check security groups, host firewalls (ufw status), and the provider's policies. Use port 587 or an alternative port your email provider offers, or send through the provider's HTTP API.
  • Connection refused: wrong host or port.

In Docker, test from inside the container: docker compose exec web python -c "import socket; socket.create_connection(('smtp.provider.example', 587), 5); print('ok')".

5. Match TLS mode and credentials

ExceptionCause
SMTPServerDisconnected: Connection unexpectedly closedoften TLS mode mismatch, or server closed an idle connection
ssl.SSLError: [SSL: WRONG_VERSION_NUMBER]EMAIL_USE_SSL on a STARTTLS port (587), or vice versa
SMTPNotSupportedError: STARTTLS extension not supportedEMAIL_USE_TLS against a port that expects implicit TLS or plain SMTP
SMTPAuthenticationError: (535, ...)wrong username or password, or provider requires an API key as password
SMTPSenderRefused: (553, ...)sender address not allowed or domain not verified
TimeoutErrornetwork block (step 4) or EMAIL_TIMEOUT too short
port and TLS mode must agreeEMAIL_HOST = "smtp.provider.example"EMAIL_PORT =587EMAIL_USE_TLS= True   # STARTTLSEMAIL_USE_SSL= False  # True only for port 465EMAIL_TIMEOUT =1012341Submission port; 25 is often blocked on cloud servers.2STARTTLS on 587.3Implicit TLS on 465; never both True.4Without it, a hung server hangs the worker.A mismatched port and TLS mode usually times out or resetsdjango-deployment.com
Use the provider’s SMTP credentials, not your account login.

Settings that match common provider configurations:

EMAIL_HOST = "smtp.provider.example"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_USE_SSL = False
EMAIL_TIMEOUT = 10

Store EMAIL_HOST_USER and EMAIL_HOST_PASSWORD in environment variables and confirm the production values are the provider's SMTP credentials, which are usually different from your dashboard login.

6. Emails sent from background tasks

If views queue emails with Celery, a working SMTP configuration is not enough; the task must run:

celery -A config inspect ping
sudo journalctl -u celery-worker --since "1 hour ago" | grep -i -E "send_.*email|Traceback"

Check that workers have the same email environment variables as the web process. A worker started without EMAIL_HOST_PASSWORD raises authentication errors that only appear in worker logs, not in web logs.

Also check that the task is queued after commit (transaction.on_commit) when it reads data created in the same request.

7. Check the provider side

Email providers show what happened to each message: accepted, delivered, bounced, blocked, or suppressed. Look for:

  • Sandbox mode: new accounts on some providers (for example Amazon SES) can only send to verified addresses until production access is granted.
  • Suppression lists: addresses that bounced or complained before are suppressed automatically; further sends are dropped.
  • Unverified domain or sender: messages are rejected at submission.
  • Sending limits: daily or per-second quotas exceeded.
accepted is not deliveredDjango send_mailreturns 1 when the relay acceptsProvider or SMTP relayactivity log, bouncesRecipient mail serverSPF, DKIM, DMARC checksSpam filteringreputation, contentInboxthe only real successAfter the relay accepts, only the provider logs show what happeneddjango-deployment.com
Search the provider activity log by recipient and time.

If the provider shows the message as delivered, the recipient's mail system accepted it. Check the recipient's spam folder and any quarantine in corporate mail systems.

8. Fix domain authentication

Receivers increasingly reject or spam-filter mail that fails authentication. For the domain in DEFAULT_FROM_EMAIL:

dig +short TXT example.com | grep spf
dig +short TXT _dmarc.example.com
  • SPF must include your provider's sending servers.
  • DKIM records from the provider must be published and verified in the provider dashboard.
  • DMARC should exist; start with p=none and move to stricter policies once aligned.

Send a test to a mailbox where you can view the original message and check the Authentication-Results header for spf=pass, dkim=pass, and dmarc=pass.

9. Log every send attempt

Add structured logging around sending so future failures are visible:

import logging

from django.core.mail import send_mail

logger = logging.getLogger("app.email")


def send_notification(subject, message, recipient):
    try:
        count = send_mail(subject, message, None, [recipient], fail_silently=False)
    except Exception:
        logger.exception("email failed subject=%r", subject)
        raise
    logger.info("email handed to backend subject=%r count=%d", subject, count)

send_mail returns the number of messages successfully handed to the backend. A return value of 1 means the SMTP server accepted it, not that it reached the inbox.

10. Verify

Verification check:

  • sendtestemail delivers to an external inbox, not spam
  • a password reset for an active account arrives within a minute
  • the provider's activity log shows the message as delivered
  • the message headers show SPF, DKIM, and DMARC passing
  • worker logs (if used) show the email task succeeding

Explanation

Django's email layer ends at handing the message to a backend. Everything after that, including network paths, provider policies, and recipient filtering, happens outside Django and leaves evidence in different places: exceptions in Django, connection tests on the server, provider dashboards, and message headers. Working through those stages in order turns "emails don't send" into a specific, fixable failure.

Edge cases / notes

Error emails to admins never arrive

mail_admins uses SERVER_EMAIL as the sender, which defaults to root@localhost and is rejected by providers. Set it to a verified address, and make sure ADMINS is populated.

Emails work for some recipients only

If Gmail users receive messages but corporate recipients do not, the corporate system may quarantine messages from new senders or domains without DMARC. Ask the recipient's IT for quarantine logs.

Staging sends to real users

If staging uses real SMTP credentials, emails may be reaching users unexpectedly while production does not. Use separate credentials and a test inbox for staging.

Testing email in automated tests

Django's test runner replaces the email backend with the in-memory locmem backend and collects messages in django.core.mail.outbox. Use it to check that the right emails are generated, independent of delivery:

from django.core import mail
from django.test import TestCase
from django.contrib.auth import get_user_model


class PasswordResetEmailTests(TestCase):
    def test_reset_email_is_sent_for_active_user(self):
        get_user_model().objects.create_user("alice", "alice@example.com", "s3cret-pass")
        self.client.post("/accounts/password_reset/", {"email": "alice@example.com"})
        self.assertEqual(len(mail.outbox), 1)
        self.assertIn("alice@example.com", mail.outbox[0].to)

Adjust the URL to your project's password reset route. These tests catch code-path problems (wrong template, missing context, a signal that no longer fires) that are otherwise mistaken for delivery problems.

A mail catcher for staging

Instead of real delivery in staging, point SMTP settings at a mail-catching service such as Mailpit running beside the application:

services:
  mailpit:
    image: axllent/mailpit
    ports:
      - "127.0.0.1:8025:8025"
EMAIL_HOST=mailpit
EMAIL_PORT=1025
EMAIL_USE_TLS=false

All messages appear in Mailpit's web interface on port 8025 (bound to localhost here; reach it through an SSH tunnel), and nothing reaches real recipients.

What to monitor in production

  • the rate of send exceptions in application or worker logs
  • bounce and complaint rates reported by the provider
  • the age of the oldest queued email task, if you send through Celery
  • DMARC aggregate reports for authentication failures from unexpected sources

Common mistakes

  • Leaving the console backend active through a development settings import.
  • Using fail_silently=True in production code paths.
  • Sending from a Gmail or other free mailbox address through a transactional provider.
  • Assuming a return value of 1 from send_mail means the email was delivered.

Next steps

FAQ

Why do Django emails work locally but not in production?

Common causes are a development email backend in production settings, blocked outbound SMTP ports on the server, missing credentials in the production environment, or an unverified sender domain.

How do I test Django email settings on the server?

Run python manage.py sendtestemail you@example.com with production environment variables loaded. Any connection or authentication error is raised in the terminal.

Why doesn't Django send a password reset email?

PasswordResetForm silently skips addresses without an active user with a usable password. Also check that sending works in general and that the email is not being filtered as spam.

How do I stop Django emails from going to spam?

Send through a reputable provider, use a sender address on a domain you control, and publish SPF, DKIM, and DMARC records so receiving servers can authenticate the messages.

Keep going