mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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:
co-authored by
Claude Sonnet 5
parent
b7554478ac
commit
6b0da82b45
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user