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
+7 -2
View File
@@ -7,6 +7,7 @@ import pytest
from portal.adapters import registry
from portal.adapters.directory import FakeDirectory
from portal.adapters.idp import FakeIdp
from portal.adapters.mail import FakeMailer
@pytest.fixture
@@ -28,14 +29,18 @@ def valid_env(tmp_path):
"OIDC_CLIENT_ID": "portal-client",
"OIDC_CLIENT_SECRET": "client-secret",
"PUBLIC_BASE_URL": "https://portal.example.test",
"SMTP_HOST": "mail.example.test",
"SMTP_USERNAME": "portal@example.test",
"SMTP_PASSWORD": "mail-password",
"MAIL_FROM": "Portal <portal@example.test>",
}
@pytest.fixture(autouse=True)
def adapters():
"""Fakes for every adapter, so no test can reach a real directory or provider."""
fakes = SimpleNamespace(directory=FakeDirectory(), idp=FakeIdp())
registry.install(fakes.directory, fakes.idp)
fakes = SimpleNamespace(directory=FakeDirectory(), idp=FakeIdp(), mailer=FakeMailer())
registry.install(fakes.directory, fakes.idp, fakes.mailer)
yield fakes
registry.install()
+38
View File
@@ -140,3 +140,41 @@ def test_oidc_addresses_must_be_https_origins(valid_env, name, value):
def test_a_plain_http_origin_is_allowed_only_in_debug(valid_env):
env = {**valid_env, "PUBLIC_BASE_URL": "http://localhost:8000"}
assert load_config({**env, "DEBUG": "true"}).oidc.public_base_url == "http://localhost:8000"
def test_smtp_settings_default_to_starttls_on_587_and_hide_the_password(valid_env):
smtp = load_config(valid_env).smtp
assert (smtp.host, smtp.port, smtp.security) == ("mail.example.test", 587, "starttls")
assert smtp.sender == "Portal <portal@example.test>"
assert "mail-password" not in repr(smtp)
def test_smtp_ssl_defaults_to_port_465_and_the_port_can_be_set(valid_env):
assert load_config({**valid_env, "SMTP_SECURITY": "SSL"}).smtp.port == 465
assert load_config({**valid_env, "SMTP_PORT": "2525"}).smtp.port == 2525
@pytest.mark.parametrize(
("name", "value"),
[
("SMTP_HOST", "smtp://mail.example.test"),
("SMTP_HOST", "mail.example.test:587"),
("SMTP_SECURITY", "plain"),
("SMTP_PORT", "abc"),
("SMTP_PORT", "0"),
("SMTP_PORT", "70000"),
("MAIL_FROM", "not-an-address"),
("MAIL_FROM", "a@b.test\r\nBcc: x@evil.test"),
("MAIL_FROM", "@example.test"),
],
)
def test_invalid_smtp_settings_are_refused(valid_env, name, value):
with pytest.raises(ConfigError, match=name):
load_config({**valid_env, name: value})
@pytest.mark.parametrize("name", ["SMTP_HOST", "SMTP_USERNAME", "SMTP_PASSWORD", "MAIL_FROM"])
def test_the_mail_settings_are_required(valid_env, name):
del valid_env[name]
with pytest.raises(ConfigError, match=name):
load_config(valid_env)
+263
View File
@@ -0,0 +1,263 @@
"""The outbox: queue, send, retry, rate limit and restart safety (AD-11)."""
import logging
from datetime import timedelta
import pytest
from django.core.management import call_command
from django.db import transaction
from django.utils import timezone
from portal.mailer import services
from portal.mailer.models import OutboxMail
pytestmark = pytest.mark.django_db(transaction=True)
Status = OutboxMail.Status
def queue(recipient="lena@example.test", n=1):
return [services.enqueue("test", recipient) for _ in range(n)][-1]
# -- queueing -----------------------------------------
def test_enqueue_renders_the_mail_in_formal_german():
mail = queue()
assert mail.status == Status.QUEUED and mail.attempts == 0
assert mail.subject == "Test-E-Mail vom Portal"
assert "Wenn Sie sie lesen können" in mail.body_text
def test_the_language_of_the_request_does_not_change_the_mail():
from django.utils import translation
with translation.override("en"):
assert queue().subject == "Test-E-Mail vom Portal"
@pytest.mark.parametrize(
"address",
["", "nobody", "a@b", "a b@example.test", "a@example.test\r\nBcc: x@evil.test", "<a@b.test>"],
)
def test_an_unusable_address_is_refused_and_nothing_is_queued(address):
with pytest.raises(ValueError, match="e-mail address"):
services.enqueue("test", address)
assert OutboxMail.objects.count() == 0
def test_a_rolled_back_change_queues_nothing():
with pytest.raises(RuntimeError), transaction.atomic():
queue()
raise RuntimeError("the change failed")
assert OutboxMail.objects.count() == 0
# -- sending -----------------------------------------
def test_flush_sends_a_queued_mail_and_removes_its_text(adapters):
mail = queue()
result = services.flush()
mail.refresh_from_db()
assert (result.sent, result.retried, result.failed) == (1, 0, 0)
assert mail.status == Status.SENT and mail.sent_at and mail.attempts == 1
assert mail.body_text == "" # a one-time link must not stay in the database
sent = adapters.mailer.sent[0]
assert sent.recipient == "lena@example.test" and sent.sender == "Portal <portal@example.test>"
assert "Test-E-Mail" in sent.subject and "Test-E-Mail" in sent.body_text
def test_a_mail_is_never_sent_twice(adapters):
queue()
services.flush()
services.flush()
services.flush(timezone.now() + timedelta(days=1))
assert len(adapters.mailer.sent) == 1
def test_a_mail_that_is_not_due_yet_waits(adapters):
mail = queue()
later = timezone.now() + timedelta(hours=1)
OutboxMail.objects.filter(pk=mail.pk).update(next_attempt_at=later)
services.flush()
assert adapters.mailer.sent == []
def test_mails_are_sent_in_the_order_they_were_queued(adapters):
for name in ("a", "b", "c"):
queue(f"{name}@example.test")
services.flush()
assert [m.recipient for m in adapters.mailer.sent] == [f"{n}@example.test" for n in "abc"]
# -- failures -----------------------------------------
def test_a_temporary_failure_is_retried_later_with_a_growing_wait(adapters):
mail = queue()
adapters.mailer.unavailable = True
now = timezone.now()
waits = []
for attempt in range(1, 5):
result = services.flush(now)
mail.refresh_from_db()
assert result.retried == 1 and mail.status == Status.QUEUED and mail.attempts == attempt
assert mail.last_error
waits.append(mail.next_attempt_at - now)
now = mail.next_attempt_at
assert waits == [timedelta(seconds=s) for s in services.BACKOFF_SECONDS]
assert waits == sorted(waits)
def test_a_retry_before_the_wait_is_over_does_nothing(adapters):
mail = queue()
adapters.mailer.unavailable = True
now = timezone.now()
services.flush(now)
attempts = adapters.mailer.attempts
services.flush(now + timedelta(seconds=30))
assert adapters.mailer.attempts == attempts
mail.refresh_from_db()
assert mail.attempts == 1
def test_a_mail_that_works_on_a_retry_is_sent(adapters):
mail = queue()
adapters.mailer.unavailable = True
now = timezone.now()
services.flush(now)
adapters.mailer.unavailable = False
mail.refresh_from_db()
result = services.flush(mail.next_attempt_at)
mail.refresh_from_db()
assert result.sent == 1 and mail.status == Status.SENT and mail.attempts == 2
def test_after_the_last_attempt_the_mail_is_failed_and_kept_for_the_admin(adapters):
mail = queue()
adapters.mailer.unavailable = True
now = timezone.now()
for _ in range(services.MAX_ATTEMPTS):
services.flush(now)
mail.refresh_from_db()
now = mail.next_attempt_at
assert mail.status == Status.FAILED and mail.attempts == services.MAX_ATTEMPTS
assert mail.recipient == "lena@example.test" and mail.last_error and mail.body_text == ""
attempts = adapters.mailer.attempts
services.flush(now + timedelta(days=1))
assert adapters.mailer.attempts == attempts # no further tries
def test_a_permanent_refusal_fails_at_once_without_retries(adapters):
mail = queue("gone@example.test")
adapters.mailer.rejected.add("gone@example.test")
result = services.flush()
mail.refresh_from_db()
assert result.failed == 1 and mail.status == Status.FAILED and mail.attempts == 1
assert "550" in mail.last_error
def test_one_refused_mail_does_not_block_the_others(adapters):
queue("gone@example.test")
queue("lena@example.test")
adapters.mailer.rejected.add("gone@example.test")
result = services.flush()
assert (result.sent, result.failed) == (1, 1)
def test_a_dead_server_is_not_tried_for_every_mail_in_the_queue(adapters):
for i in range(10):
queue(f"u{i}@example.test")
adapters.mailer.unavailable = True
services.flush()
assert adapters.mailer.attempts == services.STOP_AFTER_CONSECUTIVE_OUTAGES
# -- rate limit -----------------------------------------
def test_no_more_than_the_rate_limit_is_sent_per_minute(adapters):
for i in range(services.RATE_PER_MINUTE + 5):
queue(f"u{i}@example.test")
now = timezone.now()
assert services.flush(now).sent == services.RATE_PER_MINUTE
assert services.flush(now + timedelta(seconds=30)).sent == 0
assert services.flush(now + timedelta(seconds=61)).sent == 5
assert len(adapters.mailer.sent) == services.RATE_PER_MINUTE + 5
# -- restarts -----------------------------------------
def test_a_mail_cut_off_while_sending_is_given_up_not_sent_again(adapters):
mail = queue()
now = timezone.now()
OutboxMail.objects.filter(pk=mail.pk).update(
status=Status.SENDING, claimed_at=now - timedelta(seconds=services.LEASE_SECONDS + 1)
)
result = services.flush(now)
mail.refresh_from_db()
assert mail.status == Status.FAILED and "unknown" in mail.last_error
assert result.sent == 0 and adapters.mailer.sent == []
def test_a_mail_being_sent_right_now_is_left_alone(adapters):
mail = queue()
now = timezone.now()
OutboxMail.objects.filter(pk=mail.pk).update(status=Status.SENDING, claimed_at=now)
services.flush(now)
mail.refresh_from_db()
assert mail.status == Status.SENDING and adapters.mailer.sent == []
def test_a_row_taken_by_another_run_is_skipped(adapters, monkeypatch):
mail = queue()
real = OutboxMail.objects.filter
def take_first(*args, **kwargs):
# The moment the claim is attempted, another run has already taken the row.
if kwargs.get("pk") == mail.pk:
real(pk=mail.pk).update(status=Status.SENDING)
return real(*args, **kwargs)
monkeypatch.setattr(OutboxMail.objects, "filter", take_first)
assert services.flush().sent == 0
assert adapters.mailer.sent == []
# -- privacy -----------------------------------------
def test_logs_name_the_row_but_not_the_recipient_or_text(adapters, caplog):
queue("secret.person@example.test")
adapters.mailer.unavailable = True
with caplog.at_level(logging.DEBUG):
services.flush()
assert "not sent" in caplog.text
assert "secret.person" not in caplog.text and "Test-E-Mail" not in caplog.text
# -- the test command -----------------------------------------
def test_the_test_command_sends_a_mail_now(adapters, capsys):
call_command("send_test_mail", "lena@example.test")
assert "sent" in capsys.readouterr().out
assert [m.recipient for m in adapters.mailer.sent] == ["lena@example.test"]
def test_the_test_command_reports_a_failure(adapters, capsys):
adapters.mailer.unavailable = True
with pytest.raises(SystemExit) as stop:
call_command("send_test_mail", "lena@example.test")
assert stop.value.code == 1
assert "retried" in capsys.readouterr().out
def test_the_test_command_refuses_a_bad_address(adapters):
from django.core.management.base import CommandError
with pytest.raises(CommandError):
call_command("send_test_mail", "not-an-address")
+104
View File
@@ -0,0 +1,104 @@
"""The scheduler: one tick runs the jobs and records each run (AD-12)."""
import threading
from datetime import timedelta
import pytest
from django.utils import timezone
from portal.mailer import services as mailer
from portal.scheduler import services
from portal.scheduler.models import JobRun
from portal.scheduler.runner import Runner
pytestmark = pytest.mark.django_db(transaction=True)
def test_a_tick_runs_the_mail_job_and_records_the_run(adapters):
mailer.enqueue("test", "lena@example.test")
services.tick()
assert len(adapters.mailer.sent) == 1
run = JobRun.objects.get(job="mail.flush")
assert run.status == JobRun.Status.OK and run.finished_at
assert run.detail == "sent=1 retried=0 failed=0"
def test_an_idle_tick_is_recorded_too_and_repeating_it_is_harmless(adapters):
services.tick()
services.tick()
assert JobRun.objects.filter(job="mail.flush").count() == 2
assert adapters.mailer.sent == []
def test_a_restart_sends_nothing_twice(adapters):
mailer.enqueue("test", "lena@example.test")
services.tick()
services.tick(timezone.now() + timedelta(minutes=5)) # "after a restart"
assert len(adapters.mailer.sent) == 1
def test_a_failing_job_is_recorded_and_the_others_still_run():
ran = []
def broken(now):
raise RuntimeError("secret detail that must not be stored")
jobs = (
services.Job("first", lambda now: ran.append("first") or "fine"),
services.Job("broken", broken),
services.Job("last", lambda now: ran.append("last") or "also fine"),
)
services.tick(jobs=jobs)
assert ran == ["first", "last"]
broken_run = JobRun.objects.get(job="broken")
assert broken_run.status == JobRun.Status.ERROR and broken_run.detail == "RuntimeError"
assert JobRun.objects.get(job="last").status == JobRun.Status.OK
def test_old_run_records_are_removed():
now = timezone.now()
JobRun.objects.create(job="x", started_at=now - timedelta(days=services.KEEP_RUNS_DAYS + 1))
JobRun.objects.create(job="x", started_at=now - timedelta(days=1))
services.tick(now, jobs=())
assert JobRun.objects.filter(job="x").count() == 1
# -- the thread -----------------------------------------
def test_the_thread_ticks_repeatedly_and_stops():
ticked = threading.Semaphore(0)
runner = Runner(tick=ticked.release, interval=0.01)
runner.start()
assert ticked.acquire(timeout=2) and ticked.acquire(timeout=2)
runner.stop()
assert not runner._thread.is_alive()
def test_a_failing_tick_does_not_stop_the_thread():
calls = threading.Semaphore(0)
def tick():
calls.release()
raise RuntimeError("boom")
runner = Runner(tick=tick, interval=0.01)
runner.start()
assert calls.acquire(timeout=2) and calls.acquire(timeout=2)
runner.stop()
def test_starting_twice_runs_only_one_thread():
runner = Runner(tick=lambda: None, interval=0.05)
runner.start()
first = runner._thread
runner.start()
assert runner._thread is first
runner.stop()
assert sum(1 for t in threading.enumerate() if t.name == "scheduler") == 0
def test_the_default_interval_is_thirty_seconds():
from portal.scheduler.runner import TICK_SECONDS, runner
assert TICK_SECONDS == 30 and runner._interval == 30
+180
View File
@@ -0,0 +1,180 @@
"""The SMTP adapter against a stand-in for `smtplib`; no network."""
import smtplib
import ssl
import pytest
from portal.adapters.mail import MailMessage, MailRejected, MailUnavailable, SmtpMailer
from portal.config.env import SmtpConfig
MESSAGE = MailMessage(
sender="Portal <portal@example.test>",
recipient="lena@example.test",
subject="Hallo Lena",
body_text="Guten Tag, schöne Grüße\n",
)
def config(security="starttls", port=587):
return SmtpConfig("mail.example.test", port, security, "box@example.test", "pw", "a@b.test")
class FakeSmtpModule:
"""Looks like the `smtplib` module and records what the adapter does."""
SMTPException = smtplib.SMTPException
SMTPAuthenticationError = smtplib.SMTPAuthenticationError
SMTPRecipientsRefused = smtplib.SMTPRecipientsRefused
SMTPResponseException = smtplib.SMTPResponseException
def __init__(self):
self.calls = []
self.fail_at = {} # step name -> exception to raise
self.sent = []
self.connect_error = None
def _connection(self, kind, args, kwargs):
if self.connect_error:
raise self.connect_error
module = self
module.calls.append((kind, args, kwargs))
class Connection:
def _step(self, name, *a, **k):
module.calls.append((name, a, k))
if name in module.fail_at:
raise module.fail_at[name]
def ehlo(self):
self._step("ehlo")
def starttls(self, context=None):
self._step("starttls", context=context)
def login(self, user, password):
self._step("login", user, password)
def send_message(self, message):
self._step("send_message")
module.sent.append(message)
def quit(self):
self._step("quit")
def close(self):
module.calls.append(("close", (), {}))
return Connection()
def SMTP(self, *args, **kwargs): # noqa: N802
return self._connection("SMTP", args, kwargs)
def SMTP_SSL(self, *args, **kwargs): # noqa: N802
return self._connection("SMTP_SSL", args, kwargs)
def names(self):
return [c[0] for c in self.calls]
@pytest.fixture
def smtp():
return FakeSmtpModule()
def test_starttls_encrypts_before_the_login(smtp):
SmtpMailer(config("starttls"), smtp).send(MESSAGE)
assert smtp.names() == ["SMTP", "ehlo", "starttls", "ehlo", "login", "send_message", "quit"]
context = [c for c in smtp.calls if c[0] == "starttls"][0][2]["context"]
assert context.verify_mode == ssl.CERT_REQUIRED and context.check_hostname
def test_ssl_mode_uses_an_encrypted_connection_from_the_start(smtp):
SmtpMailer(config("ssl", 465), smtp).send(MESSAGE)
assert smtp.names() == ["SMTP_SSL", "ehlo", "login", "send_message", "quit"]
kind, args, kwargs = smtp.calls[0]
assert args == ("mail.example.test", 465) and kwargs["timeout"] > 0
assert kwargs["context"].verify_mode == ssl.CERT_REQUIRED
def test_the_login_uses_the_configured_mailbox(smtp):
SmtpMailer(config(), smtp).send(MESSAGE)
assert ("login", ("box@example.test", "pw"), {}) in smtp.calls
def test_the_message_is_a_utf8_plain_text_mail_with_the_usual_headers(smtp):
SmtpMailer(config(), smtp).send(MESSAGE)
email = smtp.sent[0]
assert email["From"] == "Portal <portal@example.test>"
assert email["To"] == "lena@example.test"
assert email["Subject"] == "Hallo Lena"
assert email["Date"] and email["Message-ID"].endswith("@mail.example.test>")
assert email.get_content_type() == "text/plain"
assert "schöne Grüße" in email.get_content()
@pytest.mark.parametrize(
"bad",
[
{"subject": "Hi\r\nBcc: x@evil.test"},
{"recipient": "a@example.test\r\nBcc: x@evil.test"},
{"recipient": "a@example.test\nBcc: x@evil.test"},
],
)
def test_a_line_break_in_a_header_cannot_add_headers(smtp, bad):
message = MailMessage(**{**MESSAGE.__dict__, **bad})
with pytest.raises(MailRejected):
SmtpMailer(config(), smtp).send(message)
assert smtp.sent == []
@pytest.mark.parametrize(
("error", "expected"),
[
(smtplib.SMTPResponseException(421, b"busy"), MailUnavailable),
(smtplib.SMTPResponseException(450, b"try later"), MailUnavailable),
(smtplib.SMTPResponseException(550, b"no such user"), MailRejected),
(smtplib.SMTPResponseException(554, b"refused"), MailRejected),
(smtplib.SMTPServerDisconnected("gone"), MailUnavailable),
(TimeoutError("slow"), MailUnavailable),
(ssl.SSLError("bad certificate"), MailUnavailable),
(
smtplib.SMTPRecipientsRefused({"lena@example.test": (550, b"unknown")}),
MailRejected,
),
(
smtplib.SMTPRecipientsRefused({"lena@example.test": (452, b"mailbox full")}),
MailUnavailable,
),
],
)
def test_server_answers_are_mapped_to_temporary_or_permanent(smtp, error, expected):
smtp.fail_at["send_message"] = error
with pytest.raises(expected):
SmtpMailer(config(), smtp).send(MESSAGE)
def test_a_refused_login_is_temporary_so_a_fixed_password_works_on_the_retry(smtp):
smtp.fail_at["login"] = smtplib.SMTPAuthenticationError(535, b"bad credentials")
with pytest.raises(MailUnavailable):
SmtpMailer(config(), smtp).send(MESSAGE)
assert "close" in smtp.names() and "send_message" not in smtp.names()
def test_an_unreachable_server_is_temporary(smtp):
smtp.connect_error = ConnectionRefusedError("no")
with pytest.raises(MailUnavailable):
SmtpMailer(config(), smtp).send(MESSAGE)
def test_a_failing_quit_does_not_hide_a_successful_send(smtp):
smtp.fail_at["quit"] = smtplib.SMTPServerDisconnected("closed")
SmtpMailer(config(), smtp).send(MESSAGE)
assert len(smtp.sent) == 1
def test_errors_never_contain_the_recipient_or_the_server_text(smtp):
smtp.fail_at["send_message"] = smtplib.SMTPResponseException(550, b"lena@example.test unknown")
with pytest.raises(MailRejected) as caught:
SmtpMailer(config(), smtp).send(MESSAGE)
assert "lena" not in str(caught.value) and "unknown" not in str(caught.value)
+2 -1
View File
@@ -17,7 +17,8 @@ PY_CALL = re.compile(r"""(?<![\w.])(?:_|gettext|gettext_lazy)\(\s*(?:"([^"]+)"|'
def msgids_in_code() -> set[str]:
found = set()
for template in (PORTAL / "templates").rglob("*.html"):
templates = [*(PORTAL / "templates").rglob("*.html"), *(PORTAL / "templates").rglob("*.txt")]
for template in templates:
text = template.read_text(encoding="utf-8")
found.update(a or b for a, b in TRANS_TAG.findall(text))
found.update(VARIABLE.sub(r"%(\1)s", body).strip() for body in BLOCKTRANS.findall(text))