Metadata-Version: 2.4
Name: poulpinou
Version: 1.1.0.dev0
Summary: Envoi de mails Django asynchrone via Celery, backend drop-in, IHM d'admin pour la file d'attente.
Author: stationmyr
License: MIT
Project-URL: Homepage, https://git.stationmyr.net/Gilles/poulpinou-django
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=4.2
Requires-Dist: celery>=5.3
Requires-Dist: Jinja2>=3.1
Provides-Extra: beat
Requires-Dist: django-celery-beat>=2.5; extra == "beat"
Provides-Extra: api
Requires-Dist: djangorestframework>=3.14; extra == "api"
Provides-Extra: bigb
Requires-Dist: bigb-django>=1.2; extra == "bigb"
Provides-Extra: dev
Requires-Dist: django-celery-beat>=2.5; extra == "dev"
Requires-Dist: djangorestframework>=3.14; extra == "dev"
Requires-Dist: bigb-django>=1.2; extra == "dev"
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-django>=4.8; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Provides-Extra: docs
Requires-Dist: sphinx>=7; extra == "docs"
Requires-Dist: sphinx-rtd-theme>=2; extra == "docs"
Requires-Dist: myst-parser>=3; extra == "docs"
Dynamic: license-file

<img src="docs/logo.png" alt="" width="160">

# poulpinou

[![CI](https://git.stationmyr.net/Gilles/poulpinou-django/badges/workflows/ci.yml/badge.svg?branch=master)](https://git.stationmyr.net/Gilles/poulpinou-django/actions?workflow=ci.yml)
[![Coverage](https://reports.stationmyr.net/Gilles/poulpinou-django/branch/master/badge/cobertura.svg?aggregate=1)](https://reports.stationmyr.net/Gilles/poulpinou-django/branch/master/cobertura)
[![Tests](https://reports.stationmyr.net/Gilles/poulpinou-django/branch/master/badge/junit.svg?aggregate=1)](https://reports.stationmyr.net/Gilles/poulpinou-django/branch/master/junit)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Asynchronous Django mail sending, via Celery, **without touching existing
code**. An app that already uses `django.core.mail.send_mail` or
`EmailMessage` keeps working exactly the same: poulpinou installs by
swapping `EMAIL_BACKEND`, queues mails in the database, then actually
sends them in the background (with rate limiting and retries) through
Django's own mail system (SMTP, or whatever backend the project already
uses).

## Why

- Calling code (`send_mail(...)`, `EmailMultiAlternatives(...).send()`,
  Django forms, etc.) needs **no change at all**.
- The project's existing SMTP configuration (`EMAIL_HOST`, `EMAIL_PORT`,
  `EMAIL_HOST_USER`, ...) **stays unchanged** — poulpinou reuses it for
  actual delivery.
- A developer integrating poulpinou only adds poulpinou's own
  configuration (`INSTALLED_APPS`, `EMAIL_BACKEND`, a few optional
  `POULPINOU_*` settings).

## Installation

```bash
pip install poulpinou
# or, to get the send-frequency configuration UI:
pip install "poulpinou[beat]"
# or, to get the mail-template REST API (see below):
pip install "poulpinou[api]"
```

```python
# settings.py
INSTALLED_APPS = [
    ...,
    "django_celery_beat",  # optional but recommended, see below
    "poulpinou",
]

# The only change to the existing mail config: the backend.
# EMAIL_HOST / EMAIL_PORT / EMAIL_HOST_USER / ... don't change.
EMAIL_BACKEND = "poulpinou.backend.PoulpinouEmailBackend"
```

```bash
python manage.py migrate
```

Then start a Celery worker as usual (poulpinou uses `@shared_task`, so
the project's own already-configured Celery app is enough):

```bash
celery -A myproject worker -l info
celery -A myproject beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
```

That's it: no other code to write.

## How it works

1. `PoulpinouEmailBackend.send_messages()` receives the `EmailMessage`
   objects Django normally builds, and stores them as-is (subject,
   text/HTML body, recipients, attachments, headers) in the
   `poulpinou.Mail` model, status `pending`. That's the only thing that
   happens synchronously — no mail is ever sent "inline".
2. The periodic task `poulpinou.tasks.dispatch_pending_mails` (scheduled
   via django-celery-beat, see below) picks up a batch of `pending`
   mails and hands them to `send_one`.
3. `send_one` rebuilds the original `EmailMessage` and sends it via
   `django.core.mail.get_connection(backend=POULPINOU_EMAIL_BACKEND)` —
   Django's standard SMTP backend by default, with the project's SMTP
   config untouched. On failure: retry with backoff, up to
   `POULPINOU_MAX_RETRIES`, then move to status `failed`.

## Admin UI (Django admin)

- **Queue**: `Poulpinou > Mails en file d'attente` lists every mail with
  its status (`pending` / `sending` / `sent` / `failed`), a counter
  banner at the top of the list, filters by status/date, search by
  subject/recipient.
- **Errors**: failed mails show `last_error` and the retry count; a
  "Remettre en attente" (requeue) action to resend them.
- **Send frequency**: if `django-celery-beat` is installed, poulpinou
  automatically creates, on the first `migrate`, a periodic task
  `poulpinou: dispatch pending mails` (every `POULPINOU_SEND_INTERVAL_SECONDS`
  seconds, 60 by default). It's then editable directly from `Periodic
  tasks` in the Django admin (interval or cron expression) — **no code
  redeploy needed to change the frequency**.
  Without `django-celery-beat`, the task has to be wired into
  `CELERY_BEAT_SCHEDULE` by hand.

## Mail templates

A second, independent feature: editable mail templates, rendered with a
sandboxed Jinja2 (`{{ var }}`, `{% if %}`, `{% for %}`), on top of the
same queue/delivery pipeline above.

1. **Declare a mail type** (static, in code -- typically from your
   app's `AppConfig.ready()`):

   ```python
   from poulpinou import registry

   registry.register_mail_type(
       key="order_confirmation",
       label="Order confirmation",
       variables=[
           registry.Variable("customer_name", required=True),
           registry.Variable("order_lines", many=True),  # looped with {% for %}
       ],
       default_subject="Your order #{{ order_id }}",
       default_body_html=(
           "<p>Hi {{ customer_name }},</p>"
           "<ul>{% for line in order_lines %}<li>{{ line }}</li>{% endfor %}</ul>"
       ),
   )

   # Available to every mail type's context, not just this one:
   registry.register_global_variable("site_name", "My Site")
   registry.register_global_variable("year", lambda: timezone.now().year)
   ```

2. **The actual content lives in the DB** (`poulpinou.models.MailTemplate`),
   seeded from the registry's `default_subject`/`default_body_html` the
   first time a key is loaded -- editable afterwards without a deploy,
   either directly or through the optional REST API below.

3. **Send one**:

   ```python
   from poulpinou.sending import send_mail_type

   send_mail_type(
       "order_confirmation",
       "customer@example.com",
       {"customer_name": "Alice", "order_lines": ["Widget x2", "Gadget x1"]},
   )
   ```

   Goes through `django.core.mail` as usual, so it's queued by
   `PoulpinouEmailBackend` exactly like any other mail if that's the
   project's configured `EMAIL_BACKEND`.

### Security: why Jinja2 here is safe to expose to template editors

Templates are expected to be authored by trusted staff through an
editor/API, not by the public -- but rendering still runs through
`jinja2.sandbox.ImmutableSandboxedEnvironment`, not a plain `Environment`:

- No `Loader` is ever attached (templates only ever come from a
  `MailTemplate` row's own text via `from_string()`), so `{% include %}`/
  `{% import %}` can't reach the filesystem.
- Attribute access to anything starting with `_` (`__class__`,
  `__globals__`, ...) and known-unsafe internals is blocked
  (`SecurityError`), and objects passed in the context can't be mutated
  from inside a template.
- An unknown or removed variable renders blank (including inside a
  `{% for %}`, which iterates over nothing) instead of raising -- a
  template referencing a variable nobody passes doesn't break mail
  sending.
- The HTML body is autoescaped (a variable's own `&`/`<`/`>` are
  escaped automatically); to insert deliberate HTML built by the
  calling code, wrap it in `markupsafe.Markup(...)` before passing it as
  context. The subject line is **not** autoescaped (it's plain text,
  not HTML).
- Still pass only plain data in the context (strings, numbers, lists,
  dicts) -- the sandbox protects against reaching outside what's given,
  not against a live ORM object's own public methods/properties being
  called from inside a template.

### REST API (optional, `pip install "poulpinou[api]"`)

Wire it into the host project's own `urls.py`:

```python
urlpatterns = [
    ...,
    path("poulpinou/", include("poulpinou.api.urls")),
]
```

| Method | URL | Role |
|---|---|---|
| `GET` | `/mail-templates/` | Every registered mail type + whether it's been customized |
| `GET`/`PATCH` | `/mail-templates/<key>/` | Read/edit a template's `subject`/`body_html` |
| `POST` | `/mail-templates/<key>/reset/` | Revert to the registry's own defaults |
| `POST` | `/mail-templates/<key>/images/` | Upload an inline image, returns its public URL |
| `POST` | `/mail-templates/<key>/attachments/` | Upload a file attached to every mail sent from this template |
| `DELETE` | `/mail-template-attachments/<id>/` | Remove an attachment |

A `PATCH` with a malformed template (bad Jinja2 syntax) is rejected
with `400` rather than saved. Permission is controlled by
`POULPINOU_MAIL_TEMPLATE_PERMISSION_CLASSES` (see below) -- defaults to
`IsAdminUser`.

## Settings (`settings.py`, all optional)

| Setting | Default | Role |
|---|---|---|
| `POULPINOU_EMAIL_BACKEND` | `django.core.mail.backends.smtp.EmailBackend` | Django backend used for *actual* delivery |
| `POULPINOU_BATCH_SIZE` | `10` | Number of mails processed per `dispatch_pending_mails` run |
| `POULPINOU_RATE_LIMIT` | `"10/m"` | Celery `rate_limit` of the send task |
| `POULPINOU_MAX_RETRIES` | `5` | Number of attempts before moving to `failed` |
| `POULPINOU_RETRY_DELAY` | `1800` (30 min) | Delay between two attempts, in seconds |
| `POULPINOU_SEND_INTERVAL_SECONDS` | `60` | Default interval for the periodic task created on the first `migrate` (editable afterwards from the admin) |
| `POULPINOU_MAIL_TEMPLATE_PERMISSION_CLASSES` | `["rest_framework.permissions.IsAdminUser"]` | DRF permission classes guarding the mail-template API (see above) |

These settings are read once when the Celery process starts (they feed
`@shared_task` decorators): changing them requires restarting the
workers, except for the send frequency, which can be controlled live
from the admin once `django-celery-beat` is installed.

## Known limitations

- Only "classic" attachments (`(name, content, mimetype)`, the vast
  majority of cases) are queued; an attachment passed as a raw MIME
  object is skipped with a logged warning.
- The connection (`get_connection`) is opened per sent mail — no SMTP
  connection pooling across mails in the same batch.

## Development

Tooling aligned with the other Python modules (e.g. `vieuxhibou-django`):
`tox` runs tests, lint and checks in isolated environments, `make` wraps
`tox` for the common commands.

```bash
make dev            # venv + pip install -e ".[dev]"
make test           # pytest, via tox
make lint           # pylint (+ pylint-django)
make format-check   # autopep8 --diff (CI)
make format         # autopep8 --in-place (local)
make bandit         # static security analysis
make tox            # all tox environments at once
```

## License

MIT, see [LICENSE](LICENSE).
