Sending transactional email from Django, done right
Django's built-in send_mail() goes through an SMTP backend by default, which works but gives you none of the delivery visibility an API-based provider offers: no webhook events, no structured bounce handling, no per-message status you can query later. Routing transactional sends through Envello's HTTP API instead is a small, contained change.
The plain HTTP version
This works in any Django view or task runner without adding a dependency, using the requests library most Django projects already have. The essentials:
- import requests
- response = requests.post('https://api.envello.dev/emails', headers={'Authorization': f'Bearer {settings.ENVELLO_API_KEY}'}, json={'from': 'Acme <[email protected]>', 'to': user.email, 'subject': 'Welcome!', 'html': rendered_html})
- response.raise_for_status() to surface a failed send instead of silently swallowing it
Rendering the email body
Django's own template engine (render_to_string with an .html template) works fine for the html field, no extra tooling needed. If you're already using Django templates for your web pages, reuse that same rendering path for transactional email content rather than introducing a second templating system.
Create templates in a templates/emails/ directory. Use render_to_string('emails/password_reset.html', context) to produce the HTML string. Pass it directly as the html field in the API request. For a plain-text fallback, render a matching .txt template and pass it as the text field.
Configuration
Store the API key in Django settings, loaded from an environment variable. Never hardcode credentials.
- In settings.py: ENVELLO_API_KEY = os.environ['ENVELLO_API_KEY']
- ENVELLO_FROM = 'YourApp <[email protected]>'
- ENVELLO_BASE_URL = 'https://api.envello.dev'
- Access these anywhere with from django.conf import settings
Error handling
The API returns standard HTTP status codes. Handle them explicitly rather than letting requests.raise_for_status() catch everything uniformly.
- 2xx: message accepted for delivery (not yet delivered). Log the message ID from the response body.
- 400: validation error (bad address, unverified domain, suppressed recipient). Don't retry. Log the response body and surface the error to the caller.
- 401/403: invalid or expired API key. Don't retry. Log and alert.
- 429: rate limit hit. Read the Retry-After header, wait, then retry. In practice, this is rare for transactional sends.
- 5xx: server error. Retry once with a 1-second delay.
Async sending with Celery
For anything triggered by a web request (password reset, signup confirmation), the API call shouldn't block Django's response to the user. Use Celery to run the send in a background task.
- Create a task: @shared_task(bind=True, max_retries=3) def send_transactional_email(self, to, subject, html):
- Call the send function inside the task, with a try/except that retries on transient failures (5xx, network errors)
- Use self.retry(exc=exc, countdown=60) for automatic exponential backoff
- In your view: send_transactional_email.delay(user.email, 'Reset your password', html)
- The view returns immediately; the email sends in the background worker
Without Celery: Django-Q or simple threading
If your project doesn't use Celery, Django-Q2 or django-rq are lighter alternatives with the same pattern: define a task function, call it asynchronously from the view.
For the simplest case (low volume, no retry requirements), Python's concurrent.futures.ThreadPoolExecutor works: submit the send function to a thread pool from the view. The trade-off is that tasks don't survive a process restart, so a failed send is lost rather than retried.
Creating a reusable email service
Wrap the send logic in a service module so every call site in your Django project uses the same configuration, error handling, and logging.
- Create an emails.py (or emails/service.py) module in your app
- Define send_email(to, subject, html, text=None) that handles the HTTP call and error mapping
- Log every attempt: message_id, recipient domain (not full address), status code
- Return a result dataclass with success/failure status and message_id
- Import and call this from views, signals, management commands, or Celery tasks
If you want an SDK instead of raw requests
A Python SDK exists (pip install envello, source at github.com/Aktai-ltd/envello-python) if you want the typed client and built-in request validation. For most Django integrations, though, the plain requests call above is simple enough that adding another dependency isn't necessary.
Webhook handling
Set up a Django view to receive delivery event webhooks from Envello.
- Create a view at /webhooks/envello/ that accepts POST requests
- Exempt it from CSRF protection: @csrf_exempt (webhooks don't carry Django's CSRF token)
- Read the raw request body (request.body) and the X-Envello-Signature header
- Verify the HMAC-SHA256 signature using hmac.compare_digest to prevent timing attacks
- Parse the JSON payload and handle event types: delivered (log), bounced (suppress address), complained (suppress)
- Return HttpResponse(status=200) immediately; process events asynchronously if they involve database writes
Suppression in your Django models
Track bounced and complained addresses in your database so your application doesn't attempt sends that will fail.
- Create an EmailSuppression model with fields: email (unique), reason ('bounced' or 'complained'), created_at
- Before sending, check: if EmailSuppression.objects.filter(email=to).exists(), skip the send and log why
- In the webhook handler, create a suppression record on bounce or complaint events
- Add an admin view so support staff can see why a user isn't receiving email
Testing
For unit tests, mock the requests.post call with unittest.mock.patch and verify the request payload: correct recipient, subject, and rendered HTML. Don't test Envello's delivery infrastructure; test that your application constructs the right message.
Use Envello's test mode for integration tests where you want to validate the full flow (API call accepted, response parsed) without delivering to real inboxes.
Django's override_settings decorator makes it easy to swap in a test API key or toggle test mode per test case.
Where this fits in a Django project
Wrap the send call in a small function in a shared module (emails.py or similar), call it from signals, views, or a Celery task depending on whether the send needs to happen synchronously. For anything triggered by a web request (password reset, signup confirmation), a background task queue is worth it so a slow API response doesn't hold up the HTTP response to your user.
Check your domain's SPF, DKIM, and DMARC records
Paste in a domain and see what's missing, plus the exact DNS records to fix it. Free, no account needed.
Check your domain →