feat: add reusable SMTP batch sessions

This commit is contained in:
2026-08-20 17:39:00 +02:00
parent 169b81d9db
commit a044fee379
5 changed files with 616 additions and 12 deletions
+107
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import smtplib
import unittest
from unittest.mock import patch
@@ -7,6 +8,9 @@ from govoplan_core.security.outbound_http import OutboundHttpBlocked
from govoplan_mail.backend.config import SmtpConfig
from govoplan_mail.backend.sending.smtp import (
SmtpConfigurationError,
SmtpBatchPolicy,
SmtpBatchSession,
SmtpSendError,
_open_smtp,
_prepare_smtp_send,
_smtp_send_result,
@@ -70,6 +74,109 @@ class SmtpSendHelperTests(unittest.TestCase):
self.assertEqual(result.accepted_count, 1)
self.assertEqual(result.refused_recipients["blocked@example.org"], (550, "blocked"))
def test_batch_preflight_reuses_one_authenticated_connection(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp()
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp) as opener:
with SmtpBatchSession(config) as batch:
first = batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
second = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
opener.assert_called_once_with(config)
self.assertFalse(first.session_reused)
self.assertTrue(second.session_reused)
self.assertEqual(1, second.connection_sequence)
self.assertEqual([b"first", b"second"], smtp.messages)
self.assertTrue(smtp.quit_called)
def test_batch_reconnects_before_next_message_when_health_check_fails(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
first_smtp = _FakeSmtp(noop_error_on_call=1)
second_smtp = _FakeSmtp()
policy = SmtpBatchPolicy(reconnect_attempts=1)
with patch(
"govoplan_mail.backend.sending.smtp._open_smtp",
side_effect=[first_smtp, second_smtp],
) as opener:
with SmtpBatchSession(config, policy=policy) as batch:
batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
result = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
self.assertEqual(2, opener.call_count)
self.assertEqual(2, result.connection_sequence)
self.assertEqual(1, result.reconnect_count)
self.assertEqual([b"first"], first_smtp.messages)
self.assertEqual([b"second"], second_smtp.messages)
def test_preflight_retries_a_transient_connection_failure(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp()
with patch(
"govoplan_mail.backend.sending.smtp._open_smtp",
side_effect=[OSError("temporary DNS failure"), smtp],
) as opener:
with SmtpBatchSession(config, policy=SmtpBatchPolicy(reconnect_attempts=1)) as batch:
result = batch.send(b"message", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
self.assertEqual(2, opener.call_count)
self.assertEqual(1, result.reconnect_count)
def test_connection_loss_after_send_starts_is_unknown_and_never_replayed(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp(send_error=smtplib.SMTPServerDisconnected("lost"))
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp), self.assertRaises(SmtpSendError) as raised:
with SmtpBatchSession(config) as batch:
batch.send(b"one", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
self.assertTrue(raised.exception.outcome_unknown)
self.assertTrue(raised.exception.systemic)
self.assertEqual("smtp_connection_lost_after_transmission", raised.exception.reason_code)
self.assertEqual(1, smtp.send_calls)
def test_authentication_preflight_is_systemic_and_blocks_batch(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
error = smtplib.SMTPAuthenticationError(535, b"bad credentials")
with patch("govoplan_mail.backend.sending.smtp._open_smtp", side_effect=error), self.assertRaises(SmtpSendError) as raised:
SmtpBatchSession(config).preflight()
self.assertTrue(raised.exception.systemic)
self.assertFalse(raised.exception.temporary)
self.assertEqual("preflight", raised.exception.phase)
self.assertEqual("smtp_authentication_failed", raised.exception.reason_code)
class _FakeSmtp:
def __init__(self, *, noop_error_on_call: int | None = None, send_error: BaseException | None = None):
self.noop_error_on_call = noop_error_on_call
self.send_error = send_error
self.noop_calls = 0
self.send_calls = 0
self.messages: list[bytes] = []
self.quit_called = False
def noop(self):
self.noop_calls += 1
if self.noop_error_on_call == self.noop_calls:
raise smtplib.SMTPServerDisconnected("stale")
return 250, b"ok"
def sendmail(self, _sender, _recipients, message):
self.send_calls += 1
if self.send_error is not None:
raise self.send_error
self.messages.append(message)
return {}
def send_message(self, message, **_kwargs):
return self.sendmail(None, None, message.as_bytes())
def quit(self):
self.quit_called = True
return 221, b"bye"
def close(self):
return None
if __name__ == "__main__":
unittest.main()