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:

bash
# 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-pager

Fastest 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.

bash
printenv | grep -E 'MAIL|SMTP|SENDGRID|POSTMARK|RESEND|SES|APP_URL'

Verify at minimum:

  • MAIL_FROM
  • SMTP_HOST
  • SMTP_PORT
  • SMTP_USER
  • SMTP_PASSWORD

Or provider-specific values such as:

  • SENDGRID_API_KEY
  • POSTMARK_SERVER_TOKEN
  • RESEND_API_KEY
  • AWS_ACCESS_KEY_ID
  • AWS_SECRET_ACCESS_KEY
  • AWS_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:

  1. request log
  2. queue enqueue event
  3. worker execution
  4. provider response

Example log search:

bash
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

If 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.

bash
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')
PY

If you use an email API, test from the production host with curl.

Example SendGrid:

bash
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"}]
  }' -i

Example Mailgun:

bash
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:

bash
nc -vz smtp.mailgun.org 587
openssl s_client -starttls smtp -connect smtp.mailgun.org:587

API endpoint checks:

bash
curl -I https://api.sendgrid.com
curl -I https://api.mailgun.net

If SMTP port access fails, your VPS or cloud provider may be blocking outbound mail. In that case:

  • use port 587 with TLS instead of 25
  • 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.

bash
dig TXT yourdomain.com
nslookup -type=TXT yourdomain.com
nslookup -type=TXT default._domainkey.yourdomain.com
nslookup -type=TXT _dmarc.yourdomain.com

You want:

  • valid SPF
  • valid DKIM
  • valid DMARC
  • provider-verified sender domain
  • MAIL_FROM aligned with that verified domain

Example SPF:

txt
v=spf1 include:mailgun.org ~all

Example DMARC:

txt
v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com

Do 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.

bash
ps aux | grep -E 'celery|rq|worker'
celery -A app inspect active
celery -A app report
redis-cli LLEN default
rq info

Common 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:

python
try:
    send_email(...)
except Exception:
    pass

Use structured logging instead:

python
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",
    })
    raise

Recommended:

  • 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
App
Queue
Worker
Provider
Recipient mailbox

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_FROM not 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:

bash
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 info

Direct SMTP send test:

bash
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')
PY

Debugging 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

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.