mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
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>
172 lines
5.4 KiB
Python
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"]
|