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

161 lines
5.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 re
from dataclasses import dataclass
from datetime import date, 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.services.sepa import DirectDebit
from ccma.storage.repository import MemberRepository, RepositoryError
@dataclass(frozen=True, slots=True)
class GeneratedDebitMail:
member_id: str
recipient: str
export_path: Path
archive_path: Path
def _safe_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
return cleaned or "Mitglied"
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 debit_mail_bytes(
*,
recipient: str,
first_name: str,
debit: DirectDebit,
collection_date: date,
creditor_id: str,
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 Info-Mails 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"SEPA-Lastschrift C3MA {collection_date.strftime('%d.%m.%Y')}"
message["X-Mozilla-Draft-Info"] = (
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
)
greeting = first_name.strip() or "Mitglied"
message.set_content(
"\n".join(
(
f"Hallo {greeting},",
"",
"hiermit informieren wir dich, dass wir den folgenden Betrag per "
"SEPA-Lastschrift einziehen werden:",
"",
f"Betrag: {debit.amount:.2f} Euro",
f"Einzugsdatum: {collection_date.strftime('%d.%m.%Y')}",
f"Verwendungszweck: {debit.purpose}",
f"Mandatsreferenz: {debit.mandate_reference}",
f"Gläubiger-Identifikationsnummer: {creditor_id}",
"",
"Sollten die Angaben nicht stimmen, melde dich bitte zeitnah bei uns.",
"",
"Viele Grüße",
signature.strip(),
)
),
charset="utf-8",
)
return message.as_bytes()
def generate_debit_mails(
repository: MemberRepository,
debits: list[DirectDebit],
*,
collection_date: date,
output_directory: Path | str,
sender_name: str,
sender_email: str,
signature: str,
) -> tuple[list[GeneratedDebitMail], list[str]]:
output = Path(output_directory)
output.mkdir(parents=True, exist_ok=True)
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)
)
return generated, warnings