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)