Send email with Django
A ten-line service module, called after commit — not from a signal mid-transaction.
Facts verified 2026-07-17 — corrections: hello@emailfast.dev
In Django, put the send behind a tiny service module and call it from views or tasks via transaction.on_commit — never straight from a post_save signal, where the row you're emailing about can still roll back. The API is one JSON POST, so the standard library is enough and nothing new lands in requirements.txt.
# yourapp/emailer.py — stdlib only
import json
import urllib.request
from django.conf import settings
def send_email(to, subject, html, data=None, idempotency_key=None):
req = urllib.request.Request(
"https://api.emailfast.dev/v1/emails",
data=json.dumps({
"to": to, "subject": subject, "html": html,
"data": data or {}, "idempotency_key": idempotency_key,
}).encode(),
headers={
"Authorization": f"Bearer {settings.EMAILFAST_API_KEY}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=10) as res:
return json.load(res)# settings.py
import os
EMAILFAST_API_KEY = os.environ["EMAILFAST_API_KEY"] # ef_sandbox_... while testing# views.py — send only after the row is really there
from django.db import transaction
from django.shortcuts import redirect
from .emailer import send_email
def signup(request):
user = create_user(request) # your existing logic
transaction.on_commit(lambda: send_email(
to=user.email,
subject="Confirm your account",
html="<p>Hi {{name}}, confirm your address here.</p>",
data={"name": user.first_name},
idempotency_key=f"confirm-{user.pk}", # a retried task can't double-send
))
return redirect("thanks")a 202 from the API means the send is committed to a durable, partitioned outbox before we answer — a crash can't lose it, and a retry with the same idempotency key can't double-send.
Gotchas
- Signals fire before the transaction commits:
post_saveplus email means confirmations for rows that never existed.transaction.on_commitwaits for the real commit. - Keep the key in
settings, sourced from the environment — failing at boot on a missing variable beats failing on the first signup. - One transactional message per request is fine inline; for campaigns use a task queue or POST up to 500 messages to
/v1/emails/batch. idempotency_key=f"confirm-{user.pk}"makes a double-fired signal or retried Celery task a replay, not a duplicate.
Next
- Python SDK quickstart — the typed client
- The email API — batch, scheduling, message timelines
- Plain Python recipe
- All docs