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 NoneUse 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`.
# 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.