Metadata-Version: 2.4
Name: poulpinou
Version: 1.0.0
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.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=4.2
Requires-Dist: celery>=5.3
Provides-Extra: beat
Requires-Dist: django-celery-beat>=2.5; extra == "beat"
Provides-Extra: dev
Requires-Dist: django-celery-beat>=2.5; extra == "dev"
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-django>=4.8; 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)
[![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]"
```

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

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

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