diff --git a/src/ccma/services/mail_delivery.py b/src/ccma/services/mail_delivery.py new file mode 100644 index 0000000..344671a --- /dev/null +++ b/src/ccma/services/mail_delivery.py @@ -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 diff --git a/src/ccma/services/reminder_mail.py b/src/ccma/services/reminder_mail.py index 616da47..be714ff 100644 --- a/src/ccma/services/reminder_mail.py +++ b/src/ccma/services/reminder_mail.py @@ -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,10 +135,14 @@ 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(output_path) - target.parent.mkdir(parents=True, exist_ok=True) - if target.exists(): - raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}") + 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(): + raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}") sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id) updated_data, updated_claim = repository.get_claim(member_id, claim_id) @@ -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: - export_temp.write_bytes(content) archive_temp.write_bytes(content) - os.replace(export_temp, target) + if delivery_mode == "local": + export_temp = target.with_name(f".{target.name}.tmp") + try: + export_temp.write_bytes(content) + os.replace(export_temp, target) + 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: - export_temp.unlink(missing_ok=True) 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) diff --git a/src/ccma/services/sepa_mail.py b/src/ccma/services/sepa_mail.py index b5b791a..f067560 100644 --- a/src/ccma/services/sepa_mail.py +++ b/src/ccma/services/sepa_mail.py @@ -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,67 +97,94 @@ 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(output_directory) - output.mkdir(parents=True, exist_ok=True) + 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] = [] - for debit in debits: - member = repository.get_member(debit.member_id) - if not member.email.strip(): - warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.") - continue - content = debit_mail_bytes( - recipient=member.email, - first_name=member.first_name, - debit=debit, - collection_date=collection_date, - creditor_id=creditor_id, - sender_name=sender_name, - sender_email=sender_email, - signature=signature, - ) - filename = ( - 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.write_bytes(content) - try: - archive_path.write_bytes(content) - except OSError: - export_path.unlink(missing_ok=True) - raise - relative_path = archive_path.relative_to( - repository.members_root / member.member_id / "files" - ).as_posix() - digest = hashlib.sha256(content).hexdigest() - repository.append_event( - member.member_id, - event_type="sepa_notification_generated", - summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}", - actor_type="user", - actor_name="Vorstand", - references={"document": relative_path}, - data={ - "sha256": digest, - "recipient": member.email, - "collection_date": collection_date.isoformat(), - "amount": f"{debit.amount:.2f}", - }, - ) - generated.append( - GeneratedDebitMail(member.member_id, member.email, export_path, archive_path) - ) + # 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(): + warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.") + continue + content = debit_mail_bytes( + recipient=member.email, + first_name=member.first_name, + debit=debit, + collection_date=collection_date, + creditor_id=creditor_id, + sender_name=sender_name, + sender_email=sender_email, + signature=signature, + ) + filename = ( + f"SEPA-Info-{collection_date.isoformat()}-" + f"{_safe_filename(member.member_number or member.display_name)}.eml" + ) + 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( + repository.members_root / member.member_id / "files" + ).as_posix() + digest = hashlib.sha256(content).hexdigest() + repository.append_event( + member.member_id, + event_type="sepa_notification_generated", + summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}", + actor_type="user", + actor_name="Vorstand", + references={"document": relative_path}, + data={ + "sha256": digest, + "recipient": member.email, + "collection_date": collection_date.isoformat(), + "amount": f"{debit.amount:.2f}", + "delivery_mode": delivery_mode, + }, + ) + generated.append( + GeneratedDebitMail(member.member_id, member.email, export_path, archive_path) + ) return generated, warnings diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index c61b98e..28ac49c 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -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", "")) diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index d57091c..92ef2eb 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -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() diff --git a/src/ccma/ui/email_delivery_dialog.py b/src/ccma/ui/email_delivery_dialog.py new file mode 100644 index 0000000..b470273 --- /dev/null +++ b/src/ccma/ui/email_delivery_dialog.py @@ -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("", 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 diff --git a/src/ccma/ui/labels.py b/src/ccma/ui/labels.py index 57bde48..42a2444 100644 --- a/src/ccma/ui/labels.py +++ b/src/ccma/ui/labels.py @@ -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) diff --git a/src/ccma/ui/options_dialog.py b/src/ccma/ui/options_dialog.py index 057790e..aa5a06c 100644 --- a/src/ccma/ui/options_dialog.py +++ b/src/ccma/ui/options_dialog.py @@ -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 diff --git a/src/ccma/ui/sepa_dialog.py b/src/ccma/ui/sepa_dialog.py index 704d470..6c95ebf 100644 --- a/src/ccma/ui/sepa_dialog.py +++ b/src/ccma/ui/sepa_dialog.py @@ -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) diff --git a/tests/test_email_settings.py b/tests/test_email_settings.py new file mode 100644 index 0000000..b6d6b17 --- /dev/null +++ b/tests/test_email_settings.py @@ -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" diff --git a/tests/test_mail_delivery.py b/tests/test_mail_delivery.py new file mode 100644 index 0000000..3c95849 --- /dev/null +++ b/tests/test_mail_delivery.py @@ -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 \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"] diff --git a/tests/test_reminder_mail.py b/tests/test_reminder_mail.py index f385572..a910be1 100644 --- a/tests/test_reminder_mail.py +++ b/tests/test_reminder_mail.py @@ -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" diff --git a/tests/test_sepa.py b/tests/test_sepa.py index cc38ef5..b2d3daa 100644 --- a/tests/test_sepa.py +++ b/tests/test_sepa.py @@ -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)