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
@@ -0,0 +1,36 @@
---
title: 'Scheduler and mail outbox'
type: 'feature'
ticket: '7'
created: '2026-10-18'
status: 'built'
baseline_revision: '0dc079a'
route: 'full'
route_source: 'auto'
risk: 'medium'
review: 'quick'
review_source: 'pinned'
context:
- '{project-root}/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md'
---
## Intent
**Problem:** Later epics must send e-mail (password change notices, confirmation links, announcements) without blocking requests, losing mail on restart or exceeding the provider's limits, and need a place to run repeating jobs.
**Approach:** `adapters/mail` is the only code that talks SMTP (TLS always, certificate verified), with an in-memory fake. `mailer` owns the outbox; other code calls `enqueue(template, recipient, context)` in its own transaction. `scheduler` owns `JobRun` and one 30-second tick that runs idempotent jobs, started in the gunicorn worker by `docker/gunicorn.conf.py` (AD-11, AD-12, AD-19).
## Decisions (made by the build; the spine left them open)
- Retries: 5 attempts, waits of 1, 5, 15 and 60 minutes. Rate limit: 20 mails a minute. Named constants until the settings registry exists; the IONOS limit is still unknown (spec open question).
- Mails are rendered when queued, in German formal (`MAIL_LANGUAGE`), as plain text only. HTML parts wait for the sanitizer (AD-16).
- The body is removed from the database once the mail is sent or has failed, because it may hold a one-time link.
- Restart safety: a row is claimed (`queued` to `sending`) before sending. A row still `sending` after 10 minutes is marked failed with an unknown outcome and is not sent again: a lost notice is better than a duplicate.
- A refused login is treated as temporary, so a corrected password works on the next retry. A dead server stops the run after three consecutive outages.
- `manage.py send_test_mail <address>` queues and sends a test mail at once, for the done check.
- Only the `mail.flush` job exists; later entries add theirs to `JOBS`.
## Implementation Notes
- `pytest` 319 passed, 6 skipped; `ruff check` clean.
- Not verified: a real SMTP server and the scheduler thread inside the container (needs the Linux host).