Files
CCMA/src/ccma/services/reminder_mail.py
T

181 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)