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 MailerConfig(AppConfig):
name = "portal.mailer"
label = "mailer"
@@ -0,0 +1,28 @@
from django.core.management.base import BaseCommand, CommandError
from portal.mailer import services
from portal.mailer.models import OutboxMail
class Command(BaseCommand):
help = "Queue a test e-mail and send it now, to check the SMTP settings."
def add_arguments(self, parser):
parser.add_argument("recipient", help="E-mail address that should receive the test mail")
def handle(self, *args, recipient, **options):
try:
mail = services.enqueue("test", recipient)
except ValueError as exc:
raise CommandError(str(exc)) from exc
result = services.flush()
mail.refresh_from_db()
self.stdout.write(f"Outbox entry {mail.pk}: {mail.status} ({result})")
if mail.status == OutboxMail.Status.SENT:
self.stdout.write("The mail server accepted the message.")
elif mail.status == OutboxMail.Status.QUEUED:
self.stdout.write(f"Not sent yet, will be retried: {mail.last_error}")
raise SystemExit(1)
else:
self.stdout.write(f"Failed: {mail.last_error}")
raise SystemExit(1)
+35
View File
@@ -0,0 +1,35 @@
# 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='OutboxMail',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('template', models.CharField(max_length=100)),
('recipient', models.CharField(max_length=254)),
('subject', models.CharField(max_length=300)),
('body_text', models.TextField(blank=True)),
('status', models.CharField(choices=[('queued', 'Queued'), ('sending', 'Sending'), ('sent', 'Sent'), ('failed', 'Failed')], default='queued', max_length=10)),
('attempts', models.PositiveSmallIntegerField(default=0)),
('created_at', models.DateTimeField()),
('next_attempt_at', models.DateTimeField()),
('claimed_at', models.DateTimeField(blank=True, null=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('last_error', models.CharField(blank=True, max_length=200)),
],
options={
'ordering': ['id'],
'indexes': [models.Index(fields=['status', 'next_attempt_at'], name='mailer_outb_status_d5af6e_idx')],
},
),
]
+29
View File
@@ -0,0 +1,29 @@
from django.db import models
class OutboxMail(models.Model):
"""One e-mail waiting to be sent, being sent, sent or failed. Owned by `mailer` (AD-19)."""
class Status(models.TextChoices):
QUEUED = "queued"
SENDING = "sending"
SENT = "sent"
FAILED = "failed"
template = models.CharField(max_length=100)
recipient = models.CharField(max_length=254)
subject = models.CharField(max_length=300)
# Kept only until the mail is sent: it may hold a one-time link.
body_text = models.TextField(blank=True)
status = models.CharField(max_length=10, choices=Status.choices, default=Status.QUEUED)
attempts = models.PositiveSmallIntegerField(default=0)
created_at = models.DateTimeField()
next_attempt_at = models.DateTimeField()
claimed_at = models.DateTimeField(null=True, blank=True)
sent_at = models.DateTimeField(null=True, blank=True)
# What went wrong, in words without the recipient or the text of the mail.
last_error = models.CharField(max_length=200, blank=True)
class Meta:
indexes = [models.Index(fields=["status", "next_attempt_at"])]
ordering = ["id"]
+151
View File
@@ -0,0 +1,151 @@
"""The outbox: other modules call `enqueue`, the scheduler calls `flush` (AD-11).
A mail is claimed (queued -> sending) before it is handed to the server, so two runs never
send the same row. If the process dies in between, the row stays `sending`; later it is marked
failed with an unknown outcome and is not sent again, because a lost mail is better than a
duplicate one for a password or sign-in notice.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from datetime import datetime, timedelta
from django.conf import settings
from django.db.models import F
from django.template.loader import render_to_string
from django.utils import timezone, translation
from portal.adapters.mail import MailMessage, MailRejected, MailUnavailable
from portal.adapters.registry import get_mailer
from .models import OutboxMail
logger = logging.getLogger(__name__)
MAX_ATTEMPTS = 5
BACKOFF_SECONDS = (60, 5 * 60, 15 * 60, 60 * 60) # wait after the 1st, 2nd, ... failed attempt
RATE_PER_MINUTE = 20
LEASE_SECONDS = 10 * 60 # a row that has been `sending` this long is given up on
STOP_AFTER_CONSECUTIVE_OUTAGES = 3
_ADDRESS = re.compile(r"^[^@\s<>,;\"()\[\]\\]+@[^@\s<>,;\"()\[\]\\]+\.[^@\s<>,;\"()\[\]\\]+$")
@dataclass(frozen=True)
class FlushResult:
sent: int = 0
retried: int = 0
failed: int = 0
def __str__(self) -> str:
return f"sent={self.sent} retried={self.retried} failed={self.failed}"
def is_valid_address(value: str) -> bool:
return len(value) <= 254 and bool(_ADDRESS.match(value))
def enqueue(template: str, recipient: str, context: dict | None = None) -> OutboxMail:
"""Render the e-mail in the portal-wide language and queue it.
Call it inside the transaction of the change that causes the mail, so a rolled-back change
queues nothing.
"""
if not is_valid_address(recipient):
raise ValueError("Not a usable e-mail address")
with translation.override(settings.MAIL_LANGUAGE):
subject = render_to_string(f"mail/{template}/subject.txt", context or {})
body = render_to_string(f"mail/{template}/body.txt", context or {})
subject = " ".join(subject.split()) # one line, whatever the template did
now = timezone.now()
return OutboxMail.objects.create(
template=template,
recipient=recipient,
subject=subject[:300],
body_text=body.strip() + "\n",
created_at=now,
next_attempt_at=now,
)
def _give_up_on_stale(now: datetime) -> None:
stale = OutboxMail.objects.filter(
status=OutboxMail.Status.SENDING, claimed_at__lt=now - timedelta(seconds=LEASE_SECONDS)
)
count = stale.update(
status=OutboxMail.Status.FAILED,
body_text="",
last_error="Outcome unknown after a restart; not sent again",
)
if count:
logger.error("Gave up on %s mails that were being sent when the process stopped", count)
def _backoff(attempts: int) -> timedelta:
return timedelta(seconds=BACKOFF_SECONDS[min(attempts, len(BACKOFF_SECONDS)) - 1])
def flush(now: datetime | None = None) -> FlushResult:
"""Send what is due, within the rate limit. Safe to call repeatedly."""
now = now or timezone.now()
_give_up_on_stale(now)
recent = OutboxMail.objects.filter(
status=OutboxMail.Status.SENT, sent_at__gt=now - timedelta(minutes=1)
).count()
budget = max(RATE_PER_MINUTE - recent, 0)
due = list(
OutboxMail.objects.filter(status=OutboxMail.Status.QUEUED, next_attempt_at__lte=now)
.order_by("id")
.values_list("pk", flat=True)[:budget]
)
sent = retried = failed = outages = 0
mailer = get_mailer()
for pk in due:
claimed = OutboxMail.objects.filter(pk=pk, status=OutboxMail.Status.QUEUED).update(
status=OutboxMail.Status.SENDING, claimed_at=now, attempts=F("attempts") + 1
)
if not claimed: # another run took it
continue
mail = OutboxMail.objects.get(pk=pk)
message = MailMessage(
sender=settings.SMTP.sender,
recipient=mail.recipient,
subject=mail.subject,
body_text=mail.body_text,
)
try:
mailer.send(message)
except MailUnavailable as exc:
outages += 1
if mail.attempts >= MAX_ATTEMPTS:
_finish(mail, OutboxMail.Status.FAILED, str(exc))
failed += 1
else:
mail.status = OutboxMail.Status.QUEUED
mail.next_attempt_at = now + _backoff(mail.attempts)
mail.last_error = str(exc)[:200]
mail.save(update_fields=["status", "next_attempt_at", "last_error"])
retried += 1
logger.warning("Mail %s not sent (attempt %s)", pk, mail.attempts)
if outages >= STOP_AFTER_CONSECUTIVE_OUTAGES:
break
except MailRejected as exc:
_finish(mail, OutboxMail.Status.FAILED, str(exc))
failed += 1
logger.warning("Mail %s was refused", pk)
else:
outages = 0
_finish(mail, OutboxMail.Status.SENT, "", sent_at=now)
sent += 1
return FlushResult(sent, retried, failed)
def _finish(mail: OutboxMail, status: str, error: str, sent_at: datetime | None = None) -> None:
mail.status = status
mail.last_error = error[:200]
mail.body_text = ""
mail.sent_at = sent_at
mail.save(update_fields=["status", "last_error", "body_text", "sent_at"])