Files
CCMA/src/ccma/services/member_data_mail.py
T
Marcel PeterkauandClaude Opus 5 31c1646571 Tell members which reference to put on a transfer
A data-review mail that lists the payment frequency but not how to pay leaves the
board matching anonymous transfers by hand. The mail now closes with the
reference to quote -- member number and full name, joined only where both exist
so a missing half cannot leave a dangling dash. Both are offered because either
identifies the payment on its own: a member who has forgotten their number falls
back on the name, and two members sharing a name are told apart by the number.

The note about the shortened IBAN moved out of the fixed template text into
{{data.iban_hint}}, filled only for members whose bank details are actually
listed -- it used to explain a masked IBAN to members who pay by transfer and see
no IBAN at all. It sits directly under the record as a parenthesised footnote,
which also keeps the dropped line from leaving a blank one behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:08:40 +02:00

350 lines
14 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 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