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