Framework guide
Send email from Python and Django
Why use this
There is no Python SDK, and you do not need one: sending is a single JSON POST to https://api.yourmail.dev/v1/emails, and there is no SMTP server to configure. This guide wraps that call in a small client with requests, then puts it behind a real Django view.
For example
Someone signs up through your Django app and gets a welcome email before the response returns.
1. Install
pip install requests2. A small client
Authentication is a Bearer API key. Pass it in rather than hardcoding it, and keep the explicit timeout: without one, requests will wait on a stalled connection indefinitely. Every error comes back as {"error": {"type", "message", "docs_url"}}, so the client raises one exception carrying the status, the type and any Retry-After.
# yourmail.py
import requests
API_URL = "https://api.yourmail.dev/v1/emails"
class YourMailError(Exception):
def __init__(self, status, type, message, retry_after=None):
super().__init__(message)
self.status = status
self.type = type
self.retry_after = retry_after
@property
def retryable(self):
# 5xx is our side failing: worth another attempt. A rate_limited 429 is
# too, but only when Retry-After is short; the daily caps answer with
# the seconds until UTC midnight. Everything else is permanent.
if self.status >= 500:
return True
return (
self.type == "rate_limited"
and self.retry_after is not None
and self.retry_after <= 60
)
def send_email(email, *, api_key):
response = requests.post(
API_URL,
json=email,
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
if response.ok:
return response.json()["id"]
try:
error = response.json()["error"]
except (ValueError, KeyError):
error = {"type": None, "message": response.text}
retry_after = response.headers.get("Retry-After")
raise YourMailError(
response.status_code,
error.get("type"),
error.get("message"),
int(retry_after) if retry_after else None,
)3. The shortest working send
Supplying both html and text is worth the extra line: some clients prefer the plain-text part, and its absence is a mild spam signal.
# send.py
import os
from yourmail import send_email
email_id = send_email(
{
"from": "Acme <hello@mail.acme.com>",
"to": "customer@example.com",
"subject": "Welcome aboard",
"html": "<p>Thanks for signing up.</p>",
"text": "Thanks for signing up.",
},
api_key=os.environ["YOURMAIL_API_KEY"],
)
print("queued", email_id)Run it with YOURMAIL_API_KEY=yourmail_… python send.py. A success returns {"id": "…"}. The from address must be on a domain you have verified; until you have one, send from no-reply@yourmail.dev instead, which delivers to your own inbox in test mode.
4. A Django view
Read the key from the environment once, in settings. It is a server-side secret: call the API only from views, management commands and workers, never from JavaScript you ship to the browser.
# settings.py
import os
YOURMAIL_API_KEY = os.environ["YOURMAIL_API_KEY"]
YOURMAIL_FROM = "Acme <hello@mail.acme.com>"# accounts/views.py
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .forms import SignupForm
from .yourmail import YourMailError, send_email
@require_POST
def signup(request):
form = SignupForm(request.POST)
if not form.is_valid():
return JsonResponse({"errors": form.errors}, status=400)
user = form.save()
try:
email_id = send_email(
{
"from": settings.YOURMAIL_FROM,
"to": user.email,
"subject": "Welcome aboard",
"html": "<p>Thanks for signing up.</p>",
"text": "Thanks for signing up.",
# Same key on every attempt: a retried request returns the
# original id instead of mailing the customer twice.
"idempotency_key": f"welcome:{user.pk}",
"tags": [{"name": "type", "value": "welcome"}],
},
api_key=settings.YOURMAIL_API_KEY,
)
except YourMailError as err:
if not err.retryable:
# Bad address, unverified domain, suppressed recipient, quota.
# Retrying this unchanged fails identically.
return JsonResponse({"error": str(err)}, status=422)
return JsonResponse({"error": "Try again shortly."}, status=503)
return JsonResponse({"id": email_id})The body uses idempotency_key because the API accepts the snake_case spellings reply_to, idempotency_key and scheduled_at alongside the camelCase ones, so a Python dict can stay idiomatic. tags is an array of {"name", "value"} pairs, not a dict; a dict is a 400. Tags show up when you filter your message history later.
5. Retry by error type, not attempt count
A 400, 401, 403 or 422 is configuration to fix: an invalid field, a bad key, an unverified domain, a suppressed recipient. It will fail the same way every time. A 5xx, a network timeout, or a 429 rate_limited with a short Retry-After is transient. A 429 quota_exceeded carries no Retry-After at all: it is a ceiling, so surface it rather than sleeping on it.
The other half of a safe retry is the idempotency key. A send is only safe to retry with the same key: without one, a retry after a lost response sends twice. Derive it from something stable, such as the user or order id plus the template name, never a fresh random value per attempt. A repeat with a key already used returns the original message's id instead of sending a second copy.
# Don't do this.
for attempt in range(5):
try:
send_email(email, api_key=settings.YOURMAIL_API_KEY)
break
except Exception:
# A suppressed recipient fails all five times, and a rate limit fails
# harder the more you hammer it. Without an idempotency key, a retry
# after a lost response sends the message twice.
continue# accounts/tasks.py
from celery import shared_task
from django.conf import settings
from requests import RequestException
from .yourmail import YourMailError, send_email
@shared_task(bind=True, max_retries=5)
def send_welcome_email(self, user_id, address):
try:
send_email(
{
"from": settings.YOURMAIL_FROM,
"to": address,
"subject": "Welcome aboard",
"html": "<p>Thanks for signing up.</p>",
# Derived from the user, not generated per attempt, so every
# retry carries the same key.
"idempotency_key": f"welcome:{user_id}",
},
api_key=settings.YOURMAIL_API_KEY,
)
except YourMailError as err:
if not err.retryable:
raise # Permanent: fail the task, don't retry it.
raise self.retry(exc=err, countdown=err.retry_after or 30)
except RequestException as err:
# Timeout or connection failure: transient.
raise self.retry(exc=err, countdown=30)The full list of error types, and which are safe to retry, is in the errors reference.
Sending a lot at once
Looping over send_email() for a few hundred recipients works but is slow and wasteful. Use the batch endpoint instead: up to 100 messages per request, each with its own recipients and body.
Switching from another provider?
- AWS SES: YourMail vs AWS SES
- Resend: migration guide · YourMail vs Resend
- Postmark: migration guide · YourMail vs Postmark
- SendGrid: migration guide · YourMail vs SendGrid
Last updated