mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
A raw SMTP send has no server-side "Sent" copy on its own (unlike IMAP drafts, which are inherently server-side) -- add an opt-in checkbox plus a configurable target folder so directly sent Mahnungen/SEPA-info-mails still show up in the account's Gesendet/Sent folder like a normal mail client would leave them. Applies only to "send" delivery; drafts already live on the server by definition. Both the Entwürfe- and Gesendet-folder fields are now editable comboboxes: a new "Ordnerliste laden" button fetches the real folder list from the IMAP server (needs working credentials first) via LIST, decoding folder names from modified UTF-7 (RFC 3501) so names like "Entwürfe" render correctly instead of as "Entw&APw-rfe". Free text still works -- ensure_imap_folder() creates the folder on first use if it doesn't exist yet, checked once per batch rather than before every single message. mail_delivery.append_to_imap_drafts() became the more general append_message(client, content, folder=, flags=), reused for both the \Draft and \Seen cases. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
255 lines
8.3 KiB
Python
255 lines
8.3 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"] = []
|
|
default_list_result: tuple = ("OK", [])
|
|
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"])
|
|
self.selected: list[str] = []
|
|
self.select_result = ("OK", [b"1"])
|
|
self.created: list[str] = []
|
|
self.create_result = ("OK", [b"CREATE completed"])
|
|
self.list_result = _FakeImapClient.default_list_result
|
|
_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 select(self, folder, readonly=False):
|
|
self.selected.append(folder)
|
|
self.calls.append("select")
|
|
return self.select_result
|
|
|
|
def create(self, folder):
|
|
self.created.append(folder)
|
|
self.calls.append("create")
|
|
return self.create_result
|
|
|
|
def list(self):
|
|
self.calls.append("list")
|
|
return self.list_result
|
|
|
|
def logout(self):
|
|
self.calls.append("logout")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_fakes():
|
|
_FakeSmtpClient.instances.clear()
|
|
_FakeImapClient.instances.clear()
|
|
_FakeImapClient.default_list_result = ("OK", [])
|
|
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_message_uses_configured_folder_and_flags(monkeypatch) -> None:
|
|
_patch_imap(monkeypatch)
|
|
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
|
mail_delivery.append_message(client, MESSAGE, folder="INBOX.Entwürfe", flags=r"(\Draft)")
|
|
|
|
fake = _FakeImapClient.instances[0]
|
|
assert fake.login_args == ("board@example.org", "secret")
|
|
# The folder name travels the wire in modified UTF-7, not raw UTF-8.
|
|
assert fake.appended == [("INBOX.Entw&APw-rfe", r"(\Draft)", None, MESSAGE)]
|
|
assert fake.calls == ["login", "append", "logout"]
|
|
|
|
|
|
def test_append_message_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_message(client, MESSAGE, folder="Missing", flags=r"(\Seen)")
|
|
|
|
|
|
def test_ensure_imap_folder_skips_creation_when_folder_already_exists(monkeypatch) -> None:
|
|
_patch_imap(monkeypatch)
|
|
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
|
mail_delivery.ensure_imap_folder(client, "INBOX.Sent")
|
|
|
|
fake = _FakeImapClient.instances[0]
|
|
assert fake.selected == ["INBOX.Sent"]
|
|
assert fake.created == []
|
|
|
|
|
|
def test_ensure_imap_folder_creates_missing_folder(monkeypatch) -> None:
|
|
_patch_imap(monkeypatch)
|
|
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
|
client.select_result = ("NO", [b"Mailbox does not exist"])
|
|
mail_delivery.ensure_imap_folder(client, "Archiv.Neu")
|
|
|
|
fake = _FakeImapClient.instances[0]
|
|
assert fake.selected == ["Archiv.Neu"]
|
|
assert fake.created == ["Archiv.Neu"]
|
|
|
|
|
|
def test_ensure_imap_folder_raises_when_creation_fails(monkeypatch) -> None:
|
|
_patch_imap(monkeypatch)
|
|
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
|
client.select_result = ("NO", [b"no such mailbox"])
|
|
client.create_result = ("NO", [b"Permission denied"])
|
|
with pytest.raises(RepositoryError, match="Permission denied"):
|
|
mail_delivery.ensure_imap_folder(client, "Verboten")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("plain", "wire"),
|
|
[
|
|
("INBOX", "INBOX"),
|
|
("Entwürfe", "Entw&APw-rfe"),
|
|
("INBOX.Entwürfe", "INBOX.Entw&APw-rfe"),
|
|
("Test & Foo", "Test &- Foo"),
|
|
],
|
|
)
|
|
def test_imap_utf7_round_trips(plain, wire) -> None:
|
|
assert mail_delivery._encode_imap_utf7(plain) == wire
|
|
assert mail_delivery._decode_imap_utf7(wire) == plain
|
|
|
|
|
|
def test_list_imap_folders_parses_and_decodes_list_response(monkeypatch) -> None:
|
|
_patch_imap(monkeypatch)
|
|
_FakeImapClient.default_list_result = (
|
|
"OK",
|
|
[
|
|
rb'(\HasNoChildren) "." INBOX',
|
|
rb'(\HasNoChildren) "." INBOX.Entw&APw-rfe',
|
|
rb'(\HasNoChildren) "." "INBOX.Gesendete Objekte"',
|
|
],
|
|
)
|
|
|
|
folders = mail_delivery.list_imap_folders(IMAP_SETTINGS)
|
|
|
|
assert folders == ["INBOX", "INBOX.Entwürfe", "INBOX.Gesendete Objekte"]
|
|
assert _FakeImapClient.instances[0].calls == ["login", "list", "logout"]
|
|
|
|
|
|
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"]
|