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
+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