Files
CCMA/tests/test_mail_delivery.py
T
Marcel PeterkauandClaude Sonnet 5 6b0da82b45 Add configurable e-mail delivery: direct SMTP send or IMAP drafts
Mahnungs- and SEPA-info-mails could previously only be saved as a local .eml
file that still had to be manually imported into Thunderbird. Add a per-store
"E-Mail-Versand" configuration (Optionen -> E-Mail-Versand, stored in
repository.json alongside the rest of the club's settings, since different
stores may use different mailboxes) with four delivery modes:

- "Lokal speichern": today's behaviour, unchanged default for existing stores.
- "Direkt versenden": sends via SMTP.
- "Als Entwurf ablegen": IMAP APPENDs into a configurable drafts folder, so it
  shows up live in whatever mail client is already watching that account.
- "Jedes Mal fragen": prompts once per generation action (not per e-mail --
  a SEPA batch can cover dozens of members) with Senden/Entwürfe/Abbrechen.

New ccma.services.mail_delivery module (smtplib/imaplib, no new dependency)
opens one authenticated connection per batch and reuses it across all
messages instead of reconnecting per recipient. Both "Verbindung testen"
buttons in Options exercise the same connection path used for real delivery.
The archived per-member copy of every generated e-mail is unaffected and
still always written regardless of delivery mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:34:34 +02:00

172 lines
5.4 KiB
Python

import pytest
from ccma.services import mail_delivery
from ccma.storage.repository import RepositoryError
class _FakeSmtpClient:
instances: list["_FakeSmtpClient"] = []
def __init__(self, host, port, **kwargs):
self.host = host
self.port = port
self.kwargs = kwargs
self.calls: list[str] = []
self.login_args: tuple | None = None
self.sent_messages: list = []
_FakeSmtpClient.instances.append(self)
def starttls(self, **kwargs):
self.calls.append("starttls")
def login(self, username, password):
self.login_args = (username, password)
self.calls.append("login")
def send_message(self, message):
self.sent_messages.append(message)
self.calls.append("send_message")
def quit(self):
self.calls.append("quit")
class _FakeImapClient:
instances: list["_FakeImapClient"] = []
error = Exception
def __init__(self, host, port, **kwargs):
self.host = host
self.port = port
self.kwargs = kwargs
self.calls: list[str] = []
self.login_args: tuple | None = None
self.appended: list[tuple] = []
self.append_result = ("OK", [b"APPEND completed"])
_FakeImapClient.instances.append(self)
def starttls(self, *args, **kwargs):
self.calls.append("starttls")
def login(self, username, password):
self.login_args = (username, password)
self.calls.append("login")
def append(self, folder, flags, date_time, message_bytes):
self.appended.append((folder, flags, date_time, message_bytes))
self.calls.append("append")
return self.append_result
def logout(self):
self.calls.append("logout")
@pytest.fixture(autouse=True)
def _reset_fakes():
_FakeSmtpClient.instances.clear()
_FakeImapClient.instances.clear()
yield
def _patch_smtp(monkeypatch):
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _FakeSmtpClient)
monkeypatch.setattr(mail_delivery.smtplib, "SMTP_SSL", _FakeSmtpClient)
def _patch_imap(monkeypatch):
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4", _FakeImapClient)
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4_SSL", _FakeImapClient)
SMTP_SETTINGS = {
"smtp_host": "mail.example.org",
"smtp_port": 587,
"smtp_security": "starttls",
"smtp_username": "board@example.org",
"smtp_password": "secret",
}
IMAP_SETTINGS = {
"imap_host": "mail.example.org",
"imap_port": 993,
"imap_security": "ssl",
"imap_username": "board@example.org",
"imap_password": "secret",
}
MESSAGE = (
b"From: Board <board@example.org>\r\n"
b"To: member@example.org\r\n"
b"Subject: Test\r\n"
b"\r\n"
b"Hello\r\n"
)
def test_send_via_smtp_logs_in_and_sends_reusing_one_connection(monkeypatch) -> None:
_patch_smtp(monkeypatch)
with mail_delivery.smtp_session(SMTP_SETTINGS) as client:
mail_delivery.send_via_smtp(client, MESSAGE)
mail_delivery.send_via_smtp(client, MESSAGE)
assert len(_FakeSmtpClient.instances) == 1
fake = _FakeSmtpClient.instances[0]
assert fake.host == "mail.example.org"
assert fake.port == 587
assert fake.login_args == ("board@example.org", "secret")
assert fake.calls == ["starttls", "login", "send_message", "send_message", "quit"]
assert len(fake.sent_messages) == 2
def test_append_to_imap_drafts_uses_configured_folder(monkeypatch) -> None:
_patch_imap(monkeypatch)
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
mail_delivery.append_to_imap_drafts(client, MESSAGE, folder="INBOX.Entwürfe")
fake = _FakeImapClient.instances[0]
assert fake.login_args == ("board@example.org", "secret")
assert fake.appended == [("INBOX.Entwürfe", r"(\Draft)", None, MESSAGE)]
assert fake.calls == ["login", "append", "logout"]
def test_append_to_imap_drafts_raises_on_rejected_status(monkeypatch) -> None:
_patch_imap(monkeypatch)
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
client.append_result = ("NO", [b"Mailbox does not exist"])
with pytest.raises(RepositoryError, match="Mailbox does not exist"):
mail_delivery.append_to_imap_drafts(client, MESSAGE, folder="Missing")
def test_smtp_session_wraps_connection_errors(monkeypatch) -> None:
def _boom(*args, **kwargs):
raise OSError("connection refused")
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _boom)
with pytest.raises(RepositoryError, match="SMTP-Verbindung fehlgeschlagen"):
with mail_delivery.smtp_session({**SMTP_SETTINGS, "smtp_security": "none"}):
pass
def test_smtp_session_requires_host() -> None:
with pytest.raises(RepositoryError, match="Kein SMTP-Server konfiguriert"):
with mail_delivery.smtp_session({}):
pass
def test_imap_session_requires_host() -> None:
with pytest.raises(RepositoryError, match="Kein IMAP-Server konfiguriert"):
with mail_delivery.imap_session({}):
pass
def test_test_smtp_connection_succeeds_and_closes(monkeypatch) -> None:
_patch_smtp(monkeypatch)
mail_delivery.test_smtp_connection(SMTP_SETTINGS)
assert _FakeSmtpClient.instances[0].calls == ["starttls", "login", "quit"]
def test_test_imap_connection_succeeds_and_closes(monkeypatch) -> None:
_patch_imap(monkeypatch)
mail_delivery.test_imap_connection(IMAP_SETTINGS)
assert _FakeImapClient.instances[0].calls == ["login", "logout"]