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