mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
181 lines
6.7 KiB
Python
181 lines
6.7 KiB
Python
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)
|