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

  1. 01

    Create a key

    https://emails.sh/dashboard issues a key starting with esh_.

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

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

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

Install
pip install emailssh
settings.py
import 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)

The email backend
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 error

Sending from a view

core/views.py

Sending from a view
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

The template
<!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

01

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.

02

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.

03

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.

04

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.