mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
dev gained the delivery/rollback semantics for the dunning and SEPA mails while the mail texts were being moved into templates here. Both services keep dev's flow -- roll back only while nothing has gone out, log the event even when archiving fails afterwards -- and render their content from the template. The welcome mail follows the same rule now, including its archiving-failure path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
8.4 KiB
Python
216 lines
8.4 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import re
|
|
from contextlib import nullcontext
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
|
|
from ccma.domain.models import Member
|
|
from ccma.services.mail_composition import compose_mail, render_template, template_values
|
|
from ccma.services.mail_delivery import (
|
|
append_message,
|
|
ensure_imap_folder,
|
|
imap_session,
|
|
send_via_smtp,
|
|
smtp_session,
|
|
)
|
|
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
|
|
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
|
|
export_path: Path | None
|
|
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(
|
|
repository: MemberRepository,
|
|
*,
|
|
member: Member,
|
|
debit: DirectDebit,
|
|
collection_date: date,
|
|
organization: dict,
|
|
sender_name: str,
|
|
sender_email: str,
|
|
signature: str,
|
|
created_at: datetime | None = None,
|
|
) -> bytes:
|
|
values = template_values(member, organization, signature=signature, created_at=created_at)
|
|
values.update(
|
|
{
|
|
"debit.amount": f"{debit.amount:.2f}",
|
|
"debit.collection_date": collection_date.strftime("%d.%m.%Y"),
|
|
"debit.purpose": debit.purpose,
|
|
"debit.mandate_reference": debit.mandate_reference,
|
|
}
|
|
)
|
|
rendered = render_template(repository, "sepa", values)
|
|
return compose_mail(
|
|
recipient=member.email,
|
|
subject=rendered.subject,
|
|
body=rendered.body,
|
|
sender_name=sender_name,
|
|
sender_email=sender_email,
|
|
created_at=created_at,
|
|
)
|
|
|
|
|
|
def generate_debit_mails(
|
|
repository: MemberRepository,
|
|
debits: list[DirectDebit],
|
|
*,
|
|
collection_date: date,
|
|
delivery_mode: str,
|
|
output_directory: Path | str | None = None,
|
|
sender_name: str,
|
|
sender_email: str,
|
|
signature: str,
|
|
) -> tuple[list[GeneratedDebitMail], list[str]]:
|
|
output: Path | None = None
|
|
email_settings: dict | None = None
|
|
smtp_ctx = nullcontext(None)
|
|
imap_ctx = nullcontext(None)
|
|
if delivery_mode == "local":
|
|
if not output_directory:
|
|
raise RepositoryError("Kein Zielordner für die lokale Ablage angegeben.")
|
|
output = Path(output_directory)
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
elif delivery_mode == "send":
|
|
email_settings = repository.get_email_settings()
|
|
smtp_ctx = smtp_session(email_settings)
|
|
if email_settings["imap_sent_enabled"]:
|
|
imap_ctx = imap_session(email_settings)
|
|
elif delivery_mode == "drafts":
|
|
email_settings = repository.get_email_settings()
|
|
imap_ctx = imap_session(email_settings)
|
|
else:
|
|
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
|
|
|
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] = []
|
|
# One connection (or two, for "send" with a Sent-folder copy) is opened for the
|
|
# whole batch -- a run can cover dozens of members -- instead of reconnecting/
|
|
# re-authenticating per recipient.
|
|
with smtp_ctx as smtp_client, imap_ctx as imap_client:
|
|
if delivery_mode == "drafts":
|
|
ensure_imap_folder(imap_client, email_settings["imap_drafts_folder"])
|
|
elif delivery_mode == "send" and imap_client is not None:
|
|
ensure_imap_folder(imap_client, email_settings["imap_sent_folder"])
|
|
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
|
|
export_path: Path | None = None
|
|
archive_path: Path | None = None
|
|
# Once the mail has actually left the building for this debit -- SMTP
|
|
# accepted it, it's filed in the IMAP folder, or the local export file was
|
|
# written -- a later archiving failure must not abort the whole batch and
|
|
# lose track of the fact that this one was already delivered; nor may it
|
|
# abort earlier/later debits that have nothing to do with this failure.
|
|
delivered = False
|
|
archive_failure: Exception | None = None
|
|
try:
|
|
content = debit_mail_bytes(
|
|
repository,
|
|
member=member,
|
|
debit=debit,
|
|
collection_date=collection_date,
|
|
organization=organization,
|
|
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"
|
|
)
|
|
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)
|
|
if delivery_mode == "local":
|
|
export_path = _available_path(output, filename)
|
|
export_path.write_bytes(content)
|
|
delivered = True
|
|
elif delivery_mode == "send":
|
|
send_via_smtp(smtp_client, content)
|
|
delivered = True
|
|
if imap_client is not None:
|
|
append_message(
|
|
imap_client, content, folder=email_settings["imap_sent_folder"], flags=r"(\Seen)"
|
|
)
|
|
else:
|
|
append_message(
|
|
imap_client, content, folder=email_settings["imap_drafts_folder"], flags=r"(\Draft)"
|
|
)
|
|
delivered = True
|
|
archive_path.write_bytes(content)
|
|
except (OSError, RepositoryError) as exc:
|
|
if not delivered:
|
|
if export_path is not None:
|
|
export_path.unlink(missing_ok=True)
|
|
warnings.append(f"{member.member_number or member.display_name}: {exc}")
|
|
continue
|
|
warnings.append(
|
|
f"{member.member_number or member.display_name}: E-Mail wurde versandt/abgelegt, "
|
|
f"konnte aber nicht archiviert werden ({exc}); bitte manuell prüfen."
|
|
)
|
|
archive_failure = exc
|
|
|
|
digest = hashlib.sha256(content).hexdigest()
|
|
references = {}
|
|
if archive_failure is None:
|
|
references["document"] = archive_path.relative_to(
|
|
repository.members_root / member.member_id / "files"
|
|
).as_posix()
|
|
data = {
|
|
"sha256": digest,
|
|
"recipient": member.email,
|
|
"collection_date": collection_date.isoformat(),
|
|
"amount": f"{debit.amount:.2f}",
|
|
"delivery_mode": delivery_mode,
|
|
}
|
|
if archive_failure is not None:
|
|
data["archive_error"] = str(archive_failure)
|
|
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=references,
|
|
data=data,
|
|
)
|
|
generated.append(
|
|
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
|
|
)
|
|
return generated, warnings
|