Merge branch 'dev' into feature/mail-templates

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>
This commit is contained in:
Marcel Peterkau
2026-08-28 20:50:51 +02:00
co-authored by Claude Opus 5
7 changed files with 726 additions and 99 deletions
+71 -45
View File
@@ -127,61 +127,87 @@ def generate_debit_mails(
if not member.email.strip():
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
continue
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)
export_path: Path | None = None
if delivery_mode == "local":
export_path = _available_path(output, filename)
export_path.write_bytes(content)
elif delivery_mode == "send":
send_via_smtp(smtp_client, content)
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)"
)
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:
if export_path is not None:
export_path.unlink(missing_ok=True)
raise
relative_path = archive_path.relative_to(
repository.members_root / member.member_id / "files"
).as_posix()
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={"document": relative_path},
data={
"sha256": digest,
"recipient": member.email,
"collection_date": collection_date.isoformat(),
"amount": f"{debit.amount:.2f}",
"delivery_mode": delivery_mode,
},
references=references,
data=data,
)
generated.append(
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)