PosthasteDocsGet an API key

Sending

Django email backend

If your project already sends mail with Django, the whole migration is one setting. Every send_mail(), EmailMessage.send() and mail_admins() call you have stays exactly as it is.

The whole change

# settings.py
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.sendgrid.net"
EMAIL_PORT = 587
EMAIL_HOST_USER = "apikey"
EMAIL_HOST_PASSWORD = os.environ["SENDGRID_KEY"]
EMAIL_USE_TLS = True

# …and several hundred send_mail() calls, elsewhere.
pip install posthaste-django

posthaste-django wraps the Python SDK, which has no third-party dependencies of its own — so this adds two names to your lockfile and brings no transitive supply chain with it. Requires Django 4.2 or newer and Python 3.9 or newer; tested against Django 4.2, 5.2 and 6.1.

The PyPI distribution is posthaste-django and the import is posthaste_django. It depends on posthaste-email, which is the Python SDK — that one’s distribution name differs from its import name because PyPI’s posthaste was claimed in 2013 by an unrelated OpenStack tool.

The from_email address must be on one of your verified domains, exactly as it must for a direct API send.

from django.core.mail import EmailMultiAlternatives

message = EmailMultiAlternatives(
    subject="Invoice 2026-114",
    body="Your invoice is attached.",
    from_email="Acme <[email protected]>",
    to=["[email protected]"],
    cc=["[email protected]"],
    reply_to=["[email protected]"],
    headers={"X-Invoice": "2026-114"},
)
message.attach_alternative("<p>Your invoice is attached.</p>", "text/html")
message.attach_file("invoices/2026-114.pdf")

message.send()   # 1

Django 6.0 and MAILERS

Django 6.0 deprecated EMAIL_BACKEND in favour of MAILERS, and will remove it in Django 7.0. This backend works under both, and under MAILERS it reads its configuration from OPTIONS.

# settings.py — Django 6.0 and newer
MAILERS = {
    "default": {
        "BACKEND": "posthaste_django.EmailBackend",
        "OPTIONS": {"api_key": os.environ["POSTHASTE_API_KEY"]},
    }
}

Configuration

The key is read from the first of these that is set:

WhereWhat
1a keyword argument — OPTIONS under MAILERS, or get_connection(...) before it
2settings.POSTHASTE["API_KEY"]
3settings.POSTHASTE_API_KEY
4$POSTHASTE_API_KEY

A missing key is an ImproperlyConfigured when the backend is constructed — not a 401 on the first password reset of your deployment.

POSTHASTE = {
    "API_KEY": os.environ["POSTHASTE_API_KEY"],
    "BASE_URL": "https://api.posthastemail.dev",  # your own deployment, if self-hosted
    "TIMEOUT": 30.0,        # per ATTEMPT, not per call
    "MAX_RETRIES": 2,       # retries AFTER the first attempt
    "BATCH": True,          # use the batch endpoint for a list of messages
    "DEFAULTS": {"stream": "transactional", "tags": ["django"]},
}

The key is never stored on the backend. It is handed to the SDK client and forgotten; repr() prints ph_live_***redacted*** instead. Both spellings of the setting match Django’s own HIDDEN_SETTINGS pattern, so the yellow debug page cannot publish it either.

What maps

DjangoPosthaste
from_emailfrom — display name and all
to, cc, bccto, cc, bcc
subject, bodysubject, text
content_subtype = "html"html
attach_alternative(…, "text/html")html
reply_to (a list)replyTo, as one header value — several addresses survive
headers={…}headers
headers={"Reply-To": …}replyTo — the header wins, exactly as it does in Django
attach(name, content, mimetype)one attachment, base64 on the wire
a MIMEPart with a Content-IDan attachment with disposition and cid — inline images work

Recipients lose their display names. The API addresses recipients by bare address, so to=["Ada Lovelace <[email protected]>"] is sent as [email protected]. The address is unchanged, and the drop is logged at DEBUG. The sender keeps its display name, which is what an inbox actually shows.

A message with only bcc still sends. The API requires a to, so the first Bcc recipient is promoted into it. Nobody else can tell: the platform fans a send out into one message per recipient, and every copy’s To header is that recipient’s own address whichever list they came from.

What is refused rather than dropped

A message that arrives missing the part its author cared about is worse than one that never left, because only the second gets reported. So these raise MessageRefused before anything is sent:

WhatWhy
an alternative that is not text/html or text/plaintext/x-amp-html and text/watch-html are deliberate choices by whoever added them. The API carries two representations.
two HTML representationsThere is no correct way to choose between them.
a header the platform owns — From, Subject, Message-ID, List-Unsubscribe, Feedback-ID, DKIM-Signature, ARC-*They are written and DKIM-signed by the platform. A caller copy either duplicates a signed header or drifts from it and fails DMARC. The error names the field to use instead.
a header value containing a line breakIt ends the header and begins one of the sender’s choosing — the injection vector. Refused rather than stripped.
an attachment with no filename, or a multipart MIME partThe API needs a filename — it is what the recipient sees — and an attachment is one file with one content type.

MessageRefused.status is 0, which is the SDK’s signal for “nothing on the far side ever answered”. Nothing was sent, and repeating the call cannot change the answer.

Sending a list

send_messages() uses the batch endpoint when it is given more than one message, split to stay inside both of the API’s ceilings — 100 messages and 500 recipients after expansion, counted the way the server counts them. So send_mass_mail() stays one request, which is the point of it.

One message goes to POST /v1/emails instead. A single send is retried automatically when it carries an idempotency key; a batch never is, so routing one message through the batch endpoint would silently give that up. Set BATCH: False to send everything one at a time.

fail_silently

# Raises. The default, and the safe one.
send_mail(subject, body, sender, recipients)

# Never raises. Returns how many went, and logs the rest at ERROR
# through the "posthaste_django" logger.
send_mail(subject, body, sender, recipients, fail_silently=True)
ValueBehaviour
False (default)Anything refused raises. A message this backend can see is unsendable raises before any of the list is sent, so a typo in message three does not leave one and two half-delivered. A message the API refuses raises after the rest went, because it had to be sent to find out.
TrueNothing raises. The return value is how many went — a count, not a boolean, so a caller can still tell a partial send from a whole one.

Silence is not silence in the logs. A refusal swallowed by fail_silently=True is logged at ERROR through the posthaste_django logger. A suppressed recipient, an unverified domain and an exhausted quota all need somebody to act, and fail_silently is almost always about not taking a request down rather than about not wanting to know.

Errors you can act on

A suppressed recipient, an unverified sending domain and an exhausted quota are three different problems with three different fixes. They arrive as three different exception classes — the SDK’s own, so nothing is flattened and .suppression, .check, .findings and .retry_after_seconds survive.

from posthaste_django import (
    SuppressedError, DomainNotVerifiedError, ContentBlockedError,
    RateLimited, QuotaExhausted, MessageRefused,
)

try:
    message.send()

except SuppressedError as error:
    # error.suppression.reason — hard_bounce | complaint | spam_trap | manual | unsubscribe
    # complaint and spam_trap are permanent. Never work around this.
    log.warning("suppressed: %s (%s)", error.suppression.address, error.suppression.reason)

except DomainNotVerifiedError:
    log.error("publish the DKIM record for the sending domain")

except ContentBlockedError as error:
    log.error("refused by %s: %s", error.check, error.findings)

except RateLimited as error:
    retry_in(error.retry_after_seconds)        # transient; it clears on its own

except QuotaExhausted as error:
    alert_billing(error.retry_after_seconds)   # hours or days. Queue it; do not retry in process.

except MessageRefused as error:
    # Nothing was sent. error.status is 0, and error.field names the attribute.
    log.error("cannot send this message: %s", error)

Given a list, a single exception cannot carry several reasons, so SendRefused does. It is raised after the accepted messages were accepted, and sent says how many — retrying the whole list without idempotency keys sends those again.

from posthaste_django import SendRefused

try:
    connection.send_messages(messages)
except SendRefused as error:
    error.sent            # 2 — these ARE sent. Do not resend them blindly.
    for failure in error.failures:
        print(failure.index, type(failure.error).__name__, failure.error.message)

Every one of them is a PosthasteError, so one except catches the lot. Every error type is documented, including which are worth retrying.

The fields Django has no word for

Django’s message has no room for a message stream, a tag, a template or an idempotency key. Set a posthaste dict on the message and it is merged last, over everything derived from the message itself.

message = EmailMultiAlternatives(subject, body, from_email, to)
message.posthaste = {
    "stream": "transactional",
    "tags": ["receipt"],
    "metadata": {"order": str(order.id)},
    "idempotency_key": f"receipt-{order.id}",
}
message.send()

Anything the SDK’s emails.send() accepts works here; the accepted names are read off the SDK rather than restated, so a field it gains becomes settable on the next upgrade. A key neither knows about is named in a MessageRefused rather than silently dropped. Use DEFAULTS in settings for the ones that apply to every message.

When to use the SMTP relay instead

Posthaste runs an SMTP relay, and Django can point at it today with no new package at all — that is a perfectly good answer, and on a normal server with port 587 open outbound it is the simpler one. Reach for this backend when outbound SMTP is blocked (most serverless and PaaS platforms), when you want a typed refusal instead of a reply code to parse, or when you want the fields above.

NextBatch sendUp to 100 messages in one request, each reported by index. One refused item never takes the rest down.