poulpinou (1.1.0)

Published 2026-10-25 22:31:28 +00:00 by Gilles in Gilles/poulpinou-django

Installation

pip install --index-url  poulpinou

About this package

Envoi de mails Django asynchrone via Celery, backend drop-in, IHM d'admin pour la file d'attente.

poulpinou

CI Coverage Tests License: MIT

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

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]"
# 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"
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):

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()):

    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:

    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:

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.

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.

Requirements

Requires Python: >=3.10
Details
PyPI
2026-10-25 22:31:28 +00:00
35
2026-10-27 03:35:23 +00:00
stationmyr
MIT
51 KiB
Assets (2)
Versions (6) View all
0.0.0.dev0 2026-10-30
1.1.0.dev0 2026-10-30
1.2.0 2026-10-26
1.1.0 2026-10-25
0.1.0.dev0 2026-10-24