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
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import imaplib
import smtplib
import ssl
from collections.abc import Iterator
from contextlib import contextmanager, suppress
from email.parser import BytesParser
from email.policy import default as email_default_policy
from ccma.storage.repository import RepositoryError
def _ssl_context() -> ssl.SSLContext:
return ssl.create_default_context()
def _smtp_client(settings: dict) -> smtplib.SMTP:
host = str(settings.get("smtp_host", "")).strip()
if not host:
raise RepositoryError("Kein SMTP-Server konfiguriert.")
port = int(settings.get("smtp_port", 587))
security = str(settings.get("smtp_security", "starttls"))
client = (
smtplib.SMTP_SSL(host, port, context=_ssl_context(), timeout=30)
if security == "ssl"
else smtplib.SMTP(host, port, timeout=30)
)
if security == "starttls":
client.starttls(context=_ssl_context())
username = str(settings.get("smtp_username", "")).strip()
if username:
client.login(username, str(settings.get("smtp_password", "")))
return client
def _imap_client(settings: dict) -> imaplib.IMAP4:
host = str(settings.get("imap_host", "")).strip()
if not host:
raise RepositoryError("Kein IMAP-Server konfiguriert.")
port = int(settings.get("imap_port", 993))
security = str(settings.get("imap_security", "ssl"))
client = (
imaplib.IMAP4_SSL(host, port, timeout=30)
if security == "ssl"
else imaplib.IMAP4(host, port, timeout=30)
)
if security == "starttls":
client.starttls(_ssl_context())
username = str(settings.get("imap_username", "")).strip()
if username:
client.login(username, str(settings.get("imap_password", "")))
return client
@contextmanager
def smtp_session(settings: dict) -> Iterator[smtplib.SMTP]:
"""Opens one authenticated SMTP connection to reuse across several sends (a SEPA
info-mail batch may cover dozens of members -- reconnecting/re-authenticating per
message would be slow and can trigger provider rate limits)."""
try:
client = _smtp_client(settings)
except (OSError, smtplib.SMTPException) as exc:
raise RepositoryError(f"SMTP-Verbindung fehlgeschlagen: {exc}") from exc
try:
yield client
finally:
with suppress(OSError, smtplib.SMTPException):
client.quit()
@contextmanager
def imap_session(settings: dict) -> Iterator[imaplib.IMAP4]:
try:
client = _imap_client(settings)
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"IMAP-Verbindung fehlgeschlagen: {exc}") from exc
try:
yield client
finally:
with suppress(OSError, imaplib.IMAP4.error):
client.logout()
def send_via_smtp(client: smtplib.SMTP, message_bytes: bytes) -> None:
message = BytesParser(policy=email_default_policy).parsebytes(message_bytes)
try:
client.send_message(message)
except (OSError, smtplib.SMTPException) as exc:
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:
try:
status, response = client.append(folder, r"(\Draft)", None, message_bytes)
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"E-Mail konnte nicht als Entwurf 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 test_smtp_connection(settings: dict) -> None:
with smtp_session(settings):
pass
def test_imap_connection(settings: dict) -> None:
with imap_session(settings):
pass
+29 -6
View File
@@ -12,12 +12,15 @@ 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.storage.repository import MemberRepository, RepositoryError
@dataclass(frozen=True, slots=True)
class GeneratedReminderMail:
export_path: Path
# None for "send"/"drafts" delivery -- there is no local file, the mail was
# handed straight to the mail server. Always set for "local" delivery.
export_path: Path | None
archive_path: Path
sha256: str
@@ -115,11 +118,14 @@ def generate_and_send_reminder_mail(
claim_id: str,
reminder_id: str,
*,
output_path: Path | str,
delivery_mode: str,
output_path: Path | str | None = None,
sender_name: str,
sender_email: str,
signature: str,
) -> GeneratedReminderMail:
if delivery_mode not in {"local", "send", "drafts"}:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
member = repository.get_member(member_id)
data, claim = repository.get_claim(member_id, claim_id)
reminder = repository._find_reminder(data, claim_id, reminder_id)
@@ -129,6 +135,10 @@ def generate_and_send_reminder_mail(
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
if not sender_email.strip() or "@" not in sender_email:
raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.")
target: Path | None = None
if delivery_mode == "local":
if not output_path:
raise RepositoryError("Kein Zielpfad für die lokale Ablage angegeben.")
target = Path(output_path)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
@@ -155,15 +165,27 @@ def generate_and_send_reminder_mail(
f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml"
)
archive_path = _available_path(archive_dir, filename)
export_temp = target.with_name(f".{target.name}.tmp")
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
export_path: Path | None = None
try:
archive_temp.write_bytes(content)
if delivery_mode == "local":
export_temp = target.with_name(f".{target.name}.tmp")
try:
export_temp.write_bytes(content)
archive_temp.write_bytes(content)
os.replace(export_temp, target)
os.replace(archive_temp, archive_path)
finally:
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)
else:
settings = repository.get_email_settings()
with imap_session(settings) as client:
append_to_imap_drafts(client, content, folder=settings["imap_drafts_folder"])
os.replace(archive_temp, archive_path)
finally:
archive_temp.unlink(missing_ok=True)
relative_path = archive_path.relative_to(
repository.members_root / member_id / "files"
@@ -184,6 +206,7 @@ def generate_and_send_reminder_mail(
"recipient": member.email,
"sha256": digest,
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
"delivery_mode": delivery_mode,
},
)
return GeneratedReminderMail(target, archive_path, digest)
return GeneratedReminderMail(export_path, archive_path, digest)
+33 -3
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import re
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import date, datetime
from email.message import EmailMessage
@@ -9,6 +10,7 @@ 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.sepa import DirectDebit
from ccma.storage.repository import MemberRepository, RepositoryError
@@ -17,7 +19,8 @@ from ccma.storage.repository import MemberRepository, RepositoryError
class GeneratedDebitMail:
member_id: str
recipient: str
export_path: Path
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
export_path: Path | None
archive_path: Path
@@ -94,19 +97,38 @@ def generate_debit_mails(
debits: list[DirectDebit],
*,
collection_date: date,
output_directory: Path | str,
delivery_mode: str,
output_directory: Path | str | None = None,
sender_name: str,
sender_email: str,
signature: str,
) -> tuple[list[GeneratedDebitMail], list[str]]:
output: Path | None = None
email_settings: dict | None = 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)
elif delivery_mode == "drafts":
email_settings = repository.get_email_settings()
session = imap_session(email_settings)
else:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
organization = repository.get_configuration().get("organization") or {}
creditor_id = str(organization.get("creditor_id", "")).strip()
if not creditor_id:
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:
for debit in debits:
member = repository.get_member(debit.member_id)
if not member.email.strip():
@@ -126,14 +148,21 @@ def generate_debit_mails(
f"SEPA-Info-{collection_date.isoformat()}-"
f"{_safe_filename(member.member_number or member.display_name)}.eml"
)
export_path = _available_path(output, filename)
archive_dir = repository.members_root / member.member_id / "files" / "documents" / "SEPA"
archive_dir.mkdir(parents=True, exist_ok=True)
archive_path = _available_path(archive_dir, filename)
export_path: Path | None = None
if delivery_mode == "local":
export_path = _available_path(output, filename)
export_path.write_bytes(content)
elif delivery_mode == "send":
send_via_smtp(client, content)
else:
append_to_imap_drafts(client, content, folder=email_settings["imap_drafts_folder"])
try:
archive_path.write_bytes(content)
except OSError:
if export_path is not None:
export_path.unlink(missing_ok=True)
raise
relative_path = archive_path.relative_to(
@@ -152,6 +181,7 @@ def generate_debit_mails(
"recipient": member.email,
"collection_date": collection_date.isoformat(),
"amount": f"{debit.amount:.2f}",
"delivery_mode": delivery_mode,
},
)
generated.append(
+101
View File
@@ -138,6 +138,23 @@ DEFAULT_CONFIGURATION = {
],
},
"contribution_rules": DEFAULT_CONTRIBUTION_RULES,
# "local" reproduces the historical behaviour (always ask for a save location,
# never touch the network) so repositories that never configured this stay on
# exactly what they had before -- it is a first-class choice, not just a fallback.
"email_settings": {
"delivery_mode": "local",
"smtp_host": "",
"smtp_port": 587,
"smtp_security": "starttls",
"smtp_username": "",
"smtp_password": "",
"imap_host": "",
"imap_port": 993,
"imap_security": "ssl",
"imap_username": "",
"imap_password": "",
"imap_drafts_folder": "INBOX.Entwürfe",
},
}
@@ -2607,6 +2624,90 @@ class MemberRepository:
}
write_json_atomic(self.root / "repository.json", config)
def get_email_settings(self) -> dict:
config = self.get_configuration()
settings = config.get("email_settings") or {}
defaults = DEFAULT_CONFIGURATION["email_settings"]
delivery_mode = str(settings.get("delivery_mode", defaults["delivery_mode"]))
if delivery_mode not in {"local", "send", "drafts", "ask"}:
delivery_mode = defaults["delivery_mode"]
smtp_security = str(settings.get("smtp_security", defaults["smtp_security"]))
if smtp_security not in {"starttls", "ssl", "none"}:
smtp_security = defaults["smtp_security"]
imap_security = str(settings.get("imap_security", defaults["imap_security"]))
if imap_security not in {"starttls", "ssl", "none"}:
imap_security = defaults["imap_security"]
try:
smtp_port = int(settings.get("smtp_port", defaults["smtp_port"]))
except (TypeError, ValueError):
smtp_port = defaults["smtp_port"]
try:
imap_port = int(settings.get("imap_port", defaults["imap_port"]))
except (TypeError, ValueError):
imap_port = defaults["imap_port"]
return {
"delivery_mode": delivery_mode,
"smtp_host": str(settings.get("smtp_host", "")),
"smtp_port": smtp_port,
"smtp_security": smtp_security,
"smtp_username": str(settings.get("smtp_username", "")),
"smtp_password": str(settings.get("smtp_password", "")),
"imap_host": str(settings.get("imap_host", "")),
"imap_port": imap_port,
"imap_security": imap_security,
"imap_username": str(settings.get("imap_username", "")),
"imap_password": str(settings.get("imap_password", "")),
"imap_drafts_folder": str(
settings.get("imap_drafts_folder", defaults["imap_drafts_folder"])
),
}
def save_email_settings(
self,
*,
delivery_mode: str,
smtp_host: str,
smtp_port: int,
smtp_security: str,
smtp_username: str,
smtp_password: str,
imap_host: str,
imap_port: int,
imap_security: str,
imap_username: str,
imap_password: str,
imap_drafts_folder: str,
) -> None:
if delivery_mode not in {"local", "send", "drafts", "ask"}:
raise RepositoryError("Ungültiger Versandmodus.")
if smtp_security not in {"starttls", "ssl", "none"}:
raise RepositoryError("Ungültige SMTP-Verschlüsselung.")
if imap_security not in {"starttls", "ssl", "none"}:
raise RepositoryError("Ungültige IMAP-Verschlüsselung.")
if delivery_mode in {"send", "ask"} and not smtp_host.strip():
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.")
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.")
config = self.get_configuration()
config["email_settings"] = {
"delivery_mode": delivery_mode,
"smtp_host": smtp_host.strip(),
"smtp_port": int(smtp_port),
"smtp_security": smtp_security,
"smtp_username": smtp_username.strip(),
"smtp_password": smtp_password,
"imap_host": imap_host.strip(),
"imap_port": int(imap_port),
"imap_security": imap_security,
"imap_username": imap_username.strip(),
"imap_password": imap_password,
"imap_drafts_folder": imap_drafts_folder.strip() or "INBOX.Entwürfe",
}
write_json_atomic(self.root / "repository.json", config)
def save_organization(self, values: dict[str, str]) -> None:
organization = {key: str(value).strip() for key, value in values.items()}
organization["iban"] = normalize_iban(organization.get("iban", ""))
+15 -2
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,6 +1259,11 @@ class ReminderMailDialog(tk.Toplevel):
self.grab_set()
def _generate(self) -> None:
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",
@@ -1267,13 +1273,15 @@ class ReminderMailDialog(tk.Toplevel):
)
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
+16 -6
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,13 +278,18 @@ 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:
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",
@@ -291,12 +297,14 @@ class SepaMailExportDialog(tk.Toplevel):
)
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)
+103
View File
@@ -0,0 +1,103 @@
import pytest
from ccma.storage.repository import MemberRepository, RepositoryError
def test_email_settings_default_to_local_delivery_for_new_repositories(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
settings = repository.get_email_settings()
assert settings["delivery_mode"] == "local"
assert settings["smtp_host"] == ""
assert settings["imap_host"] == ""
def test_email_settings_round_trip_through_save_and_get(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.save_email_settings(
delivery_mode="ask",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="starttls",
smtp_username="board@example.org",
smtp_password="secret",
imap_host="mail.example.org",
imap_port=993,
imap_security="ssl",
imap_username="board@example.org",
imap_password="secret",
imap_drafts_folder="INBOX.Entwürfe",
)
settings = repository.get_email_settings()
assert settings["delivery_mode"] == "ask"
assert settings["smtp_host"] == "mail.example.org"
assert settings["smtp_port"] == 587
assert settings["smtp_password"] == "secret"
assert settings["imap_drafts_folder"] == "INBOX.Entwürfe"
@pytest.mark.parametrize(
("overrides", "match"),
[
({"delivery_mode": "carrier-pigeon"}, "Ungültiger Versandmodus"),
({"smtp_security": "rot13"}, "Ungültige SMTP-Verschlüsselung"),
({"imap_security": "rot13"}, "Ungültige IMAP-Verschlüsselung"),
({"delivery_mode": "send", "smtp_host": ""}, "SMTP-Server erforderlich"),
({"delivery_mode": "drafts", "imap_host": ""}, "IMAP-Server erforderlich"),
({"delivery_mode": "ask", "imap_host": ""}, "IMAP-Server erforderlich"),
({"smtp_port": 0}, "SMTP-Port"),
({"smtp_port": 70000}, "SMTP-Port"),
({"imap_port": 0}, "IMAP-Port"),
],
)
def test_email_settings_rejects_invalid_input(tmp_path, overrides, match) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
values = {
"delivery_mode": "send",
"smtp_host": "mail.example.org",
"smtp_port": 587,
"smtp_security": "starttls",
"smtp_username": "",
"smtp_password": "",
"imap_host": "mail.example.org",
"imap_port": 993,
"imap_security": "ssl",
"imap_username": "",
"imap_password": "",
"imap_drafts_folder": "",
}
values.update(overrides)
with pytest.raises(RepositoryError, match=match):
repository.save_email_settings(**values)
def test_email_settings_local_mode_needs_no_server(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.save_email_settings(
delivery_mode="local",
smtp_host="",
smtp_port=587,
smtp_security="starttls",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
settings = repository.get_email_settings()
assert settings["delivery_mode"] == "local"
# Blank folder falls back to a sensible default instead of staying empty.
assert settings["imap_drafts_folder"] == "INBOX.Entwürfe"
+171
View File
@@ -0,0 +1,171 @@
import pytest
from ccma.services import mail_delivery
from ccma.storage.repository import RepositoryError
class _FakeSmtpClient:
instances: list["_FakeSmtpClient"] = []
def __init__(self, host, port, **kwargs):
self.host = host
self.port = port
self.kwargs = kwargs
self.calls: list[str] = []
self.login_args: tuple | None = None
self.sent_messages: list = []
_FakeSmtpClient.instances.append(self)
def starttls(self, **kwargs):
self.calls.append("starttls")
def login(self, username, password):
self.login_args = (username, password)
self.calls.append("login")
def send_message(self, message):
self.sent_messages.append(message)
self.calls.append("send_message")
def quit(self):
self.calls.append("quit")
class _FakeImapClient:
instances: list["_FakeImapClient"] = []
error = Exception
def __init__(self, host, port, **kwargs):
self.host = host
self.port = port
self.kwargs = kwargs
self.calls: list[str] = []
self.login_args: tuple | None = None
self.appended: list[tuple] = []
self.append_result = ("OK", [b"APPEND completed"])
_FakeImapClient.instances.append(self)
def starttls(self, *args, **kwargs):
self.calls.append("starttls")
def login(self, username, password):
self.login_args = (username, password)
self.calls.append("login")
def append(self, folder, flags, date_time, message_bytes):
self.appended.append((folder, flags, date_time, message_bytes))
self.calls.append("append")
return self.append_result
def logout(self):
self.calls.append("logout")
@pytest.fixture(autouse=True)
def _reset_fakes():
_FakeSmtpClient.instances.clear()
_FakeImapClient.instances.clear()
yield
def _patch_smtp(monkeypatch):
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _FakeSmtpClient)
monkeypatch.setattr(mail_delivery.smtplib, "SMTP_SSL", _FakeSmtpClient)
def _patch_imap(monkeypatch):
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4", _FakeImapClient)
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4_SSL", _FakeImapClient)
SMTP_SETTINGS = {
"smtp_host": "mail.example.org",
"smtp_port": 587,
"smtp_security": "starttls",
"smtp_username": "board@example.org",
"smtp_password": "secret",
}
IMAP_SETTINGS = {
"imap_host": "mail.example.org",
"imap_port": 993,
"imap_security": "ssl",
"imap_username": "board@example.org",
"imap_password": "secret",
}
MESSAGE = (
b"From: Board <board@example.org>\r\n"
b"To: member@example.org\r\n"
b"Subject: Test\r\n"
b"\r\n"
b"Hello\r\n"
)
def test_send_via_smtp_logs_in_and_sends_reusing_one_connection(monkeypatch) -> None:
_patch_smtp(monkeypatch)
with mail_delivery.smtp_session(SMTP_SETTINGS) as client:
mail_delivery.send_via_smtp(client, MESSAGE)
mail_delivery.send_via_smtp(client, MESSAGE)
assert len(_FakeSmtpClient.instances) == 1
fake = _FakeSmtpClient.instances[0]
assert fake.host == "mail.example.org"
assert fake.port == 587
assert fake.login_args == ("board@example.org", "secret")
assert fake.calls == ["starttls", "login", "send_message", "send_message", "quit"]
assert len(fake.sent_messages) == 2
def test_append_to_imap_drafts_uses_configured_folder(monkeypatch) -> None:
_patch_imap(monkeypatch)
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
mail_delivery.append_to_imap_drafts(client, MESSAGE, folder="INBOX.Entwürfe")
fake = _FakeImapClient.instances[0]
assert fake.login_args == ("board@example.org", "secret")
assert fake.appended == [("INBOX.Entwürfe", r"(\Draft)", None, MESSAGE)]
assert fake.calls == ["login", "append", "logout"]
def test_append_to_imap_drafts_raises_on_rejected_status(monkeypatch) -> None:
_patch_imap(monkeypatch)
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
client.append_result = ("NO", [b"Mailbox does not exist"])
with pytest.raises(RepositoryError, match="Mailbox does not exist"):
mail_delivery.append_to_imap_drafts(client, MESSAGE, folder="Missing")
def test_smtp_session_wraps_connection_errors(monkeypatch) -> None:
def _boom(*args, **kwargs):
raise OSError("connection refused")
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _boom)
with pytest.raises(RepositoryError, match="SMTP-Verbindung fehlgeschlagen"):
with mail_delivery.smtp_session({**SMTP_SETTINGS, "smtp_security": "none"}):
pass
def test_smtp_session_requires_host() -> None:
with pytest.raises(RepositoryError, match="Kein SMTP-Server konfiguriert"):
with mail_delivery.smtp_session({}):
pass
def test_imap_session_requires_host() -> None:
with pytest.raises(RepositoryError, match="Kein IMAP-Server konfiguriert"):
with mail_delivery.imap_session({}):
pass
def test_test_smtp_connection_succeeds_and_closes(monkeypatch) -> None:
_patch_smtp(monkeypatch)
mail_delivery.test_smtp_connection(SMTP_SETTINGS)
assert _FakeSmtpClient.instances[0].calls == ["starttls", "login", "quit"]
def test_test_imap_connection_succeeds_and_closes(monkeypatch) -> None:
_patch_imap(monkeypatch)
mail_delivery.test_imap_connection(IMAP_SETTINGS)
assert _FakeImapClient.instances[0].calls == ["login", "logout"]
+56
View File
@@ -1,3 +1,4 @@
from contextlib import contextmanager
from email.parser import BytesParser
from email.policy import default
@@ -35,6 +36,7 @@ def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="local",
output_path=export_path,
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
@@ -72,6 +74,7 @@ def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="local",
output_path=tmp_path / "Mahnung.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
@@ -81,3 +84,56 @@ def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
assert "Offener Betrag: 105.00 Euro" in message.get_content()
assert "Mahngebühr: 5.00 Euro" in message.get_content()
def test_reminder_mail_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
import ccma.services.reminder_mail as reminder_mail_module
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
)
sent = []
fake_smtp_session = contextmanager(lambda settings: iter([object()]))
monkeypatch.setattr(reminder_mail_module, "smtp_session", fake_smtp_session)
monkeypatch.setattr(
reminder_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
)
generated = generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert generated.export_path is None
assert generated.archive_path.is_file()
assert len(sent) == 1
data = repository.get_contributions(member.member_id)
assert data.reminders[0]["status"] == "sent"
+63
View File
@@ -1,4 +1,5 @@
import xml.etree.ElementTree as ET
from contextlib import contextmanager
from datetime import UTC, date, datetime
from decimal import Decimal
from email.parser import BytesParser
@@ -214,6 +215,7 @@ def test_generated_mail_is_exported_archived_and_logged(tmp_path):
repository,
debits,
collection_date=date(2026, 8, 3),
delivery_mode="local",
output_directory=tmp_path / "mail-export",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
@@ -227,3 +229,64 @@ def test_generated_mail_is_exported_archived_and_logged(tmp_path):
event = repository.get_events(member.member_id)[-1]
assert event.event_type == "sepa_notification_generated"
assert event.references["document"].startswith("documents/SEPA/")
def test_generated_mail_send_mode_reuses_one_smtp_connection_for_batch(tmp_path, monkeypatch):
import ccma.services.sepa_mail as sepa_mail_module
repository, member = _repository(tmp_path)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
)
repository.save_organization(organization)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
connections_opened = []
@contextmanager
def fake_smtp_session(settings):
connections_opened.append(settings)
yield object()
sent = []
monkeypatch.setattr(sepa_mail_module, "smtp_session", fake_smtp_session)
monkeypatch.setattr(
sepa_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
)
generated, warnings = generate_debit_mails(
repository,
debits,
collection_date=date(2026, 8, 3),
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert warnings == []
assert generated[0].export_path is None
assert generated[0].archive_path.is_file()
assert len(connections_opened) == 1, "one connection should be reused for the whole batch"
assert len(sent) == len(debits)