diff --git a/src/ccma/services/reminder_mail.py b/src/ccma/services/reminder_mail.py new file mode 100644 index 0000000..a9accbd --- /dev/null +++ b/src/ccma/services/reminder_mail.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import hashlib +import os +import re +from dataclasses import dataclass +from datetime import datetime +from email.message import EmailMessage +from email.policy import SMTP +from email.utils import format_datetime, make_msgid +from pathlib import Path + +from ccma.domain.contributions import claim_balance +from ccma.domain.dates import format_date_for_display +from ccma.storage.repository import MemberRepository, RepositoryError + + +@dataclass(frozen=True, slots=True) +class GeneratedReminderMail: + export_path: Path + archive_path: Path + sha256: str + + +def _safe_filename(value: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._") + return cleaned or "Mahnung" + + +def _available_path(directory: Path, filename: str) -> Path: + candidate = directory / filename + if not candidate.exists(): + return candidate + stem, suffix = Path(filename).stem, Path(filename).suffix + index = 2 + while (directory / f"{stem}-{index}{suffix}").exists(): + index += 1 + return directory / f"{stem}-{index}{suffix}" + + +def reminder_mail_bytes( + *, + recipient: str, + first_name: str, + reminder: dict, + claim: dict, + balance: str, + organization: dict, + sender_name: str, + sender_email: str, + signature: str, + created_at: datetime | None = None, +) -> bytes: + if not recipient.strip(): + 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.") + timestamp = created_at or datetime.now().astimezone() + message = EmailMessage(policy=SMTP) + message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1]) + message["Date"] = format_datetime(timestamp) + message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>" + message["To"] = recipient.strip() + message["Subject"] = f"{reminder.get('name', 'Zahlungserinnerung')} – {claim.get('title', '')}" + message["X-Mozilla-Draft-Info"] = ( + "internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0" + ) + iban = str(organization.get("iban", "")).strip() + bic = str(organization.get("bic", "")).strip() + payment_details = f"IBAN: {iban}" + (f"\nBIC: {bic}" if bic else "") + detail = str(reminder.get("detail", "")).strip() + message.set_content( + "\n".join( + ( + f"Hallo {first_name.strip() or 'Mitglied'},", + "", + f"zu der Forderung „{claim.get('title', 'Offene Forderung')}“ " + "ist noch ein Betrag offen.", + "", + f"Offener Betrag: {balance} Euro", + f"Ursprünglich fällig: " + f"{format_date_for_display(str(claim.get('due_date', '')))}", + f"Neue Zahlungsfrist: " + f"{format_date_for_display(str(reminder.get('payment_deadline', '')))}", + f"Mahngebühr: {reminder.get('fee', '0.00')} Euro", + f"Hinweis: {detail}" if detail else "", + "", + "Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:", + payment_details, + "", + "Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, " + "betrachte sie bitte als gegenstandslos.", + "", + "Viele Grüße", + signature.strip(), + ) + ), + charset="utf-8", + ) + return message.as_bytes() + + +def generate_and_send_reminder_mail( + repository: MemberRepository, + member_id: str, + claim_id: str, + reminder_id: str, + *, + output_path: Path | str, + sender_name: str, + sender_email: str, + signature: str, +) -> GeneratedReminderMail: + member = repository.get_member(member_id) + data, claim = repository.get_claim(member_id, claim_id) + reminder = repository._find_reminder(data, claim_id, reminder_id) + if str(reminder.get("status", "")) not in {"draft", "generated"}: + raise RepositoryError("Nur ein Mahnungsentwurf kann als E-Mail erzeugt werden.") + if not member.email.strip(): + 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}") + + sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id) + updated_data, updated_claim = repository.get_claim(member_id, claim_id) + organization = repository.get_configuration().get("organization") or {} + content = reminder_mail_bytes( + recipient=member.email, + first_name=member.first_name, + reminder=sent_reminder, + claim=updated_claim, + balance=f"{claim_balance(updated_data, updated_claim):.2f}", + organization=organization, + sender_name=sender_name, + sender_email=sender_email, + signature=signature, + ) + archive_dir = repository.members_root / member_id / "files" / "documents" / "Mahnungen" + archive_dir.mkdir(parents=True, exist_ok=True) + filename = ( + f"{datetime.now().date().isoformat()}-" + 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") + 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) + archive_temp.unlink(missing_ok=True) + relative_path = archive_path.relative_to( + repository.members_root / member_id / "files" + ).as_posix() + digest = hashlib.sha256(content).hexdigest() + repository.append_event( + member_id, + event_type="reminder_email_sent", + summary=f"Mahnung per E-Mail versandt: {sent_reminder.get('name', '')}", + actor_type="user", + actor_name="Vorstand", + references={ + "claim_id": claim_id, + "reminder_id": reminder_id, + "document": relative_path, + }, + data={ + "recipient": member.email, + "sha256": digest, + "payment_deadline": str(sent_reminder.get("payment_deadline", "")), + }, + ) + return GeneratedReminderMail(target, archive_path, digest) diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index 5720a04..ef71cc5 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -5,7 +5,7 @@ from collections.abc import Callable from datetime import date from decimal import Decimal from pathlib import Path -from tkinter import messagebox, ttk +from tkinter import filedialog, messagebox, ttk from ccma.domain.contributions import ( CLAIM_STATUS_LABELS, @@ -21,6 +21,7 @@ from ccma.domain.contributions import ( payment_allocated_total, ) 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.labels import ( @@ -156,6 +157,13 @@ class ClaimTab(ttk.Frame): buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled" ) self.send_reminder_button.pack(side="right", padx=(8, 0)) + self.email_reminder_button = ttk.Button( + buttons, + text="E-Mail erzeugen & versandt", + command=self._email_reminder, + state="disabled", + ) + self.email_reminder_button.pack(side="right", padx=(8, 0)) ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right") ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack( side="right", padx=(0, 8) @@ -425,8 +433,22 @@ class ClaimTab(ttk.Frame): editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"}) state = "normal" if editable else "disabled" self.send_reminder_button.configure(state=state) + self.email_reminder_button.configure(state=state) self.discard_reminder_button.configure(state=state) + def _email_reminder(self) -> None: + reminder = self._selected_reminder() + if reminder: + ReminderMailDialog( + self, + self.repository, + self.member_id, + self.claim_id, + str(reminder["reminder_id"]), + str(reminder.get("name", "Mahnung")), + self._changed, + ) + def _send_reminder(self) -> None: reminder = self._selected_reminder() if not reminder or not messagebox.askyesno( @@ -893,6 +915,97 @@ class ReminderDialog(_Dialog): self.on_saved() +class ReminderMailDialog(tk.Toplevel): + def __init__( + self, + master, + repository, + member_id, + claim_id, + reminder_id, + reminder_name, + on_saved, + ): + super().__init__(master) + self.repository = repository + self.member_id = member_id + self.claim_id = claim_id + self.reminder_id = reminder_id + self.reminder_name = reminder_name + self.on_saved = on_saved + organization = repository.get_configuration().get("organization") or {} + self.sender_name_var = tk.StringVar( + value=str(organization.get("name") or "Verwaltung C3MA") + ) + self.sender_email_var = tk.StringVar(value=str(organization.get("email") or "")) + self.title("Mahnung als E-Mail erzeugen") + self.transient(master) + self.resizable(True, False) + self.columnconfigure(1, weight=1) + ttk.Label( + self, + text=( + "Die erzeugte E-Mail wird in der Mitgliederakte archiviert und die Mahnung " + "sofort als versandt gebucht. Zahlungsfrist und Mahngebühr werden dabei wirksam." + ), + wraplength=620, + ).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10)) + ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5) + ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid( + row=1, column=1, sticky="ew", padx=(0, 14), pady=5 + ) + ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5) + ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid( + row=2, column=1, sticky="ew", padx=(0, 14), pady=5 + ) + ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5) + self.signature = tk.Text(self, width=55, height=5, wrap="word") + self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5) + self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.") + buttons = ttk.Frame(self, padding=14) + buttons.grid(row=4, column=0, columnspan=2, sticky="e") + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left") + ttk.Button( + buttons, + text="EML speichern und als versandt buchen", + style="Accent.TButton", + command=self._generate, + ).pack(side="left", padx=(8, 0)) + 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: + return + try: + result = generate_and_send_reminder_mail( + self.repository, + self.member_id, + self.claim_id, + self.reminder_id, + output_path=selected, + sender_name=self.sender_name_var.get(), + sender_email=self.sender_email_var.get(), + signature=self.signature.get("1.0", "end-1c"), + ) + except (OSError, RepositoryError) as exc: + messagebox.showerror("Mahnung konnte nicht erzeugt werden", str(exc), parent=self) + return + messagebox.showinfo( + "Mahnung erzeugt", + f"Die E-Mail wurde gespeichert und als versandt gebucht:\n{result.export_path}", + parent=self, + ) + self.destroy() + self.on_saved() + + class DunningHoldDialog(_Dialog): def __init__(self, master, repository, member_id, claim_id, on_saved): super().__init__(master, "Mahnsperre setzen", on_saved) diff --git a/tests/test_reminder_mail.py b/tests/test_reminder_mail.py new file mode 100644 index 0000000..2e3aed2 --- /dev/null +++ b/tests/test_reminder_mail.py @@ -0,0 +1,83 @@ +from email.parser import BytesParser +from email.policy import default + +from test_reminders import _overdue_claim_repository + +from ccma.services.reminder_mail import generate_and_send_reminder_mail + + +def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path): + repository, member = _overdue_claim_repository(tmp_path / "store") + member.email = "reminder@example.org" + repository.save_member(member) + organization = repository.get_configuration()["organization"] + organization.update( + { + "name": "Chaos Computer Club Mannheim e.V.", + "email": "verwaltung@example.org", + "iban": "DE98670505050038907751", + "bic": "MANSDE66XXX", + } + ) + repository.save_organization(organization) + reminder = repository.create_reminder_draft( + member.member_id, + "claim-1", + level=1, + name="Zahlungserinnerung", + payment_deadline_days=14, + detail="Bitte Mitgliedsnummer angeben.", + ) + export_path = tmp_path / "Zahlungserinnerung.eml" + + generated = generate_and_send_reminder_mail( + repository, + member.member_id, + "claim-1", + reminder["reminder_id"], + output_path=export_path, + sender_name="Verwaltung C3MA", + sender_email="verwaltung@example.org", + signature="Der Vorstand", + ) + + assert generated.export_path.read_bytes() == generated.archive_path.read_bytes() + message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes()) + assert message["To"] == "reminder@example.org" + assert "Zahlungserinnerung" in message["Subject"] + assert "100.00 Euro" in message.get_content() + assert "DE98670505050038907751" in message.get_content() + data = repository.get_contributions(member.member_id) + assert data.reminders[0]["status"] == "sent" + assert data.reminders[0]["payment_deadline"] + assert repository.get_events(member.member_id)[-1].event_type == "reminder_email_sent" + assert generated.archive_path.parent.name == "Mahnungen" + + +def test_reminder_mail_includes_fee_in_open_balance(tmp_path): + repository, member = _overdue_claim_repository(tmp_path / "store") + member.email = "reminder@example.org" + repository.save_member(member) + reminder = repository.create_reminder_draft( + member.member_id, + "claim-1", + level=1, + name="Erste Mahnung", + payment_deadline_days=14, + fee="5.00", + ) + + generated = generate_and_send_reminder_mail( + repository, + member.member_id, + "claim-1", + reminder["reminder_id"], + output_path=tmp_path / "Mahnung.eml", + sender_name="Verwaltung C3MA", + sender_email="verwaltung@example.org", + signature="Der Vorstand", + ) + + 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()