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
+19 -4
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import os
import re
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime
from email.message import EmailMessage
@@ -12,7 +13,13 @@ from pathlib import Path
from ccma.domain.contributions import claim_balance
from ccma.domain.dates import format_date_for_display
from ccma.services.mail_delivery import append_to_imap_drafts, imap_session, send_via_smtp, smtp_session
from ccma.services.mail_delivery import (
append_message,
ensure_imap_folder,
imap_session,
send_via_smtp,
smtp_session,
)
from ccma.storage.repository import MemberRepository, RepositoryError
@@ -178,12 +185,20 @@ def generate_and_send_reminder_mail(
export_temp.unlink(missing_ok=True)
export_path = target
elif delivery_mode == "send":
with smtp_session(repository.get_email_settings()) as client:
send_via_smtp(client, content)
settings = repository.get_email_settings()
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
send_via_smtp(smtp_client, content)
if imap_client is not None:
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
append_message(
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
)
else:
settings = repository.get_email_settings()
with imap_session(settings) as client:
append_to_imap_drafts(client, content, folder=settings["imap_drafts_folder"])
ensure_imap_folder(client, settings["imap_drafts_folder"])
append_message(client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)")
os.replace(archive_temp, archive_path)
finally:
archive_temp.unlink(missing_ok=True)
+29 -9
View File
@@ -10,7 +10,13 @@ from email.policy import SMTP
from email.utils import format_datetime, make_msgid
from pathlib import Path
from ccma.services.mail_delivery import append_to_imap_drafts, imap_session, send_via_smtp, smtp_session
from ccma.services.mail_delivery import (
append_message,
ensure_imap_folder,
imap_session,
send_via_smtp,
smtp_session,
)
from ccma.services.sepa import DirectDebit
from ccma.storage.repository import MemberRepository, RepositoryError
@@ -105,18 +111,21 @@ def generate_debit_mails(
) -> tuple[list[GeneratedDebitMail], list[str]]:
output: Path | None = None
email_settings: dict | None = None
smtp_ctx = nullcontext(None)
imap_ctx = nullcontext(None)
if delivery_mode == "local":
if not output_directory:
raise RepositoryError("Kein Zielordner für die lokale Ablage angegeben.")
output = Path(output_directory)
output.mkdir(parents=True, exist_ok=True)
session = nullcontext(None)
elif delivery_mode == "send":
email_settings = repository.get_email_settings()
session = smtp_session(email_settings)
smtp_ctx = smtp_session(email_settings)
if email_settings["imap_sent_enabled"]:
imap_ctx = imap_session(email_settings)
elif delivery_mode == "drafts":
email_settings = repository.get_email_settings()
session = imap_session(email_settings)
imap_ctx = imap_session(email_settings)
else:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
@@ -126,9 +135,14 @@ def generate_debit_mails(
raise RepositoryError("In den Optionen fehlt die Gläubiger-ID.")
generated: list[GeneratedDebitMail] = []
warnings: list[str] = []
# One connection is opened for the whole batch (a run can cover dozens of
# members) instead of reconnecting/re-authenticating per recipient.
with session as client:
# One connection (or two, for "send" with a Sent-folder copy) is opened for the
# whole batch -- a run can cover dozens of members -- instead of reconnecting/
# re-authenticating per recipient.
with smtp_ctx as smtp_client, imap_ctx as imap_client:
if delivery_mode == "drafts":
ensure_imap_folder(imap_client, email_settings["imap_drafts_folder"])
elif delivery_mode == "send" and imap_client is not None:
ensure_imap_folder(imap_client, email_settings["imap_sent_folder"])
for debit in debits:
member = repository.get_member(debit.member_id)
if not member.email.strip():
@@ -156,9 +170,15 @@ def generate_debit_mails(
export_path = _available_path(output, filename)
export_path.write_bytes(content)
elif delivery_mode == "send":
send_via_smtp(client, content)
send_via_smtp(smtp_client, content)
if imap_client is not None:
append_message(
imap_client, content, folder=email_settings["imap_sent_folder"], flags=r"(\Seen)"
)
else:
append_to_imap_drafts(client, content, folder=email_settings["imap_drafts_folder"])
append_message(
imap_client, content, folder=email_settings["imap_drafts_folder"], flags=r"(\Draft)"
)
try:
archive_path.write_bytes(content)
except OSError:
+14
View File
@@ -154,6 +154,10 @@ DEFAULT_CONFIGURATION = {
"imap_username": "",
"imap_password": "",
"imap_drafts_folder": "INBOX.Entwürfe",
# Sent-copy applies only to "send" (a direct SMTP send has no server-side
# "Sent" copy on its own, unlike drafts which are inherently server-side).
"imap_sent_enabled": False,
"imap_sent_folder": "INBOX.Sent",
},
}
@@ -2660,6 +2664,8 @@ class MemberRepository:
"imap_drafts_folder": str(
settings.get("imap_drafts_folder", defaults["imap_drafts_folder"])
),
"imap_sent_enabled": bool(settings.get("imap_sent_enabled", defaults["imap_sent_enabled"])),
"imap_sent_folder": str(settings.get("imap_sent_folder", defaults["imap_sent_folder"])),
}
def save_email_settings(
@@ -2677,6 +2683,8 @@ class MemberRepository:
imap_username: str,
imap_password: str,
imap_drafts_folder: str,
imap_sent_enabled: bool = False,
imap_sent_folder: str = "",
) -> None:
if delivery_mode not in {"local", "send", "drafts", "ask"}:
raise RepositoryError("Ungültiger Versandmodus.")
@@ -2688,6 +2696,10 @@ class MemberRepository:
raise RepositoryError("Für den direkten Versand ist ein SMTP-Server erforderlich.")
if delivery_mode in {"drafts", "ask"} and not imap_host.strip():
raise RepositoryError("Für die Ablage als Entwurf ist ein IMAP-Server erforderlich.")
if imap_sent_enabled and not imap_host.strip():
raise RepositoryError(
"Für die Ablage im Gesendet-Ordner ist ein IMAP-Server erforderlich."
)
for label, port in (("Der SMTP-Port", smtp_port), ("Der IMAP-Port", imap_port)):
if port < 1 or port > 65535:
raise RepositoryError(f"{label} muss zwischen 1 und 65535 liegen.")
@@ -2705,6 +2717,8 @@ class MemberRepository:
"imap_username": imap_username.strip(),
"imap_password": imap_password,
"imap_drafts_folder": imap_drafts_folder.strip() or "INBOX.Entwürfe",
"imap_sent_enabled": bool(imap_sent_enabled),
"imap_sent_folder": imap_sent_folder.strip() or "INBOX.Sent",
}
write_json_atomic(self.root / "repository.json", config)
+48 -7
View File
@@ -12,7 +12,7 @@ from ccma.services.intervals import (
IntervalValidationError,
normalize_anniversary_intervals,
)
from ccma.services.mail_delivery import test_imap_connection, test_smtp_connection
from ccma.services.mail_delivery import list_imap_folders, test_imap_connection, test_smtp_connection
from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern
from ccma.ui.changelog_view import ChangelogView
from ccma.ui.icons import IconStore
@@ -649,7 +649,7 @@ class OptionsDialog(tk.Toplevel):
row=4, column=0, columnspan=4, sticky="w", pady=(8, 0)
)
imap_frame = ttk.LabelFrame(parent, text="IMAP (für die Ablage als Entwurf)", padding=10)
imap_frame = ttk.LabelFrame(parent, text="IMAP (für Entwürfe & Gesendet)", padding=10)
imap_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(16, 0))
imap_frame.columnconfigure(1, weight=1)
self.imap_host_var = tk.StringVar(value=email_settings["imap_host"])
@@ -660,6 +660,8 @@ class OptionsDialog(tk.Toplevel):
self.imap_username_var = tk.StringVar(value=email_settings["imap_username"])
self.imap_password_var = tk.StringVar(value=email_settings["imap_password"])
self.imap_drafts_folder_var = tk.StringVar(value=email_settings["imap_drafts_folder"])
self.imap_sent_enabled_var = tk.BooleanVar(value=email_settings["imap_sent_enabled"])
self.imap_sent_folder_var = tk.StringVar(value=email_settings["imap_sent_folder"])
ttk.Label(imap_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_host_var).grid(
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
@@ -685,12 +687,33 @@ class OptionsDialog(tk.Toplevel):
row=3, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Label(imap_frame, text="Entwürfe-Ordner").grid(row=4, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_drafts_folder_var).grid(
row=4, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Button(imap_frame, text="Verbindung testen", command=self._test_imap_connection).grid(
row=5, column=0, columnspan=4, sticky="w", pady=(8, 0)
self.imap_drafts_folder_combo = ttk.Combobox(imap_frame, textvariable=self.imap_drafts_folder_var)
self.imap_drafts_folder_combo.grid(row=4, column=1, columnspan=3, sticky="ew", pady=4)
ttk.Checkbutton(
imap_frame,
text="Direkt versandte Mahnungen/Info-Mails zusätzlich in einen Gesendet-Ordner ablegen",
variable=self.imap_sent_enabled_var,
).grid(row=5, column=0, columnspan=4, sticky="w", pady=(8, 4))
ttk.Label(imap_frame, text="Gesendet-Ordner").grid(row=6, column=0, sticky="w", pady=4, padx=(0, 8))
self.imap_sent_folder_combo = ttk.Combobox(imap_frame, textvariable=self.imap_sent_folder_var)
self.imap_sent_folder_combo.grid(row=6, column=1, columnspan=3, sticky="ew", pady=4)
buttons_row = ttk.Frame(imap_frame)
buttons_row.grid(row=7, column=0, columnspan=4, sticky="w", pady=(8, 0))
ttk.Button(buttons_row, text="Verbindung testen", command=self._test_imap_connection).pack(
side="left", padx=(0, 8)
)
ttk.Button(buttons_row, text="Ordnerliste laden", command=self._load_imap_folders).pack(side="left")
ttk.Label(
imap_frame,
text=(
"„Ordnerliste laden“ ruft die Ordner live vom Server ab (Zugangsdaten oben müssen "
"dafür bereits ausgefüllt sein) und füllt beide Dropdowns; freie Eingabe bleibt "
"möglich -- ein dabei angegebener, noch nicht vorhandener Ordner wird bei der "
"nächsten Ablage automatisch angelegt."
),
style="Muted.TLabel",
wraplength=660,
).grid(row=8, column=0, columnspan=4, sticky="w", pady=(6, 0))
ttk.Label(
parent,
@@ -717,6 +740,8 @@ class OptionsDialog(tk.Toplevel):
"imap_username": self.imap_username_var.get(),
"imap_password": self.imap_password_var.get(),
"imap_drafts_folder": self.imap_drafts_folder_var.get(),
"imap_sent_enabled": self.imap_sent_enabled_var.get(),
"imap_sent_folder": self.imap_sent_folder_var.get(),
}
def _test_smtp_connection(self) -> None:
@@ -747,6 +772,22 @@ class OptionsDialog(tk.Toplevel):
return
messagebox.showinfo("IMAP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
def _load_imap_folders(self) -> None:
settings = self._collect_email_settings()
try:
settings["imap_port"] = int(settings["imap_port"])
except ValueError:
messagebox.showerror("Ungültiger Port", "Der IMAP-Port muss eine ganze Zahl sein.", parent=self)
return
try:
folders = list_imap_folders(settings)
except RepositoryError as exc:
messagebox.showerror("Ordnerliste konnte nicht geladen werden", str(exc), parent=self)
return
self.imap_drafts_folder_combo.configure(values=folders)
self.imap_sent_folder_combo.configure(values=folders)
messagebox.showinfo("Ordnerliste geladen", f"{len(folders)} Ordner gefunden.", parent=self)
def _choose_store(self) -> None:
current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home()
initial = current if current.is_dir() else current.parent