Skip to content

Send email with Django

Send transactional email from Django through PostStack.

Django already has everything you need to send email: django.core.mail (send_mail, EmailMessage, EmailMultiAlternatives) plus a pluggable EMAIL_BACKEND. The fastest way to send Django email through PostStack is to keep that code and change only settings.py — point the built-in SMTP backend at smtp.poststack.dev and use your API key as the password. Password resets, mail_admins error reports, and every existing send_mail call then go out through PostStack's EU infrastructure. When you need more than SMTP can return — a per-message id, delivery/bounce webhooks, idempotent retries — call the REST API with the Python SDK from the same project. Both paths are shown below.

Quickstart#

Before you start

You need an API key (sk_live_…, or sk_test_… to try it without delivering anything) and a sending domain you have verified in PostStack. See Authentication and Domains.

  1. Install (optional)

    bash
    pip install poststack   # optional REST SDK — the SMTP EMAIL_BACKEND below is built into Django
  2. Configure Django email settings (settings.py)

    python
    # settings.py — point Django's email backend at PostStack
    import os
    
    EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
    EMAIL_HOST = "smtp.poststack.dev"
    EMAIL_PORT = 587
    EMAIL_USE_TLS = True            # STARTTLS on 587. For port 465 use EMAIL_USE_SSL = True instead — never both
    EMAIL_HOST_USER = "poststack"   # any non-empty value; the API key is what authenticates
    EMAIL_HOST_PASSWORD = os.environ["POSTSTACK_API_KEY"]  # sk_live_... (or sk_test_... in staging)
    EMAIL_TIMEOUT = 10              # seconds; the default (None) can hang a worker forever
    
    DEFAULT_FROM_EMAIL = "Acme <hello@yourdomain.com>"   # must be on a verified domain
    SERVER_EMAIL = "errors@yourdomain.com"                # sender of mail_admins / 500 error mail
    
    # Used by the REST SDK examples further down
    POSTSTACK_API_KEY = os.environ["POSTSTACK_API_KEY"]
  3. Send email with send_mail

    python
    from django.core.mail import send_mail
    
    # Plain text + optional HTML in one call
    send_mail(
        subject="Welcome to Acme",
        message="Thanks for signing up.",           # text/plain part
        from_email=None,                             # None = DEFAULT_FROM_EMAIL
        recipient_list=["user@example.com"],
        html_message="<h1>Welcome!</h1><p>Thanks for signing up.</p>",
        fail_silently=False,                         # raise instead of swallowing SMTP errors
    )
  4. Handle errors

    python
    import logging
    import smtplib
    from django.core.mail import EmailMessage
    
    logger = logging.getLogger(__name__)
    
    def send_receipt(to: str, pdf: bytes) -> bool:
        email = EmailMessage(
            subject="Your receipt",
            body="Your receipt is attached.",
            to=[to],
        )
        email.attach("receipt.pdf", pdf, "application/pdf")
        try:
            return email.send(fail_silently=False) == 1   # send() returns the number of messages sent
        except smtplib.SMTPAuthenticationError:
            # 535 — wrong/revoked API key in EMAIL_HOST_PASSWORD
            logger.exception("PostStack rejected the SMTP credentials")
            raise
        except smtplib.SMTPResponseException as exc:
            # The server's reason is in smtp_error, e.g.
            # "Domain 'example.com' not found for this team" when the From address
            # is not on a verified domain. Fix settings before retrying these.
            logger.error("PostStack refused the message: %s %s", exc.smtp_code, exc.smtp_error)
            raise
        except (smtplib.SMTPException, OSError):
            # connection refused / timeout — usually a firewall blocking outbound 587
            logger.exception("Could not reach smtp.poststack.dev")
            raise

Good to know

  • SMTP host smtp.poststack.dev, port 587 with STARTTLS (EMAIL_USE_TLS) or port 465 with implicit TLS (EMAIL_USE_SSL); the password is your API key
  • DEFAULT_FROM_EMAIL and SERVER_EMAIL must use a domain you have verified in PostStack — Django’s defaults (webmaster@localhost, root@localhost) are rejected
  • An sk_test_... key accepts and logs mail without delivering it — use it in staging and CI

Send HTML email in Django with EmailMultiAlternatives#

EmailMultiAlternatives sends a multipart/alternative message: a plain-text body for clients that do not render HTML, plus an HTML version. Render both from templates with render_to_string so they stay in sync.

python
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string

def send_welcome(user):
    context = {"user": user}
    msg = EmailMultiAlternatives(
        subject="Welcome to Acme",
        body=render_to_string("emails/welcome.txt", context),     # text/plain
        from_email="Acme <hello@yourdomain.com>",
        to=[user.email],
        reply_to=["support@yourdomain.com"],
    )
    msg.attach_alternative(render_to_string("emails/welcome.html", context), "text/html")
    msg.send()

Send email with an attachment in Django (EmailMessage)#

EmailMessage.attach() takes a filename, the content (bytes or str) and a MIME type; attach_file() reads from disk and guesses the type. PostStack accepts up to 10 attachments per message, 10 MB per file and 25 MB in total (decoded size).

python
from django.core.mail import EmailMessage

email = EmailMessage(
    subject="Invoice #1042",
    body="Your invoice and terms are attached.",
    from_email="billing@yourdomain.com",
    to=["customer@example.com"],
    cc=["accounts@example.com"],
    bcc=["archive@yourdomain.com"],
)
email.attach("invoice-1042.pdf", pdf_bytes, "application/pdf")   # in-memory
email.attach_file("/srv/app/static/terms.pdf")                    # from disk
# email.content_subtype = "html"   # uncomment to send body as text/html
email.send()

Tags, idempotency and scheduling over SMTP#

SMTP cannot return a message id, but PostStack reads X-PostStack-* headers on messages it relays: comma-separated tags (filterable in the email log and the GET /emails?tag= API), an idempotency key so a retried Celery task does not deliver twice, and an ISO 8601 schedule time. Other custom X- headers are passed through unchanged.

python
from django.core.mail import EmailMessage

EmailMessage(
    subject="Your order has shipped",
    body="Tracking number: 1Z999…",
    to=["user@example.com"],
    headers={
        "X-PostStack-Tags": "order-shipped,transactional",
        "X-PostStack-Idempotency-Key": f"order-{order.pk}-shipped",
        "X-PostStack-Schedule": "2026-10-01T08:00:00Z",   # optional: send later
    },
).send()

Send many emails over one connection#

Each send_mail() call opens and closes its own SMTP connection. For a loop, open one connection with get_connection() and pass it to send_messages() (or use send_mass_mail). For bulk marketing mail, a PostStack broadcast is usually a better fit than a loop.

python
from django.core.mail import EmailMessage, get_connection

with get_connection() as connection:          # one TLS handshake + AUTH for the batch
    messages = [
        EmailMessage("Your weekly report", render_report(u), to=[u.email])
        for u in users
    ]
    sent = connection.send_messages(messages)  # returns how many were accepted

Alternative: send through the REST API with the Python SDK#

Use the SDK when you need the email id back (to match email.delivered / email.bounced webhooks to a user), per-send open/click tracking, templates with variables, or batch sends of up to 100 messages per request. Keep it in a small service module and call it from a Celery task.

python
# emails.py
from django.conf import settings
from poststack import PostStack

ps = PostStack(api_key=settings.POSTSTACK_API_KEY)   # one client per process

def send_welcome_email(user):
    return ps.emails.send({
        "from": "Acme <hello@yourdomain.com>",
        "to": [user.email],                     # always a list
        "subject": "Welcome to Acme",
        "html": f"<h1>Welcome, {user.get_short_name()}!</h1>",
        "text": f"Welcome, {user.get_short_name()}!",
        "tags": ["welcome"],
    })

# tasks.py
from celery import shared_task
from django.contrib.auth import get_user_model
from poststack import PostStackError
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)
    except PostStackError as exc:
        # The SDK already retried 408/429/5xx. Never retry a 4xx such as
        # 400 (invalid payload) or 422 (unverified domain, suppressed recipient).
        if exc.status_code >= 500 or exc.status_code == 429:
            raise self.retry(exc=exc, countdown=2 ** self.request.retries * 30)
        raise
    user.welcome_email_id = result["id"]         # "em_…" — correlate webhooks later
    user.save(update_fields=["welcome_email_id"])

# views.py
def signup(request):
    user = create_user(request.POST)
    send_welcome_email_task.delay(user.id)      # the request returns immediately
    ...

Handle bounces and complaints with a webhook view#

A hard bounce or spam complaint arrives after send() has returned, so Django cannot see it in the request. Add a webhook endpoint in PostStack for email.bounced and email.complained, and verify the X-PostStack-Signature header against request.body — the raw bytes, never a re-serialised json.loads result. PostStack already suppresses the address, so further sends to it are dropped; the view only has to update your own records. This matches on the em_… id saved by the SDK example above. With SMTP, match on data["to"] instead.

python
# views.py
import json
import os

from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from .emails import ps                      # the client from emails.py above
from .models import User

@csrf_exempt                       # PostStack cannot send Django's CSRF token
@require_POST
def poststack_webhook(request):
    if not ps.webhooks.verify(              # local HMAC check, no API call
        request.body,                       # raw bytes, exactly as received
        request.headers.get("X-PostStack-Signature", ""),
        os.environ["POSTSTACK_WEBHOOK_SECRET"],   # whsec_...
    ):
        return HttpResponse(status=401)

    event = json.loads(request.body)
    data = event["data"]
    if event["type"] in ("email.bounced", "email.complained"):
        User.objects.filter(welcome_email_id=data["email_id"]).update(email_undeliverable=True)
    return HttpResponse(status=200)         # any 2xx; anything else is retried

# urls.py
urlpatterns = [path("webhooks/poststack", views.poststack_webhook)]   # register this exact URL

Console EMAIL_BACKEND for development, locmem for tests#

Do not send real mail from a laptop or CI. Django’s test runner already swaps in the locmem backend and collects messages in mail.outbox.

python
# settings/dev.py — print emails to the runserver console
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"

# settings/staging.py — real SMTP, but a PostStack test key: accepted,
# logged in the dashboard, never delivered. Same settings; set
# POSTSTACK_API_KEY to an sk_test_... key in this environment.

# tests.py
from django.core import mail
from django.test import TestCase

class SignupEmailTests(TestCase):
    def test_welcome_email_sent(self):
        self.client.post("/signup/", {"email": "new@example.com"})
        self.assertEqual(len(mail.outbox), 1)
        self.assertEqual(mail.outbox[0].subject, "Welcome to Acme")

Framework integrations#

EMAIL_BACKEND (SMTP) — zero code changes

django.core.mail.backends.smtp.EmailBackend is Django's default backend, so the only change is the EMAIL_* settings above. Everything built on django.core.mail — PasswordResetView, mail_admins, AdminEmailHandler, django-allauth, third-party apps — sends through PostStack. Tags, idempotency keys and scheduled sends are available as X-PostStack-* headers.

REST API via the Python SDK

ps.emails.send({...}) returns an em_… id you can store on your model and match against webhook events (email.delivered, email.bounced, email.complained). The SDK retries 408/429/5xx with backoff and adds an Idempotency-Key automatically, so a retried request never delivers twice.

Celery / Django-Q / RQ background tasks

Whether you use SMTP or the SDK, send from a background task so a slow network call never delays the HTTP response. Pass ids (not model instances) to the task, and use self.retry() with backoff for transient errors only.

Per-environment backends

Use django.core.mail.backends.console.EmailBackend in local development, locmem (Django sets this automatically) in tests, and the PostStack SMTP backend — with an sk_test_ key in staging and sk_live_ in production — everywhere else.

Common pitfalls#

SMTPAuthenticationError (535)

EMAIL_HOST_PASSWORD must be a PostStack API key (sk_live_… or sk_test_…) with sending permission, not your dashboard password. Check for a trailing newline when the value comes from a secrets file, and that the key has not been revoked.

"Domain '…' not found for this team" / "is not verified"

The From address must be on a domain you added and verified in PostStack. Django falls back to DEFAULT_FROM_EMAIL (default webmaster@localhost) when from_email is None, and mail_admins / 500-error reports use SERVER_EMAIL (default root@localhost) — set both.

Setting both EMAIL_USE_TLS and EMAIL_USE_SSL

Django raises ValueError: EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive. Use EMAIL_USE_TLS = True with port 587 (STARTTLS) or EMAIL_USE_SSL = True with port 465 (implicit TLS).

Timeouts and hanging workers

EMAIL_TIMEOUT defaults to no timeout, so a blocked outbound port can hang a gunicorn worker. Set it (10 s is plenty) and make sure your host allows outbound TCP 587 or 465 — some clouds block SMTP ports on new accounts; the REST API over 443 is the fallback.

Emails silently not sent

fail_silently=True swallows every SMTP error — leave it False and log exceptions. Also check that the console or locmem backend is not still configured for the environment you are testing.

Sending inside the request/response cycle

An SMTP or API call in the view adds its round-trip to your response time and fails the request if the network hiccups. Dispatch to Celery (or Django-Q/RQ) for user-facing flows.

FAQ#

How do I configure the Django email backend for PostStack?

Set EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend", EMAIL_HOST = "smtp.poststack.dev", EMAIL_PORT = 587, EMAIL_USE_TLS = True, EMAIL_HOST_USER = "poststack" and EMAIL_HOST_PASSWORD to your API key (read from an environment variable). Port 465 with EMAIL_USE_SSL = True also works. No package is needed — it is Django’s built-in SMTP backend.

What is the difference between send_mail, EmailMessage and EmailMultiAlternatives?

send_mail() is a one-call helper for text (plus optional html_message). EmailMessage is the full object: cc, bcc, reply_to, custom headers and attachments. EmailMultiAlternatives is an EmailMessage that can carry several bodies — typically plain text plus HTML via attach_alternative(). All three go through whatever EMAIL_BACKEND is configured.

How do I send an email with an attachment in Django?

Create an EmailMessage (or EmailMultiAlternatives), call email.attach(filename, content, mimetype) for in-memory data or email.attach_file(path) for a file on disk, then email.send(). PostStack accepts up to 10 attachments, 10 MB each and 25 MB in total.

Should I use SMTP or the PostStack Python SDK?

SMTP if you want existing Django email code, password resets and admin error mail to work unchanged. The SDK if you need the returned email id, webhooks matched to your own records, templates with variables, or batch sends. Many projects use both: the SMTP backend for framework mail and the SDK for product email.

Does this work with django-allauth, Wagtail and other apps?

Yes. Anything that sends through django.core.mail uses your EMAIL_BACKEND, so it goes through PostStack once the settings are in place.

How do I find out that an email from Django bounced?

Subscribe a webhook endpoint to email.bounced (and email.complained), verify the X-PostStack-Signature header against request.body in a csrf_exempt view, and update your records by email_id or recipient. PostStack suppresses hard-bounced and complaining addresses automatically, so later sends to them are not attempted.

Does it work with async views (ASGI)?

Django’s mail functions are synchronous. In an async def view, wrap the call with asgiref.sync.sync_to_async, use AsyncPostStack from the SDK, or — better — hand the send to a background task.

Reference#

The docs this guide builds on:

  • SDKs — every official client, its retries and idempotency keys
  • SMTP relay — hosts, ports, TLS modes and the X-PostStack-* headers
  • Emails API — every send field, attachments, scheduling and batch sends
  • Templates — stored templates with variables
  • Webhooks — event types, payloads and signature verification
  • Suppressions — what happens to an address after a hard bounce or complaint

Ready to send from Django?

Create a free account and an API key — no card needed.

Get started free

Next steps

Was this page helpful?