feat: add scheduler and mail outbox

SMTP adapter with TLS and a fake, outbox with retries, backoff, rate limit and restart safety, scheduler tick with job-run records started by gunicorn, and a send_test_mail command.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
2026-10-18 09:18:42 +02:00
co-authored by Claude Sonnet 5.5
parent 0dc079a8ac
commit 20510a73e5
40 changed files with 1315 additions and 6 deletions
+18
View File
@@ -17,6 +17,12 @@ All configuration comes from environment variables, validated at startup. If any
| `OIDC_CLIENT_ID` | yes | | Client ID of the provider's application |
| `OIDC_CLIENT_SECRET` | yes | | Client secret of the provider's application |
| `PUBLIC_BASE_URL` | yes | | Address users reach the portal at, scheme and host only. The provider's redirect address must be this plus `/auth/callback` |
| `SMTP_HOST` | yes | | Host name of the mail server (no port, no URL) |
| `SMTP_SECURITY` | no | `starttls` | `starttls` (port 587) or `ssl` (port 465); mail is never sent unencrypted and the server certificate is verified |
| `SMTP_PORT` | no | 587 or 465 | Overrides the port |
| `SMTP_USERNAME` | yes | | Mailbox user name |
| `SMTP_PASSWORD` | yes | | Mailbox password |
| `MAIL_FROM` | yes | | Sender address of every e-mail, optionally with a display name |
| `LOG_LEVEL` | no | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
| `LDAP_SERVER` | yes | | Directory server host name (optionally `:port`); LDAPS on 636 is always used and the name must be in the server's certificate |
| `LDAP_USER_OU_DN` | yes | | Distinguished name of the OU whose users the portal may read and change |
@@ -61,6 +67,18 @@ Text is German (formal and informal) and English. The English text is the messag
Theme, language and form of address are stored per device in the cookies `pm_theme`, `pm_lang` and `pm_form`; nothing is stored on the server.
### Scheduler and e-mail
One scheduler thread runs inside the single gunicorn worker and ticks every 30 seconds (`docker/gunicorn.conf.py`). Each tick runs the jobs in `portal/scheduler/services.py`; every run is recorded in `JobRun`. Other code never talks to the mail server: it calls `portal.mailer.services.enqueue(template, recipient, context)` inside its own transaction, and the `mail.flush` job sends from the outbox with retries (1, 5, 15 and 60 minutes between attempts, 5 attempts), at most 20 mails a minute. A mail that was being sent when the process stopped is marked failed and is not sent again.
Mail templates live in `portal/templates/mail/<name>/subject.txt` and `body.txt`. They are rendered when queued, in the portal-wide language (`MAIL_LANGUAGE`, German formal), and the text is removed from the database once the mail is sent.
To check the SMTP settings, queue and send a test mail from the running container:
```sh
docker compose exec portal python manage.py send_test_mail someone@example.test
```
### Directory adapter against the test OU
`uv sync` builds `python-ldap`, which needs the OpenLDAP and SASL development libraries (the Docker image installs them). The automated tests use an in-memory fake and a stand-in for the `ldap` module, so they run anywhere.