"""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 ", 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): return self._connection("SMTP", args, kwargs) def SMTP_SSL(self, *args, **kwargs): 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 " 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)