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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user