# Send email with Django

> Send email from Django with Email Fast: a small service module called after commit, stdlib HTTP against the REST API, env-var config, and idempotency.

Canonical: https://emailfast.dev/send-email-with/django

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

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

```python
# settings.py
import os
EMAILFAST_API_KEY = os.environ["EMAILFAST_API_KEY"]  # ef_sandbox_... while testing
```

```python
# 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_save` plus email means confirmations for rows that never existed. `transaction.on_commit` waits 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](/docs/quickstart-python) — the typed client
- [The email API](/features/email-api) — batch, scheduling, message timelines
- [Plain Python recipe](/send-email-with/python)
- [All docs](/docs)
