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

Use PostStack as your Django EMAIL_BACKEND (send_mail, EmailMessage, attachments)

Already have Django email code? Point Django’s SMTP backend at PostStack and your existing `send_mail`, `EmailMessage`, and `EmailMultiAlternatives` calls keep working unchanged. PostStack’s relay requires TLS on port 587 and accepts both implicit TLS and STARTTLS; the simplest is implicit TLS, so set `EMAIL_USE_SSL = True` (or use STARTTLS with `EMAIL_USE_TLS = True` — just not both). Reach for the SDK above when you also want webhooks, open/click tracking, suppression handling, and a per-message `email.id`.

python
# settings.py — route Django's built-in email through PostStack over SMTP
import os

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.poststack.dev"
EMAIL_PORT = 587
EMAIL_USE_SSL = True            # implicit TLS on 587 (RFC 8314) — do NOT also set EMAIL_USE_TLS
EMAIL_HOST_USER = "poststack"
EMAIL_HOST_PASSWORD = os.environ["POSTSTACK_API_KEY"]
DEFAULT_FROM_EMAIL = "hello@yourdomain.com"

# 1. send_mail — the one-liner
from django.core.mail import send_mail

send_mail(
    "Welcome to Acme",
    "Thanks for signing up.",
    None,                       # None uses DEFAULT_FROM_EMAIL
    ["user@example.com"],
)

# 2. EmailMultiAlternatives — HTML with a plain-text fallback
from django.core.mail import EmailMultiAlternatives

msg = EmailMultiAlternatives(
    subject="Welcome",
    body="Thanks for signing up.",          # text/plain part
    to=["user@example.com"],
)
msg.attach_alternative("<h1>Welcome!</h1>", "text/html")
msg.send()

# 3. EmailMessage — attach a file (e.g. a PDF receipt)
from django.core.mail import EmailMessage

email = EmailMessage(
    subject="Your receipt",
    body="Your receipt is attached.",
    to=["user@example.com"],
)
email.attach("receipt.pdf", pdf_bytes, "application/pdf")
email.send()

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.

How do I set EMAIL_BACKEND to send Django email through PostStack?

Add `EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"` to settings.py with `EMAIL_HOST = "smtp.poststack.dev"`, `EMAIL_PORT = 587`, `EMAIL_USE_SSL = True` (implicit TLS; PostStack also supports STARTTLS on 587 via `EMAIL_USE_TLS = True`), `EMAIL_HOST_USER = "poststack"`, and `EMAIL_HOST_PASSWORD` read from `POSTSTACK_API_KEY`. That routes all of Django’s built-in email — password resets, admin error mail, and your own `send_mail` calls — through PostStack with no code changes.

Can I keep using send_mail, EmailMessage, and EmailMultiAlternatives with PostStack?

Yes. Once PostStack is your `EMAIL_BACKEND`, your existing `send_mail()`, `EmailMessage`, and `EmailMultiAlternatives` code — including `.attach()` for files and `.attach_alternative()` for HTML — sends through PostStack unchanged. Switch to the PostStack SDK directly when you also want webhooks, open/click tracking, and a per-message `email.id`.

Related guides

Ready to send emails with Django?

Create a free account and get your API key in under a minute.