Metadata-Version: 2.5
Name: posthaste-django
Version: 0.1.0
Summary: Django email backend for the Posthaste transactional email API.
Project-URL: Homepage, https://posthastemail.dev
Project-URL: Documentation, https://posthastemail.dev/docs/django
Project-URL: Source, https://github.com/posthastemail/posthaste-django
License-Expression: MIT
License-File: LICENSE
Keywords: django,email,email-backend,posthaste,transactional-email
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Communications :: Email
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: django>=4.2
Requires-Dist: posthaste-email>=0.1.0
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Description-Content-Type: text/markdown

# posthaste-django

The Django email backend for the [Posthaste](https://posthastemail.dev) transactional email API.

**Change one setting.** Every `send_mail()`, `EmailMessage.send()`,
`EmailMultiAlternatives` and `mail_admins()` call you already have keeps working, unchanged.

```bash
pip install posthaste-django
```

```python
# settings.py
EMAIL_BACKEND = "posthaste_django.EmailBackend"
POSTHASTE_API_KEY = os.environ["POSTHASTE_API_KEY"]
```

That is the whole migration. 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 it depends on `posthaste-email`, which is the
> Python SDK. The SDK's **import** name is `posthaste`; its distribution name differs because PyPI's
> `posthaste` was claimed in 2013 by an unrelated OpenStack tool.

---

## 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 takes its configuration from `OPTIONS`:

```python
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:

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

A missing key is an `ImproperlyConfigured` at construction, not a 401 on the first password reset of
your deployment.

```python
POSTHASTE = {
    "API_KEY": os.environ["POSTHASTE_API_KEY"],
    "BASE_URL": "https://api.posthastemail.dev",   # point at your own deployment
    "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, and
`repr()` prints `ph_live_***redacted***` instead. Both spellings of the setting are hidden by
Django's own `SafeExceptionReporterFilter`, so the yellow debug page cannot publish it either.

## What maps

| Django                               | Posthaste                                  |
| ------------------------------------ | ------------------------------------------ |
| `from_email`                         | `from` — display name and all              |
| `to`, `cc`, `bcc`                    | `to`, `cc`, `bcc`                          |
| `subject`, `body`                    | `subject`, `text`                          |
| `content_subtype = "html"`           | `html`                                     |
| `attach_alternative(…, "text/html")` | `html`                                     |
| `reply_to` (a list)                  | `replyTo`, as one header value             |
| `headers={…}`                        | `headers`                                  |
| `headers={"Reply-To": …}`            | `replyTo` — the header wins, as in Django  |
| `attach(name, content, mimetype)`    | one attachment, base64 on the wire         |
| a `MIMEPart` with a `Content-ID`     | an attachment with `disposition` and `cid` |

**Recipients lose their display names.** The API addresses recipients by bare address, so
`to=["Ada Lovelace <ada@example.com>"]` is sent as `ada@example.com`. The address is unchanged; 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. Nothing is visible to anybody else: 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**:

- an alternative that is not `text/html` or `text/plain` — `text/x-amp-html` and `text/watch-html`
  are deliberate choices by whoever added them, and the API carries two representations
- two HTML representations, where there is no correct way to choose
- a header the platform owns and DKIM-signs — `From`, `To`, `Subject`, `Message-ID`,
  `List-Unsubscribe`, `Feedback-ID`, `DKIM-Signature`, `ARC-*` and the rest. The error names the
  field to use instead.
- a header value containing a line break, which is the injection vector
- an attachment with no filename, or a multipart MIME part
- `content_subtype` that is neither `plain` nor `html`

`MessageRefused.status` is `0` — 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 `POST /v1/emails/batch` 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. `send_mass_mail()` therefore stays one request, which is the point of it.

One message goes to `POST /v1/emails` instead. A single send is auto-retried 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`

**`False` (the 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.

**`True`** — nothing raises. Refusals are logged at ERROR through the `posthaste_django` logger, and
the return value is the number that went. It is a count, not a boolean: a caller who reads it can
still tell a partial send from a whole one. Silence was what you asked for; silence in the logs was
not.

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

```python
from posthaste_django import SuppressedError, DomainNotVerifiedError, QuotaExhausted, RateLimited

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("not sent: %s is suppressed (%s)", error.suppression.address, error.suppression.reason)
except DomainNotVerifiedError:
    log.error("publish the DKIM record for the sending domain")
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.
```

Given a **list**, a single exception cannot carry several reasons, so `SendRefused` does:

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

## Posthaste features Django has no field 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:

```python
message = EmailMultiAlternatives(subject, body, from_email, to)
message.posthaste = {
    "stream": "transactional",
    "tags": ["receipt"],
    "metadata": {"order": str(order.id)},
    "idempotency_key": f"receipt-{order.id}",   # also enables the SDK's auto-retry
}
message.send()
```

Anything `posthaste.emails.send()` accepts works here; the accepted names are read off the SDK
rather than restated, so a field the SDK gains becomes settable on the next upgrade. A key neither
knows about is named in a `MessageRefused` rather than silently dropped.

`idempotency_key` is the single most useful thing you can set. Without one, a send is never retried
automatically, because a retry after a lost response sends the email twice.

## Testing your own application

`EmailBackend` takes a `transport`, which is anything satisfying `posthaste.http.Transport` — so a
test can assert on the request body without a socket:

```python
backend = EmailBackend(api_key="ph_test_…", transport=my_stub)
```

Django's own `locmem` backend still works too, and is the right choice when you are testing your
application rather than this one.

## Development

```bash
python -m pytest packages/django-email-backend
```

No install step: `pythonpath` in `pyproject.toml` puts both `src` trees on the path. The suite never
opens a socket — `conftest.py` makes the attempt raise a `BaseException` the SDK's own
`except Exception` cannot swallow — and never names an address at a domain we do not own.
