Cover archive-directory setup in the sent/delivered rollback path

archive_dir.mkdir() and the archive path lookup ran outside the
try/except that decides whether to revert mark_reminder_sent (reminder
mail) or keep a SEPA batch running for the other debits. A failure there
(read-only store, full disk) left a reminder booked as "sent" with no
mail ever having gone out, and could still abort an entire SEPA batch
for one member's directory problem.

Moved that setup inside the same try blocks so it's treated exactly like
any other pre-delivery failure: reminder_mail reverts to draft, and
sepa_mail records a warning and continues with the remaining debits.
Also moved debit_mail_bytes() into the per-debit try in sepa_mail for
the same reason. Added a targeted mkdir-failure test for each.
This commit is contained in:
Marcel Peterkau
2026-08-20 12:51:17 +02:00
parent 5fc6d7aec9
commit a4536eedd0
4 changed files with 178 additions and 38 deletions
+21 -21
View File
@@ -152,6 +152,17 @@ def generate_and_send_reminder_mail(
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id)
export_path: Path | None = None
# Once the mail has actually left the building -- landed on the SMTP server, been
# filed into an IMAP folder, or been written to the local export file -- it must
# not be un-sent again: a later failure (Sent-folder copy, moving the archive file
# into place) can no longer roll the reminder back to "draft", or a retry could
# send/file the same Mahnung a second time and double-book its fee. Everything
# before that point -- including preparing the archive directory/path -- still
# rolls back on failure, since nothing has actually gone out yet.
delivered = False
archive_failure: Exception | None = None
archive_temp: Path | None = None
try:
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
organization = repository.get_configuration().get("organization") or {}
@@ -166,26 +177,14 @@ def generate_and_send_reminder_mail(
sender_email=sender_email,
signature=signature,
)
except Exception:
repository.revert_reminder_sent(member_id, claim_id, reminder_id)
raise
archive_dir = repository.members_root / member_id / "files" / "documents" / "Mahnungen"
archive_dir.mkdir(parents=True, exist_ok=True)
filename = (
f"{datetime.now().date().isoformat()}-"
f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml"
)
archive_path = _available_path(archive_dir, filename)
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
export_path: Path | None = None
# Once the mail has actually left the building -- landed on the SMTP server, been
# filed into an IMAP folder, or been written to the local export file -- it must
# not be un-sent again: a later failure (Sent-folder copy, moving the archive file
# into place) can no longer roll the reminder back to "draft", or a retry could
# send/file the same Mahnung a second time and double-book its fee.
delivered = False
archive_failure: Exception | None = None
try:
archive_dir = repository.members_root / member_id / "files" / "documents" / "Mahnungen"
archive_dir.mkdir(parents=True, exist_ok=True)
filename = (
f"{datetime.now().date().isoformat()}-"
f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml"
)
archive_path = _available_path(archive_dir, filename)
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
archive_temp.write_bytes(content)
if delivery_mode == "local":
export_temp = target.with_name(f".{target.name}.tmp")
@@ -215,7 +214,8 @@ def generate_and_send_reminder_mail(
delivered = True
os.replace(archive_temp, archive_path)
except Exception as exc:
archive_temp.unlink(missing_ok=True)
if archive_temp is not None:
archive_temp.unlink(missing_ok=True)
if not delivered:
repository.revert_reminder_sent(member_id, claim_id, reminder_id)
raise
+20 -17
View File
@@ -148,24 +148,8 @@ 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(
recipient=member.email,
first_name=member.first_name,
debit=debit,
collection_date=collection_date,
creditor_id=creditor_id,
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
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
@@ -174,6 +158,25 @@ def generate_debit_mails(
delivered = False
archive_failure: Exception | None = None
try:
content = debit_mail_bytes(
recipient=member.email,
first_name=member.first_name,
debit=debit,
collection_date=collection_date,
creditor_id=creditor_id,
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)