Send Emails with Django
Learn how to send transactional emails using PostStack and Django.
Django ships its own `send_mail` helper and an `EMAIL_BACKEND` setting, but those are tuned for occasional admin mail over SMTP — not high-volume transactional sending. For password resets, receipts, and welcome emails you want PostStack's API directly: better deliverability, webhooks, suppression handling, and an `email.id` you can store against your models. The pattern below keeps the SDK in a small `emails.py` service module, imports it from views and Celery tasks, and never blocks the request thread on a network call.
1. Install the SDK
pip install poststack2. Initialize the client
# settings.py — read the key from the environment, never hardcode it
import os
POSTSTACK_API_KEY = os.environ["POSTSTACK_API_KEY"]
# emails.py — a thin service module your views and tasks import
from poststack import PostStack
from django.conf import settings
ps = PostStack(api_key=settings.POSTSTACK_API_KEY)3. Send an email
# emails.py
from django.conf import settings
from poststack import PostStack, PostStackError
ps = PostStack(api_key=settings.POSTSTACK_API_KEY)
def send_welcome_email(user):
return ps.emails.send({
"from": "hello@yourdomain.com",
"to": [user.email],
"subject": "Welcome to Acme",
"html": f"<h1>Welcome, {user.get_short_name()}!</h1>",
})
# views.py — dispatch to Celery so the request returns immediately
from django.http import JsonResponse
from .tasks import send_welcome_email_task
def signup(request):
user = create_user(request.POST)
send_welcome_email_task.delay(user.id) # non-blocking
return JsonResponse({"ok": True})
# tasks.py
from celery import shared_task
from django.contrib.auth import get_user_model
from .emails import send_welcome_email
@shared_task(bind=True, max_retries=3)
def send_welcome_email_task(self, user_id):
user = get_user_model().objects.get(pk=user_id)
try:
result = send_welcome_email(user)
user.welcome_email_id = result["id"] # correlate webhooks later
user.save(update_fields=["welcome_email_id"])
except PostStackError as exc:
raise self.retry(exc=exc, countdown=2 ** self.request.retries)4. Handle errors
Django idioms for error handling, retries, and structured logging when calling the PostStack API.
import os
import logging
from celery import shared_task
from poststack import PostStack, PostStackError
logger = logging.getLogger(__name__)
ps = PostStack(api_key=os.environ["POSTSTACK_API_KEY"])
@shared_task(bind=True, max_retries=3)
def send_email_task(self, payload):
try:
return ps.emails.send(payload)
except PostStackError as exc:
# The SDK already retried 429/5xx internally; only re-queue on a
# terminal server error, and never retry a 4xx (bad payload/recipient).
if exc.status_code >= 500 or exc.status_code == 429:
raise self.retry(exc=exc, countdown=2 ** self.request.retries)
logger.error("PostStack %s: %s (req %s)", exc.code, exc.error, exc.request_id)
return NoneFramework integrations
Celery / Django-Q background tasks
Wrap the send in a `@shared_task` and call `.delay()` from your view so the user-facing request returns immediately. Use `self.retry()` with exponential backoff for transient API errors, and store the returned `email.id` on the model so inbound webhook events can be correlated back to the user.
Signals (post_save welcome emails)
Connect a `post_save` signal on your user model to a handler that dispatches the Celery task. Keep the actual SDK call inside the task, not the signal handler, so a slow API call never blocks the save transaction.
EMAIL_BACKEND vs the SDK
Keep Django's `EMAIL_BACKEND` for framework-internal mail (admin error reports, `PasswordResetView` if you use the built-in flow). Use the PostStack SDK directly for product transactional email where you need webhooks, tracking, and per-message IDs.
ASGI / async views
If you run Django under ASGI with `async def` views, do not call the synchronous client directly — wrap it in `asgiref.sync.sync_to_async`, or use `AsyncPostStack` so you do not block the event loop.
Common pitfalls
Sending inside the request/response cycle
Calling `ps.emails.send(...)` directly in a view adds the API round-trip to your response time and fails the request if the API hiccups. Always dispatch to a background task for user-facing flows.
Putting the API key in settings.py literally
Read `POSTSTACK_API_KEY` from `os.environ` (via django-environ or your platform secret store). A key committed in `settings.py` will be auto-revoked if it reaches a public repo.
Blocking the event loop under ASGI
Under ASGI, the synchronous client blocks the worker. Use `sync_to_async` or switch to `AsyncPostStack` in async views.
Notes
- Use the synchronous `PostStack` client — Django views are sync by default
- Dispatch sends to Celery (or Django-Q/RQ) so the HTTP request is never blocked on the API call
FAQ
Should I use the PostStack SDK or Django’s send_mail?
Use Django's `send_mail`/`EMAIL_BACKEND` for framework-internal mail like admin error reports. Use the PostStack SDK for product transactional email — it gives you webhooks, open/click tracking, suppression handling, and a per-message `email.id` you can store against your models.
How do I send email from a Celery task in Django?
Put the SDK call in a `@shared_task`, call `.delay()` from your view, and use `self.retry()` for transient errors. The example above stores the returned `email.id` on the user model so webhook events can be correlated later.
Does this work with Django running under ASGI?
Yes. With sync views the synchronous client is fine. With `async def` views, wrap the call in `asgiref.sync.sync_to_async` or use `AsyncPostStack` so you do not block the event loop.
Related guides
Ready to send emails with Django?
Create a free account and get your API key in under a minute.