mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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>
350 lines
14 KiB
Python
350 lines
14 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.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
|