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
181 lines
6.2 KiB
Python
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)
|