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
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SchedulerConfig(AppConfig):
name = "portal.scheduler"
label = "scheduler"
@@ -0,0 +1,29 @@
# Generated by Django 5.2.18 on 2026-10-18 07:16
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='JobRun',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('job', models.CharField(max_length=60)),
('started_at', models.DateTimeField()),
('finished_at', models.DateTimeField(blank=True, null=True)),
('status', models.CharField(choices=[('running', 'Running'), ('ok', 'Ok'), ('error', 'Error')], default='running', max_length=10)),
('detail', models.CharField(blank=True, max_length=200)),
],
options={
'ordering': ['-started_at', '-id'],
'indexes': [models.Index(fields=['job', '-started_at'], name='scheduler_j_job_2c7e36_idx')],
},
),
]
+21
View File
@@ -0,0 +1,21 @@
from django.db import models
class JobRun(models.Model):
"""The record of one run of one job. Owned by `scheduler` (AD-19)."""
class Status(models.TextChoices):
RUNNING = "running"
OK = "ok"
ERROR = "error"
job = models.CharField(max_length=60)
started_at = models.DateTimeField()
finished_at = models.DateTimeField(null=True, blank=True)
status = models.CharField(max_length=10, choices=Status.choices, default=Status.RUNNING)
# A short summary, or the name of the error. Never personal content.
detail = models.CharField(max_length=200, blank=True)
class Meta:
indexes = [models.Index(fields=["job", "-started_at"])]
ordering = ["-started_at", "-id"]
+51
View File
@@ -0,0 +1,51 @@
"""The scheduler thread: one per process, one tick every 30 seconds (AD-12)."""
from __future__ import annotations
import logging
import threading
from collections.abc import Callable
from django.db import close_old_connections
from . import services
logger = logging.getLogger(__name__)
TICK_SECONDS = 30
class Runner:
def __init__(self, tick: Callable[[], None] = services.tick, interval: float = TICK_SECONDS):
self._tick = tick
self._interval = interval
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
"""Start the thread; calling it again while it runs does nothing."""
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._loop, name="scheduler", daemon=True)
self._thread.start()
def stop(self, timeout: float = 5) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout)
def _loop(self) -> None:
logger.info("Scheduler started")
while not self._stop.is_set():
try:
self._tick()
except Exception:
logger.exception("Scheduler tick failed")
finally:
close_old_connections()
self._stop.wait(self._interval)
logger.info("Scheduler stopped")
runner = Runner()
+48
View File
@@ -0,0 +1,48 @@
"""Jobs and one tick that runs them (AD-12).
Every job reads the database for what is due and is safe to repeat, so a missed or doubled
tick does no harm. Each run is recorded for the Admin status page.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from django.utils import timezone
from portal.mailer import services as mailer
from .models import JobRun
logger = logging.getLogger(__name__)
KEEP_RUNS_DAYS = 7
@dataclass(frozen=True)
class Job:
name: str
run: Callable[[datetime], object] # returns something printable as the run's summary
# The order is the order they run in. Later entries add jobs here.
JOBS: tuple[Job, ...] = (Job("mail.flush", lambda now: mailer.flush(now)),)
def tick(now: datetime | None = None, jobs: tuple[Job, ...] | None = None) -> None:
"""Run every job once. A failing job is recorded and does not stop the others."""
now = now or timezone.now()
for job in JOBS if jobs is None else jobs:
run = JobRun.objects.create(job=job.name, started_at=now)
try:
detail = str(job.run(now))
status = JobRun.Status.OK
except Exception as exc:
logger.exception("Job %s failed", job.name)
detail, status = type(exc).__name__, JobRun.Status.ERROR
run.status, run.detail, run.finished_at = status, detail[:200], timezone.now()
run.save(update_fields=["status", "detail", "finished_at"])
JobRun.objects.filter(started_at__lt=now - timedelta(days=KEEP_RUNS_DAYS)).delete()