Add configurable e-mail delivery: direct SMTP send or IMAP drafts

Mahnungs- and SEPA-info-mails could previously only be saved as a local .eml
file that still had to be manually imported into Thunderbird. Add a per-store
"E-Mail-Versand" configuration (Optionen -> E-Mail-Versand, stored in
repository.json alongside the rest of the club's settings, since different
stores may use different mailboxes) with four delivery modes:

- "Lokal speichern": today's behaviour, unchanged default for existing stores.
- "Direkt versenden": sends via SMTP.
- "Als Entwurf ablegen": IMAP APPENDs into a configurable drafts folder, so it
  shows up live in whatever mail client is already watching that account.
- "Jedes Mal fragen": prompts once per generation action (not per e-mail --
  a SEPA batch can cover dozens of members) with Senden/Entwürfe/Abbrechen.

New ccma.services.mail_delivery module (smtplib/imaplib, no new dependency)
opens one authenticated connection per batch and reuses it across all
messages instead of reconnecting per recipient. Both "Verbindung testen"
buttons in Options exercise the same connection path used for real delivery.
The archived per-member copy of every generated e-mail is unaffected and
still always written regardless of delivery mode.

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 b7554478ac
commit 6b0da82b45
13 changed files with 1030 additions and 88 deletions
+23 -10
View File
@@ -25,6 +25,7 @@ from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.services.reminder_mail import generate_and_send_reminder_mail
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
from ccma.ui.labels import (
CLAIM_ITEM_TYPE_LABELS,
REMINDER_CHANNEL_LABELS,
@@ -1258,22 +1259,29 @@ class ReminderMailDialog(tk.Toplevel):
self.grab_set()
def _generate(self) -> None:
selected = filedialog.asksaveasfilename(
parent=self,
title="Mahnung als Thunderbird-Entwurf speichern",
defaultextension=".eml",
initialfile=f"{self.reminder_name}.eml",
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
)
if not selected:
delivery_mode = resolve_delivery_mode(self, self.repository)
if delivery_mode is None:
return
output_path = None
if delivery_mode == "local":
selected = filedialog.asksaveasfilename(
parent=self,
title="Mahnung als Thunderbird-Entwurf speichern",
defaultextension=".eml",
initialfile=f"{self.reminder_name}.eml",
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
)
if not selected:
return
output_path = selected
try:
result = generate_and_send_reminder_mail(
self.repository,
self.member_id,
self.claim_id,
self.reminder_id,
output_path=selected,
delivery_mode=delivery_mode,
output_path=output_path,
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
@@ -1281,9 +1289,14 @@ class ReminderMailDialog(tk.Toplevel):
except (OSError, RepositoryError) as exc:
messagebox.showerror("Mahnung konnte nicht erzeugt werden", str(exc), parent=self)
return
outcome = {
"local": f"gespeichert:\n{result.export_path}",
"send": "direkt versandt.",
"drafts": "als Entwurf im Postfach abgelegt.",
}[delivery_mode]
messagebox.showinfo(
"Mahnung erzeugt",
f"Die E-Mail wurde gespeichert und als versandt gebucht:\n{result.export_path}",
f"Die E-Mail wurde {outcome}\n\nDie Mahnung wurde als versandt gebucht.",
parent=self,
)
self.destroy()
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
from ccma.storage.repository import MemberRepository
class EmailDeliveryChoiceDialog(tk.Toplevel):
"""Shown once per generation action (not per individual e-mail) when the
repository's delivery mode is "ask" -- lets the board decide per-batch whether
to send immediately or park the e-mail(s) as a draft for later review."""
def __init__(self, master: tk.Misc, *, can_send: bool, can_draft: bool):
super().__init__(master)
self.result: str | None = None
self.title("E-Mail-Versand")
self.transient(master.winfo_toplevel())
self.resizable(False, False)
frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True)
ttk.Label(
frame,
text="Wie soll mit dieser E-Mail verfahren werden?",
wraplength=360,
justify="left",
).pack(anchor="w", pady=(0, 14))
buttons = ttk.Frame(frame)
buttons.pack(anchor="e")
ttk.Button(buttons, text="Abbrechen", command=self._cancel).pack(side="left", padx=(0, 8))
send_button = ttk.Button(buttons, text="Senden", command=self._choose_send)
send_button.pack(side="left", padx=(0, 8))
send_button.configure(state="normal" if can_send else "disabled")
draft_button = ttk.Button(
buttons, text="Als Entwurf ablegen", style="Accent.TButton", command=self._choose_drafts
)
draft_button.pack(side="left")
draft_button.configure(state="normal" if can_draft else "disabled")
self.bind("<Escape>", lambda _event: self._cancel())
self.protocol("WM_DELETE_WINDOW", self._cancel)
self.after_idle(self.grab_set)
def _choose_send(self) -> None:
self.result = "send"
self.destroy()
def _choose_drafts(self) -> None:
self.result = "drafts"
self.destroy()
def _cancel(self) -> None:
self.result = None
self.destroy()
def resolve_delivery_mode(master: tk.Misc, repository: MemberRepository) -> str | None:
"""Returns the delivery mode to use for the next generated e-mail(s): "local",
"send" or "drafts" -- or None if the board cancelled out of the "ask every time"
prompt, in which case the caller should abort without generating anything."""
settings = repository.get_email_settings()
mode = settings["delivery_mode"]
if mode in {"local", "send", "drafts"}:
return mode
dialog = EmailDeliveryChoiceDialog(
master, can_send=bool(settings["smtp_host"]), can_draft=bool(settings["imap_host"])
)
master.wait_window(dialog)
return dialog.result
+13
View File
@@ -30,6 +30,19 @@ REMINDER_CHANNEL_LABELS = {
"personal": "Persönlich",
}
EMAIL_DELIVERY_MODE_LABELS = {
"local": "Lokal speichern (bisheriges Verhalten)",
"send": "Direkt versenden",
"drafts": "Als Entwurf im Postfach ablegen",
"ask": "Jedes Mal fragen",
}
EMAIL_SECURITY_LABELS = {
"starttls": "STARTTLS",
"ssl": "SSL/TLS",
"none": "Keine",
}
def display_label(labels: Mapping[str, str], key: str) -> str:
return labels.get(key, key)
+182 -1
View File
@@ -12,10 +12,17 @@ from ccma.services.intervals import (
IntervalValidationError,
normalize_anniversary_intervals,
)
from ccma.services.mail_delivery import 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
from ccma.ui.labels import THEME_LABELS, display_label, storage_key
from ccma.ui.labels import (
EMAIL_DELIVERY_MODE_LABELS,
EMAIL_SECURITY_LABELS,
THEME_LABELS,
display_label,
storage_key,
)
class OptionsDialog(tk.Toplevel):
@@ -94,6 +101,7 @@ class OptionsDialog(tk.Toplevel):
organization = ttk.Frame(notebook, padding=16)
automation = ttk.Frame(notebook, padding=16)
reminders = ttk.Frame(notebook, padding=16)
email = ttk.Frame(notebook, padding=16)
changelog = ChangelogView(notebook)
notebook.add(paths, text="Pfade")
notebook.add(appearance, text="Darstellung")
@@ -101,6 +109,7 @@ class OptionsDialog(tk.Toplevel):
notebook.add(organization, text="Verein / Absender")
notebook.add(automation, text="Hausmeister")
notebook.add(reminders, text="Mahnungen")
notebook.add(email, text="E-Mail-Versand")
notebook.add(changelog, text="Changelog")
self._build_paths(paths)
self._build_appearance(appearance)
@@ -108,6 +117,7 @@ class OptionsDialog(tk.Toplevel):
self._build_organization(organization)
self._build_automation(automation)
self._build_reminders(reminders)
self._build_email(email)
buttons = ttk.Frame(root)
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
@@ -576,6 +586,167 @@ class OptionsDialog(tk.Toplevel):
del self.standard_items[int(selected[0])]
self._refresh_standard_items()
def _build_email(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
email_settings = self.repository.get_email_settings()
self.email_delivery_mode_var = tk.StringVar(
value=display_label(EMAIL_DELIVERY_MODE_LABELS, email_settings["delivery_mode"])
)
ttk.Label(parent, text="Versandmodus").grid(row=0, column=0, sticky="w", pady=6)
ttk.Combobox(
parent,
textvariable=self.email_delivery_mode_var,
values=list(EMAIL_DELIVERY_MODE_LABELS.values()),
state="readonly",
width=42,
).grid(row=0, column=1, sticky="w", pady=6)
ttk.Label(
parent,
text=(
"Gilt für Mahnungs- und SEPA-Info-Mails. „Jedes Mal fragen“ zeigt vor jeder "
"Erzeugung eine Auswahl zwischen Senden und Entwurf ablegen."
),
style="Muted.TLabel",
wraplength=680,
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
smtp_frame = ttk.LabelFrame(parent, text="SMTP (zum direkten Versenden)", padding=10)
smtp_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(4, 0))
smtp_frame.columnconfigure(1, weight=1)
self.smtp_host_var = tk.StringVar(value=email_settings["smtp_host"])
self.smtp_port_var = tk.StringVar(value=str(email_settings["smtp_port"]))
self.smtp_security_var = tk.StringVar(
value=display_label(EMAIL_SECURITY_LABELS, email_settings["smtp_security"])
)
self.smtp_username_var = tk.StringVar(value=email_settings["smtp_username"])
self.smtp_password_var = tk.StringVar(value=email_settings["smtp_password"])
ttk.Label(smtp_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_host_var).grid(
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
)
ttk.Label(smtp_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_port_var, width=8).grid(row=0, column=3, pady=4)
ttk.Label(smtp_frame, text="Verschlüsselung").grid(
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
)
ttk.Combobox(
smtp_frame,
textvariable=self.smtp_security_var,
values=list(EMAIL_SECURITY_LABELS.values()),
state="readonly",
width=14,
).grid(row=1, column=1, sticky="w", pady=4)
ttk.Label(smtp_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_username_var).grid(
row=2, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Label(smtp_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_password_var, show="").grid(
row=3, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Button(smtp_frame, text="Verbindung testen", command=self._test_smtp_connection).grid(
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.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"])
self.imap_port_var = tk.StringVar(value=str(email_settings["imap_port"]))
self.imap_security_var = tk.StringVar(
value=display_label(EMAIL_SECURITY_LABELS, email_settings["imap_security"])
)
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"])
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)
)
ttk.Label(imap_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_port_var, width=8).grid(row=0, column=3, pady=4)
ttk.Label(imap_frame, text="Verschlüsselung").grid(
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
)
ttk.Combobox(
imap_frame,
textvariable=self.imap_security_var,
values=list(EMAIL_SECURITY_LABELS.values()),
state="readonly",
width=14,
).grid(row=1, column=1, sticky="w", pady=4)
ttk.Label(imap_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_username_var).grid(
row=2, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Label(imap_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_password_var, show="").grid(
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)
)
ttk.Label(
parent,
text=(
"Server, Zugangsdaten und Passwörter werden mit den übrigen Vereinseinstellungen "
"in repository.json innerhalb dieses Mitglieder-Stores gespeichert -- pro Store "
"lässt sich so ein eigenes Postfach hinterlegen."
),
style="Muted.TLabel",
wraplength=680,
).grid(row=4, column=0, columnspan=2, sticky="w", pady=(12, 0))
def _collect_email_settings(self) -> dict:
return {
"delivery_mode": storage_key(EMAIL_DELIVERY_MODE_LABELS, self.email_delivery_mode_var.get()),
"smtp_host": self.smtp_host_var.get(),
"smtp_port": self.smtp_port_var.get(),
"smtp_security": storage_key(EMAIL_SECURITY_LABELS, self.smtp_security_var.get()),
"smtp_username": self.smtp_username_var.get(),
"smtp_password": self.smtp_password_var.get(),
"imap_host": self.imap_host_var.get(),
"imap_port": self.imap_port_var.get(),
"imap_security": storage_key(EMAIL_SECURITY_LABELS, self.imap_security_var.get()),
"imap_username": self.imap_username_var.get(),
"imap_password": self.imap_password_var.get(),
"imap_drafts_folder": self.imap_drafts_folder_var.get(),
}
def _test_smtp_connection(self) -> None:
settings = self._collect_email_settings()
try:
settings["smtp_port"] = int(settings["smtp_port"])
except ValueError:
messagebox.showerror("Ungültiger Port", "Der SMTP-Port muss eine ganze Zahl sein.", parent=self)
return
try:
test_smtp_connection(settings)
except RepositoryError as exc:
messagebox.showerror("SMTP-Verbindung fehlgeschlagen", str(exc), parent=self)
return
messagebox.showinfo("SMTP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
def _test_imap_connection(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:
test_imap_connection(settings)
except RepositoryError as exc:
messagebox.showerror("IMAP-Verbindung fehlgeschlagen", str(exc), parent=self)
return
messagebox.showinfo("IMAP-Verbindung", "Verbindung und Anmeldung erfolgreich.", 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
@@ -621,6 +792,15 @@ class OptionsDialog(tk.Toplevel):
except (ValueError, IntervalValidationError) as exc:
messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), parent=self)
return
email_settings = self._collect_email_settings()
try:
email_settings["smtp_port"] = int(email_settings["smtp_port"])
email_settings["imap_port"] = int(email_settings["imap_port"])
except ValueError:
messagebox.showerror(
"Ungültige E-Mail-Einstellung", "SMTP-/IMAP-Port müssen ganze Zahlen sein.", parent=self
)
return
store_text = self.store_var.get().strip()
if not store_text:
messagebox.showerror(
@@ -685,6 +865,7 @@ class OptionsDialog(tk.Toplevel):
levels=self.levels,
standard_fee_items=self.standard_items,
)
self.repository.save_email_settings(**email_settings)
except (OSError, RepositoryError) as exc:
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
return
+22 -12
View File
@@ -10,6 +10,7 @@ from ccma.domain.dates import DateValidationError, normalize_date_input
from ccma.services.sepa import pending_direct_debits, write_csv, write_pain008
from ccma.services.sepa_mail import generate_debit_mails
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
def _next_weekday(selected: date) -> date:
@@ -277,26 +278,33 @@ class SepaMailExportDialog(tk.Toplevel):
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
ttk.Button(
buttons,
text="Ordner wählen und Entwürfe erzeugen",
text="Info-Mails erzeugen",
style="Accent.TButton",
command=self._generate,
).pack(side="left", padx=(8, 0))
self.grab_set()
def _generate(self) -> None:
selected = filedialog.askdirectory(
parent=self,
title="Ausgabeordner für Thunderbird-Entwürfe auswählen",
mustexist=True,
)
if not selected:
delivery_mode = resolve_delivery_mode(self, self.repository)
if delivery_mode is None:
return
output_directory = None
if delivery_mode == "local":
selected = filedialog.askdirectory(
parent=self,
title="Ausgabeordner für Thunderbird-Entwürfe auswählen",
mustexist=True,
)
if not selected:
return
output_directory = selected
try:
generated, warnings = generate_debit_mails(
self.repository,
self.debits,
collection_date=self.collection_date,
output_directory=selected,
delivery_mode=delivery_mode,
output_directory=output_directory,
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
@@ -304,10 +312,12 @@ class SepaMailExportDialog(tk.Toplevel):
except (OSError, RepositoryError) as exc:
messagebox.showerror("Info-Mails konnten nicht erzeugt werden", str(exc), parent=self)
return
detail = (
f"{len(generated)} Entwurf/Entwürfe wurden in\n{selected}\nerzeugt und in den "
"Mitgliederakten archiviert."
)
location = {
"local": f"in\n{output_directory}\nerzeugt",
"send": "direkt versandt",
"drafts": "als Entwurf im Postfach abgelegt",
}[delivery_mode]
detail = f"{len(generated)} Info-Mail(s) wurden {location} und in den Mitgliederakten archiviert."
if warnings:
detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12])
messagebox.showinfo("Info-Mails erzeugt", detail, parent=self)