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:
- Django never tries to send (wrong backend, code path not reached, task never runs).
- Django tries, but the connection to the SMTP server fails (blocked port, wrong host, TLS mismatch).
- The SMTP server rejects the message (authentication, unverified sender, sandbox restrictions).
- 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.
Quick answer
- Check
EMAIL_BACKENDin production; the console, file, locmem, or dummy backends never deliver mail. - Run
python manage.py sendtestemail you@example.comwith production settings and read any exception. - Search code for
fail_silently=True, which hides errors. - Test network access to the SMTP host and port from the server (
nc -vz host 587). - Match port and TLS settings: 587 with
EMAIL_USE_TLS, 465 withEMAIL_USE_SSL. - Check the email provider's activity or suppression logs for rejections and bounces.
- If emails are sent from Celery, confirm the task ran.
- 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)
"
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" .
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
| Exception | Cause |
|---|---|
SMTPServerDisconnected: Connection unexpectedly closed | often 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 supported | EMAIL_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 |
TimeoutError | network block (step 4) or EMAIL_TIMEOUT too short |
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.
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=noneand 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:
sendtestemaildelivers 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=Truein production code paths. - Sending from a Gmail or other free mailbox address through a transactional provider.
- Assuming a return value of
1fromsend_mailmeans the email was delivered.
Next steps
- Configure sending correctly with Configure Django Email Sending in Production.
- If email tasks never run, see Fix Celery Tasks Stuck in PENDING.
- Capture failures centrally with Django Logging Setup for Production.
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.