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
+117 -3
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import base64
import binascii
import imaplib
import re
import smtplib
import ssl
from collections.abc import Iterator
@@ -90,16 +93,127 @@ def send_via_smtp(client: smtplib.SMTP, message_bytes: bytes) -> None:
raise RepositoryError(f"E-Mail konnte nicht versandt werden: {exc}") from exc
def append_to_imap_drafts(client: imaplib.IMAP4, message_bytes: bytes, *, folder: str) -> None:
def append_message(client: imaplib.IMAP4, message_bytes: bytes, *, folder: str, flags: str) -> None:
try:
status, response = client.append(folder, r"(\Draft)", None, message_bytes)
status, response = client.append(_encode_imap_utf7(folder), flags, None, message_bytes)
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"E-Mail konnte nicht als Entwurf abgelegt werden: {exc}") from exc
raise RepositoryError(f"E-Mail konnte nicht in „{folder} abgelegt werden: {exc}") from exc
if status != "OK":
detail = response[0].decode("utf-8", "replace") if response and response[0] else status
raise RepositoryError(f"IMAP-Server hat die Ablage im Ordner „{folder}“ abgelehnt: {detail}")
def ensure_imap_folder(client: imaplib.IMAP4, folder: str) -> None:
"""Creates the folder on the server if it doesn't exist yet -- lets someone type
a not-yet-existing Entwürfe/Gesendet folder name and have it just appear, instead
of every append failing until it's created by hand in a separate mail client."""
encoded = _encode_imap_utf7(folder)
try:
status, _response = client.select(encoded, readonly=True)
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht geprüft werden: {exc}") from exc
if status == "OK":
return
try:
status, response = client.create(encoded)
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {exc}") from exc
if status != "OK":
detail = response[0].decode("utf-8", "replace") if response and response[0] else status
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {detail}")
_LIST_RESPONSE_RE = re.compile(
rb'^\((?P<flags>[^)]*)\)\s+(?:"(?P<qdelim>[^"]*)"|(?P<delim>\S+))\s+(?P<name>.+)$'
)
def list_imap_folders(settings: dict) -> list[str]:
"""Human-readable (decoded) folder names for the Options dialog's folder picker
-- fetched live from the server, so it only works once credentials are entered."""
with imap_session(settings) as client:
try:
status, response = client.list()
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"Ordnerliste konnte nicht abgerufen werden: {exc}") from exc
if status != "OK":
raise RepositoryError("Ordnerliste konnte nicht abgerufen werden.")
folders = []
for raw in response or []:
if not raw:
continue
name = _parse_list_response(raw)
if name:
folders.append(name)
return folders
def _parse_list_response(raw: bytes) -> str | None:
match = _LIST_RESPONSE_RE.match(raw)
if not match:
return None
name = match.group("name").decode("utf-8", "replace").strip()
if name.startswith('"') and name.endswith('"') and len(name) >= 2:
name = name[1:-1]
return _decode_imap_utf7(name)
def _decode_imap_utf7(value: str) -> str:
# Modified UTF-7 (RFC 3501 5.1.3): "&" takes the role of "+", and "," takes the
# role of "/" inside the base64 run; "&-" is a literal ampersand.
if "&" not in value:
return value
result: list[str] = []
index = 0
length = len(value)
while index < length:
char = value[index]
if char != "&":
result.append(char)
index += 1
continue
end = value.find("-", index + 1)
if end == -1:
end = length
chunk = value[index + 1 : end]
if chunk == "":
result.append("&")
else:
base64_chunk = chunk.replace(",", "/")
base64_chunk += "=" * (-len(base64_chunk) % 4)
try:
result.append(base64.b64decode(base64_chunk).decode("utf-16-be"))
except (binascii.Error, UnicodeDecodeError):
result.append("&" + chunk + "-")
index = end + 1
return "".join(result)
def _encode_imap_utf7(value: str) -> str:
if "&" not in value and all(32 <= ord(char) <= 126 for char in value):
return value
result: list[str] = []
index = 0
length = len(value)
while index < length:
char = value[index]
if char == "&":
result.append("&-")
index += 1
continue
if 32 <= ord(char) <= 126:
result.append(char)
index += 1
continue
start = index
while index < length and not (32 <= ord(value[index]) <= 126):
index += 1
chunk = value[start:index]
encoded = base64.b64encode(chunk.encode("utf-16-be")).decode("ascii").rstrip("=")
result.append("&" + encoded.replace("/", ",") + "-")
return "".join(result)
def test_smtp_connection(settings: dict) -> None:
with smtp_session(settings):
pass