Skip to content

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

bash
pip install poststack

2. Initialize the client

python
# 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

python
# 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.

python
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 None

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