# Send email from Django

Send transactional email from Django with emails.sh. A custom EMAIL_BACKEND so send_mail and password reset keep working over HTTP.

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. **Create a key** https://emails.sh/dashboard issues a key starting with esh_.
1. **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.
1. **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.
1. **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

```bash
pip install emailssh
```

## settings.py

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

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

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


Docs: https://emails.sh/docs.md