Send email from Django
Django routes every send, including the built in password reset, through EMAIL_BACKEND. Write one backend class and send_mail, EmailMultiAlternatives, and the auth views all go over the emails.sh API. This page gives you that class and the settings around it.
Setup
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_.
- 02
Read it from the environment
os.environ["EMAILSSH_API_KEY"] in settings.py. Django settings are imported once at startup, so a missing key fails on boot rather than on the first password reset.
- 03
Add the backend
core/email_backend.py below subclasses BaseEmailBackend. Point EMAIL_BACKEND at it and every existing send_mail call routes through emails.sh with no other change.
- 04
Set DEFAULT_FROM_EMAIL
Django uses it whenever a caller omits from_email, including the password reset view. It must be an address on a domain you verified.
pip install emailsshimport os
EMAIL_BACKEND = 'core.email_backend.EmailsshBackend'
EMAILSSH_API_KEY = os.environ['EMAILSSH_API_KEY']
# Used whenever a caller omits from_email, which includes the built in password
# reset view, so it has to be an address on a verified domain.
DEFAULT_FROM_EMAIL = 'Acme <hello@acme.com>'
SERVER_EMAIL = 'Acme errors <errors@acme.com>'The email backend
core/email_backend.py (Django 4.2 or 5.x)
import json
import urllib.error
import urllib.request
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
ENDPOINT = "https://emails.sh/v1/emails"
class EmailsshBackend(BaseEmailBackend):
"""Sends every Django EmailMessage through the emails.sh HTTP API.
Subclassing the base backend means send_mail, EmailMultiAlternatives, the
password reset view, and mail_admins all keep working untouched.
"""
def __init__(self, fail_silently=False, **kwargs):
super().__init__(fail_silently=fail_silently, **kwargs)
self.api_key = getattr(settings, "EMAILSSH_API_KEY", None)
def send_messages(self, email_messages):
if not email_messages:
return 0
sent = 0
for message in email_messages:
try:
self._send(message)
sent += 1
except Exception:
if not self.fail_silently:
raise
return sent
def _send(self, message):
html = None
for content, mimetype in getattr(message, "alternatives", []):
if mimetype == "text/html":
html = content
break
payload = {
"from": message.from_email or settings.DEFAULT_FROM_EMAIL,
"to": list(message.to),
"subject": message.subject,
"text": message.body,
}
if html:
payload["html"] = html
if message.cc:
payload["cc"] = list(message.cc)
if message.bcc:
payload["bcc"] = list(message.bcc)
if message.reply_to:
payload["reply_to"] = message.reply_to[0]
request = urllib.request.Request(
ENDPOINT,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
return json.loads(response.read())
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", "replace")
raise RuntimeError(f"emails.sh returned {error.code}: {body}") from errorSending from a view
core/views.py
from django.core.mail import EmailMultiAlternatives
from django.http import JsonResponse
from django.template.loader import render_to_string
from django.views.decorators.http import require_POST
@require_POST
def signup(request):
email = request.POST.get("email", "")
if "@" not in email:
return JsonResponse({"error": "A valid email is required"}, status=400)
context = {"name": request.POST.get("name") or "there"}
text_body = render_to_string("email/welcome.txt", context)
html_body = render_to_string("email/welcome.html", context)
message = EmailMultiAlternatives(
subject="Welcome to Acme",
body=text_body,
to=[email],
)
message.attach_alternative(html_body, "text/html")
message.send()
return JsonResponse({"ok": True})The template
templates/email/welcome.html
<!doctype html>
<html>
<body style="font-family: system-ui, sans-serif; line-height: 1.5; color: #111">
<p>Hi {{ name }},</p>
<p>Your Acme account is ready.</p>
<p><a href="https://acme.com/login">Sign in</a></p>
<p style="color: #666; font-size: 13px">
You are receiving this because you created an account at acme.com.
</p>
</body>
</html>Worth knowing
A custom backend beats calling the API in your views
Django sends mail you did not write: password reset, mail_admins, and any package that calls send_mail. Only EMAIL_BACKEND catches all of it. Calling the API directly from one view leaves the rest on the console backend.
send() blocks the request
urlopen runs inline in the WSGI worker, so a slow API call is a slow response. Wrap the send in a Celery task, django-q, or a management command run by a worker for anything not on the critical path.
attach_alternative is where the HTML lives
EmailMessage.body is the plain text part. HTML only exists in message.alternatives, which is why the backend above walks that list rather than reading body.
Use the console backend in development
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" in your dev settings prints the message instead of sending, and locmem.EmailBackend in tests fills django.core.mail.outbox for assertions.
Questions
How do I change the email backend in Django?
Set EMAIL_BACKEND in settings.py to the dotted path of a class subclassing BaseEmailBackend, like the one above. Nothing else in your code changes.
Will the built in password reset use this?
Yes. PasswordResetView calls send_mail, which goes through EMAIL_BACKEND, so it routes through emails.sh as soon as the setting points at your class.
Do I need Celery?
Not to send. You need it (or another worker) if you do not want the user waiting on the API round trip during signup or checkout.
Why do my emails arrive as plain text only?
You called send_mail without html_message, or built an EmailMessage without attach_alternative. The HTML part has to exist before the backend can forward it.
The rest of the API
POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.