Copy directly-sent e-mails to an IMAP Sent folder, with a live folder picker

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>
This commit is contained in:
Marcel Peterkau
2026-08-15 04:34:34 +02:00
co-authored by Claude Sonnet 5
parent 6b0da82b45
commit cc4aaef895
9 changed files with 505 additions and 28 deletions
+88 -5
View File
@@ -33,6 +33,7 @@ class _FakeSmtpClient:
class _FakeImapClient:
instances: list["_FakeImapClient"] = []
default_list_result: tuple = ("OK", [])
error = Exception
def __init__(self, host, port, **kwargs):
@@ -43,6 +44,11 @@ class _FakeImapClient:
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):
@@ -57,6 +63,20 @@ class _FakeImapClient:
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")
@@ -65,6 +85,7 @@ class _FakeImapClient:
def _reset_fakes():
_FakeSmtpClient.instances.clear()
_FakeImapClient.instances.clear()
_FakeImapClient.default_list_result = ("OK", [])
yield
@@ -118,23 +139,85 @@ def test_send_via_smtp_logs_in_and_sends_reusing_one_connection(monkeypatch) ->
assert len(fake.sent_messages) == 2
def test_append_to_imap_drafts_uses_configured_folder(monkeypatch) -> None:
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_to_imap_drafts(client, MESSAGE, folder="INBOX.Entwürfe")
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")
assert fake.appended == [("INBOX.Entwürfe", r"(\Draft)", None, MESSAGE)]
# 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_to_imap_drafts_raises_on_rejected_status(monkeypatch) -> None:
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_to_imap_drafts(client, MESSAGE, folder="Missing")
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: