Email Not Sending
The essential playbook for implementing email not sending in your SaaS.
Use this page when registration emails, password resets, notifications, or billing emails are not being delivered from your app. The goal is to isolate whether the failure is in app code, background jobs, SMTP/API configuration, DNS records, or provider-side blocking, then restore reliable delivery with minimal guesswork.
Quick Fix / Quick Setup
Start with the fastest isolation sequence:
# 1) Verify runtime env vars on the server
printenv | grep -E 'MAIL|SMTP|SENDGRID|POSTMARK|RESEND|SES'
# 2) Test DNS records for your sending domain
dig TXT yourdomain.com
# Check SPF
nslookup -type=TXT yourdomain.com
# Check DKIM selector example
nslookup -type=TXT default._domainkey.yourdomain.com
# Check DMARC
nslookup -type=TXT _dmarc.yourdomain.com
# 3) Test SMTP connectivity
nc -vz smtp.mailgun.org 587
openssl s_client -starttls smtp -connect smtp.mailgun.org:587
# 4) Send a direct test email from app shell / Python
python - <<'PY'
import os, smtplib
from email.mime.text import MIMEText
host=os.getenv('SMTP_HOST')
port=int(os.getenv('SMTP_PORT','587'))
user=os.getenv('SMTP_USER')
password=os.getenv('SMTP_PASSWORD')
msg=MIMEText('SMTP test email')
msg['Subject']='SMTP test'
msg['From']=os.getenv('MAIL_FROM')
msg['To']=os.getenv('TEST_EMAIL')
with smtplib.SMTP(host, port, timeout=20) as s:
s.starttls()
s.login(user, password)
s.send_message(msg)
print('sent')
PY
# 5) If using background workers, verify queue/worker health
ps aux | grep -E 'celery|rq|worker'
celery -A app inspect active
redis-cli LLEN default
# 6) Inspect recent app logs for provider/API errors
journalctl -u your-app -n 200 --no-pager
journalctl -u your-worker -n 200 --no-pagerFastest path: confirm the app is using the expected mail credentials in production, send one direct test email outside your normal app flow, then verify whether your domain has valid SPF/DKIM/DMARC. If the provider accepted the message but the user did not receive it, the issue is usually DNS, suppression lists, spam placement, or sending from an unverified domain.
What’s happening
Email delivery has multiple failure points:
- app logic
- queue execution
- mail transport
- provider account state
- DNS authentication
- recipient-side filtering
Key points:
- A successful API or SMTP request does not guarantee inbox delivery.
- The provider may accept the message but classify it as unverified, suppressed, bounced, blocked, or spam-prone.
- Auth flows often hide the real issue because the UI says "check your email" while the actual send failed asynchronously.
- Production failures commonly happen after deployment because environment variables, domain verification, or callback URLs differ from local development.
Step-by-step implementation
1. Confirm production configuration
Check effective environment variables on both the web process and the worker process.
printenv | grep -E 'MAIL|SMTP|SENDGRID|POSTMARK|RESEND|SES|APP_URL'Verify at minimum:
MAIL_FROMSMTP_HOSTSMTP_PORTSMTP_USERSMTP_PASSWORD
Or provider-specific values such as:
SENDGRID_API_KEYPOSTMARK_SERVER_TOKENRESEND_API_KEYAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_REGION
Common failure: the web app has the correct secrets, but the worker is running with stale or missing values.
2. Confirm the app actually attempted a send
Trigger a known flow:
- signup verification
- password reset
- invoice email
- support notification
Then correlate:
- request log
- queue enqueue event
- worker execution
- provider response
Example log search:
journalctl -u your-app -n 200 --no-pager
journalctl -u your-worker -n 200 --no-pager
grep -Ri 'email\|smtp\|sendgrid\|mailgun\|ses' /var/logIf no worker log appears after the request, the problem is likely queue enqueueing or worker execution.
3. Send a direct transport test from production
If this fails, the issue is not your app flow. It is credentials, SMTP/API access, provider state, or network/DNS.
python - <<'PY'
import os, smtplib
from email.mime.text import MIMEText
msg=MIMEText('test')
msg['Subject']='test'
msg['From']=os.getenv('MAIL_FROM')
msg['To']=os.getenv('TEST_EMAIL')
with smtplib.SMTP(os.getenv('SMTP_HOST'), int(os.getenv('SMTP_PORT','587'))) as s:
s.starttls()
s.login(os.getenv('SMTP_USER'), os.getenv('SMTP_PASSWORD'))
s.send_message(msg)
print('sent')
PYIf you use an email API, test from the production host with curl.
Example SendGrid:
curl -X POST https://api.sendgrid.com/v3/mail/send \
-H "Authorization: Bearer $SENDGRID_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"personalizations":[{"to":[{"email":"'"$TEST_EMAIL"'"}]}],
"from":{"email":"'"$MAIL_FROM"'"},
"subject":"API test",
"content":[{"type":"text/plain","value":"test email"}]
}' -iExample Mailgun:
curl -s --user "api:$MAILGUN_API_KEY" \
https://api.mailgun.net/v3/yourdomain.com/messages \
-F from="$MAIL_FROM" \
-F to="$TEST_EMAIL" \
-F subject="Mailgun test" \
-F text="test email"4. Verify SMTP or API connectivity
SMTP checks:
nc -vz smtp.mailgun.org 587
openssl s_client -starttls smtp -connect smtp.mailgun.org:587API endpoint checks:
curl -I https://api.sendgrid.com
curl -I https://api.mailgun.netIf SMTP port access fails, your VPS or cloud provider may be blocking outbound mail. In that case:
- use port
587with TLS instead of25 - switch to provider API delivery
- check host firewall and cloud egress rules
5. Verify sender identity and domain authentication
Check DNS records for your sending domain.
dig TXT yourdomain.com
nslookup -type=TXT yourdomain.com
nslookup -type=TXT default._domainkey.yourdomain.com
nslookup -type=TXT _dmarc.yourdomain.comYou want:
- valid SPF
- valid DKIM
- valid DMARC
- provider-verified sender domain
MAIL_FROMaligned with that verified domain
Example SPF:
v=spf1 include:mailgun.org ~allExample DMARC:
v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.comDo not send production auth or billing email from:
- personal Gmail addresses
- unverified domains
- placeholder
noreply@addresses on domains not configured with your provider
6. Check provider account state
Inspect your provider dashboard for:
- sandbox mode
- paused account
- failed billing
- domain verification pending
- region mismatch
- rate limiting
- suppression lists
- bounce or complaint events
- blocked recipient addresses
Provider-specific checks:
SMTP
Verify:
- host
- port
- username
- password
- TLS mode
- from-address
Amazon SES
Verify:
- region
- sender identity
- DKIM records
- sandbox mode
- IAM or SMTP credentials
SendGrid
Verify:
- API key permissions
- sender identity
- domain authentication
- suppression lists
- activity feed events
Mailgun
Verify:
- domain
- SMTP credentials or API key
- sending region
- DNS records
- accepted/rejected event logs
Postmark / Resend
Verify:
- server token or API key
- sender signature or domain verification
- message stream configuration
7. Check background jobs and queue execution
If your app enqueues emails, verify the queue path.
ps aux | grep -E 'celery|rq|worker'
celery -A app inspect active
celery -A app report
redis-cli LLEN default
rq infoCommon failures:
- worker not running
- queue name mismatch
- Redis/RabbitMQ unavailable
- jobs retrying forever
- jobs dead-lettered
- worker has stale environment variables after deploy
For Celery, verify the worker uses the same settings file and env vars as the web process.
8. Check code-level handling
Your email code should log provider response details and fail visibly.
Bad pattern:
try:
send_email(...)
except Exception:
passUse structured logging instead:
import logging
logger = logging.getLogger(__name__)
try:
result = send_email(...)
logger.info("email_sent", extra={
"type": "password_reset",
"recipient": user.email[:3] + "***",
"provider": "smtp",
})
except Exception as e:
logger.exception("email_send_failed", extra={
"type": "password_reset",
"provider": "smtp",
})
raiseRecommended:
- store provider message IDs
- log queue job IDs
- tag by email type
- avoid swallowing exceptions
- retry with backoff for transient failures
9. Validate auth email link generation
Users often report "email not sending" when the email arrives but the link is invalid.
Check:
APP_URL- frontend URL
- HTTPS scheme
- reverse proxy headers
- token lifetime
- reset/verification callback path
Test a generated link directly in production.
If you need to review auth-specific flow bugs, also check Common Auth Bugs and Fixes, Email Verification Flow (Step-by-Step), and Password Reset Flow Implementation.
10. Test across recipient providers
Deliver to at least:
- Gmail
- Outlook
- one custom domain mailbox
Interpretation:
- all providers fail: app, queue, transport, credentials, or provider issue
- only one provider fails: reputation, authentication, spam filtering, or recipient-specific suppression
- provider accepted but mail missing: check spam, suppression, DMARC alignment, and sender reputation
11. Add a durable email safety layer
Minimum production-safe setup:
- verified sending domain
- SPF, DKIM, DMARC
- direct test path from production host
- worker health checks
- structured logs for sends and failures
- provider event visibility
- synthetic recurring test email
Process Flow
Common causes
- Missing or incorrect SMTP/API credentials in production
- Worker process not running, stuck, or using stale environment variables
- Outbound SMTP blocked by VPS or cloud provider
- Unverified sender domain or from-address
- Missing or invalid SPF, DKIM, or DMARC records
- Provider sandbox mode, paused account, billing issue, or rate limiting
- Recipient address on suppression/bounce list
- Email exceptions swallowed in code without logging
- Wrong provider region or endpoint
- Invalid template rendering or app logic preventing send
- Broken Redis/RabbitMQ connection for queued email jobs
MAIL_FROMnot aligned with verified domain- Emails delivered to spam due to poor domain reputation or missing authentication
- Password reset or verification flow generating invalid links, causing false reports of email failure
Debugging tips
Use these commands directly from the production host:
printenv | grep -E 'MAIL|SMTP|SENDGRID|POSTMARK|RESEND|SES'
journalctl -u your-app -n 200 --no-pager
journalctl -u your-worker -n 200 --no-pager
grep -Ri 'email\|smtp\|sendgrid\|mailgun\|ses' /var/log
nc -vz smtp.mailgun.org 587
openssl s_client -starttls smtp -connect smtp.mailgun.org:587
curl -I https://api.sendgrid.com
curl -I https://api.mailgun.net
dig TXT yourdomain.com
nslookup -type=TXT default._domainkey.yourdomain.com
nslookup -type=TXT _dmarc.yourdomain.com
celery -A app inspect active
celery -A app report
redis-cli LLEN default
rq infoDirect SMTP send test:
python - <<'PY'
import os, smtplib
from email.mime.text import MIMEText
msg=MIMEText('test')
msg['Subject']='test'
msg['From']=os.getenv('MAIL_FROM')
msg['To']=os.getenv('TEST_EMAIL')
with smtplib.SMTP(os.getenv('SMTP_HOST'), int(os.getenv('SMTP_PORT','587'))) as s:
s.starttls(); s.login(os.getenv('SMTP_USER'), os.getenv('SMTP_PASSWORD')); s.send_message(msg)
print('sent')
PYDebugging rules:
- compare actual runtime env vars, not only
.env - confirm whether the provider accepted the request
- inspect suppression and bounce lists before retrying repeatedly
- if links are broken, inspect domain and HTTPS config
- if production-only failure exists, compare firewall and outbound egress restrictions
If you need broader incident debugging, use Debugging Production Issues and Error Tracking with Sentry.
Checklist
- ✓ Production mail credentials are set on both web and worker processes
- ✓ A direct test email from the production host succeeds
- ✓ Provider account is active and not in sandbox or restricted mode
- ✓ Sender domain and from-address are verified
- ✓ SPF, DKIM, and DMARC records exist and match provider instructions
- ✓ Queue workers are running and processing jobs
- ✓ App logs capture email exceptions and provider responses
- ✓ Suppression, bounce, and complaint lists have been checked
- ✓ Email links use the correct production domain and HTTPS
- ✓ Delivery has been tested across at least two recipient providers
- ✓ Auth flows using email have been tested end-to-end
- ✓ Production release checks are documented in SaaS Production Checklist
Related guides
- Common Auth Bugs and Fixes
- Debugging Production Issues
- Error Tracking with Sentry
- SaaS Production Checklist
- Environment Setup on VPS
FAQ
Why are password reset emails not arriving?
Most often the email job never ran, the provider rejected the sender domain, or the recipient was suppressed. Check worker logs, provider events, and DNS authentication first.
Can my server block outgoing email?
Yes. Many VPS and cloud providers restrict outbound SMTP on ports like 25. Use the provider’s recommended submission port such as 587 with TLS, or use an email API instead of raw SMTP.
How do I know if the provider accepted the message?
Inspect the SMTP response or API response in logs, then verify the provider dashboard activity feed using the provider message ID if available.
Should auth emails go through the same queue as other jobs?
They can, but keep retries, visibility, and alerting in place. Password reset and verification emails are high-priority flows and should be easy to trace.
What is the minimum setup for reliable delivery?
Verified sender domain, SPF, DKIM, correct production secrets, structured logging, active worker processes, and a direct test path from the production host.
Why does my app say email sent but no message arrives?
Usually the app accepted the request or queued the job, but the provider rejected it later, the recipient was suppressed, or the message landed in spam.
Why does email work locally but not in production?
Production often has different environment variables, blocked outbound SMTP ports, missing DNS records, or a worker process that is not running.
Do I need SPF and DKIM for password reset emails?
Yes. You can sometimes send without them, but deliverability is worse and some providers will reject or spam-folder your messages.
What if only Gmail users do not receive emails?
Check SPF, DKIM, DMARC, sender reputation, content triggers, and provider deliverability indicators.
Final takeaway
Treat email sending as a pipeline, not a single app call.
First prove where the failure is:
- app code
- queueing
- transport
- provider state
- DNS authentication
- inbox placement
The fastest durable fix is:
- verified domain setup
- correct production secrets on all processes
- direct transport testing from production
- provider event visibility
- end-to-end testing of auth and transactional flows
For adjacent production issues, continue with Debugging Production Issues, Error Tracking with Sentry, and SaaS Production Checklist.