poulpinou (0.0.0.dev0)
Installation
pip install --index-url poulpinouAbout this package
Envoi de mails Django asynchrone via Celery, backend drop-in, IHM d'admin pour la file d'attente.
poulpinou
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 optionalPOULPINOU_*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
PoulpinouEmailBackend.send_messages()receives theEmailMessageobjects Django normally builds, and stores them as-is (subject, text/HTML body, recipients, attachments, headers) in thepoulpinou.Mailmodel, statuspending. That's the only thing that happens synchronously — no mail is ever sent "inline".- The periodic task
poulpinou.tasks.dispatch_pending_mails(scheduled via django-celery-beat, see below) picks up a batch ofpendingmails and hands them tosend_one. send_onerebuilds the originalEmailMessageand sends it viadjango.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 toPOULPINOU_MAX_RETRIES, then move to statusfailed.
Admin UI (Django admin)
- Queue:
Poulpinou > Mails en file d'attentelists 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_errorand the retry count; a "Remettre en attente" (requeue) action to resend them. - Send frequency: if
django-celery-beatis installed, poulpinou automatically creates, on the firstmigrate, a periodic taskpoulpinou: dispatch pending mails(everyPOULPINOU_SEND_INTERVAL_SECONDSseconds, 60 by default). It's then editable directly fromPeriodic tasksin the Django admin (interval or cron expression) — no code redeploy needed to change the frequency. Withoutdjango-celery-beat, the task has to be wired intoCELERY_BEAT_SCHEDULEby 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.
-
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) -
The actual content lives in the DB (
poulpinou.models.MailTemplate), seeded from the registry'sdefault_subject/default_body_htmlthe first time a key is loaded -- editable afterwards without a deploy, either directly or through the optional REST API below. -
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.mailas usual, so it's queued byPoulpinouEmailBackendexactly like any other mail if that's the project's configuredEMAIL_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
Loaderis ever attached (templates only ever come from aMailTemplaterow's own text viafrom_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 inmarkupsafe.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.