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.dates import format_date_for_display from ccma.domain.models import ( MEMBERSHIP_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, 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.storage.repository import MemberRepository, RepositoryError # Everyone the club still has a live relationship with. An ended membership is not # asked to check data the club is about to delete anyway, and an application has not # been decided on yet -- both can still be picked by hand in the dialog. DEFAULT_RECIPIENT_STATUSES: tuple[str, ...] = ( "accepted_pending_payment", "active", "suspended_contribution", "resigned_end_of_year", "honorary", ) # A field the member is meant to complete reads better as a visible gap than as an # empty line the eye skips over. NOT_STORED = "(nicht hinterlegt)" # Only makes sense next to a masked IBAN, so it is filled per member rather than # written into the template as a fixed line. It sits directly under the record and # reads as a footnote to it, which is also why it carries its own parentheses: a # dropped line must not leave a blank one behind. IBAN_HINT = ( "(Die IBAN zeigen wir absichtlich nur verkürzt an – die letzten vier Stellen " "genügen zum Abgleich.)" ) @dataclass(frozen=True, slots=True) class GeneratedDataReviewMail: 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 mask_iban(iban: str) -> str: """Shows enough of the IBAN to recognise the account (country code and the last four digits) without putting the full account number into an e-mail: the member only has to confirm it is still the right account.""" cleaned = iban.replace(" ", "").strip() if len(cleaned) < 8: return cleaned return f"{cleaned[:2]}{'*' * (len(cleaned) - 6)}{cleaned[-4:]}" def member_data_fields(member: Member) -> list[tuple[str, str]]: """The stored record as the member gets to see it: label and value, in the order they read naturally. Bank details are only listed for members who have any -- asking someone who pays by transfer to check a mandate they never gave is noise.""" fields: list[tuple[str, str]] = [ ("Mitgliedsnummer", member.member_number), ("Name", " ".join(part for part in (member.first_name, member.last_name) if part)), ("Nickname", member.nickname), ("Geburtsdatum", format_date_for_display(member.birth_date)), ("E-Mail", member.email), ("Telefon", member.phone), ("Straße und Hausnummer", member.street), ] if member.address_addition.strip(): fields.append(("Adresszusatz", member.address_addition)) fields.extend( [ ( "PLZ und Ort", " ".join(part for part in (member.postal_code, member.city) if part), ), ("Land", member.country), ("Mitgliedsstatus", MEMBERSHIP_STATUS_LABELS.get(member.status, member.status)), ("Mitglied seit", format_date_for_display(member.membership_started_at)), ( "Zahlweise", PAYMENT_FREQUENCY_LABELS.get( member.payment_frequency, member.payment_frequency ).capitalize(), ), ] ) if has_bank_details(member): signed = format_date_for_display(member.mandate_signed_at) fields.extend( [ ("Kontoinhaber", member.account_holder), ("IBAN", mask_iban(member.iban)), ("Mandatsreferenz", member.mandate_reference), ( "Lastschriftmandat", ("aktiv" if member.mandate_active else "nicht aktiv") + (f", erteilt am {signed}" if signed else ""), ), ] ) return [(label, value.strip() or NOT_STORED) for label, value in fields] def has_bank_details(member: Member) -> bool: """Whether the club stores anything about this member's account at all. Members who pay by transfer have nothing here, and asking them to check a mandate they never gave is noise.""" return any( value.strip() for value in (member.iban, member.mandate_reference, member.account_holder) ) def payment_reference(member: Member) -> str: """What the member should write on a transfer. Both halves are offered because either one identifies the payment on its own: a member who has forgotten their number can fall back on the name, and a name shared by two members is told apart by the number.""" return " – ".join( part for part in ( member.member_number.strip(), " ".join(value for value in (member.first_name, member.last_name) if value).strip(), ) if part ) def data_review_recipients( repository: MemberRepository, *, statuses: tuple[str, ...] | None = None ) -> list[Member]: """The members a data-review run is meant for by default -- an e-mail address is required, so members without one are left out here and reported by the dialog.""" wanted = set(statuses if statuses is not None else DEFAULT_RECIPIENT_STATUSES) return [ member for member in repository.list_members() if member.status in wanted and member.email.strip() ] def data_review_mail_bytes( repository: MemberRepository, *, member: Member, 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) fields = member_data_fields(member) entries = [{"field.label": label, "field.value": value} for label, value in fields] values.update( { "member.iban_masked": mask_iban(member.iban), "data.sheet": "\n".join(f"{label}: {value}" for label, value in fields), "data.count": str(len(fields)), "data.iban_hint": IBAN_HINT if has_bank_details(member) else "", "payment.reference": payment_reference(member), } ) rendered = render_template(repository, "data_review", values, {"data": entries}) 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_data_review_mails( repository: MemberRepository, member_ids: list[str], *, delivery_mode: str, output_directory: Path | str | None = None, sender_name: str, sender_email: str, signature: str, today: date | None = None, ) -> tuple[list[GeneratedDataReviewMail], list[str]]: """Sends one data-review mail per member. A member whose mail fails is reported as a warning and the run continues: a single bad address must not stop a mailing to the whole club halfway through.""" repository.assert_writable() 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 {} stamp = (today or date.today()).isoformat() generated: list[GeneratedDataReviewMail] = [] warnings: list[str] = [] # One connection for the whole run -- this mailing covers every member, so # reconnecting per recipient would be slow and invites provider rate limits. 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 member_id in member_ids: try: member = repository.get_member(member_id) except RepositoryError as exc: warnings.append(f"{member_id}: {exc}") continue label = member.member_number or member.display_name if not member.email.strip(): warnings.append(f"{label}: E-Mail-Adresse fehlt.") continue export_path: Path | None = None archive_path: Path | None = None # Once this mail has left the building, a later archiving failure must not # hide that it went out, nor abort the members that come after it. delivered = False archive_failure: Exception | None = None try: content = data_review_mail_bytes( repository, member=member, organization=organization, sender_name=sender_name, sender_email=sender_email, signature=signature, ) filename = f"Datenpruefung-{stamp}-{_safe_filename(label)}.eml" archive_dir = ( repository.members_root / member.member_id / "files" / "documents" / "Datenpruefung" ) 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"{label}: {exc}") continue warnings.append( f"{label}: E-Mail wurde versandt/abgelegt, konnte aber nicht archiviert " f"werden ({exc}); bitte manuell prüfen." ) archive_failure = exc digest = hashlib.sha256(content).hexdigest() references: dict[str, str] = {} 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, "delivery_mode": delivery_mode, "field_count": len(member_data_fields(member)), } if archive_failure is not None: data["archive_error"] = str(archive_failure) repository.append_event( member.member_id, event_type="data_review_email_sent", summary="Bitte um Datenüberprüfung verschickt", actor_type="user", actor_name="Vorstand", references=references, data=data, ) generated.append( GeneratedDataReviewMail( member.member_id, member.email, export_path, archive_path ) ) return generated, warnings