Files
ping-mee-self-service-platform/portal/tests/test_smtp_mailer.py
T
ebbe.bassandClaude Sonnet 5.5 20510a73e5 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
2026-10-18 09:18:42 +02:00

181 lines
6.2 KiB
Python

"""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)