Stop rolling back reminders/SEPA mails after a successful send

The previous fix reverted the reminder to "draft" on any failure after
mark_reminder_sent, including failures that happened after the mail had
already been handed to send_via_smtp or appended to an IMAP folder. That
made a successful send followed by a Sent-folder-copy or archive-move
failure look like nothing was sent, inviting a duplicate send/fee booking
on retry -- the same "sent" flag needs to be preserved once delivery is
no longer reversible, per follow-up review.

reminder_mail.generate_and_send_reminder_mail and
sepa_mail.generate_debit_mails now track whether the mail actually left
the building (SMTP accepted / IMAP append succeeded / local file written)
separately from the later archiving step:
- Failure before that point: reminder_mail reverts to draft (unchanged);
  sepa_mail now records a warning and continues with the remaining
  debits instead of aborting the whole batch.
- Failure after that point (Sent-copy append, moving the archive file
  into place): the reminder stays "sent" / the debit stays in the
  batch's results, an event is still logged for traceability (with an
  archive_error note and no document reference), and the caller gets a
  clear error to follow up on manually -- no rollback, no silent loss of
  the fact that the mail already went out.
This commit is contained in:
Marcel Peterkau
2026-08-20 12:22:37 +02:00
parent aab43cc0fe
commit 5fc6d7aec9
4 changed files with 386 additions and 82 deletions
+75 -54
View File
@@ -166,69 +166,90 @@ def generate_and_send_reminder_mail(
sender_email=sender_email,
signature=signature,
)
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
try:
archive_temp.write_bytes(content)
if delivery_mode == "local":
export_temp = target.with_name(f".{target.name}.tmp")
try:
export_temp.write_bytes(content)
os.replace(export_temp, target)
finally:
export_temp.unlink(missing_ok=True)
export_path = target
elif delivery_mode == "send":
settings = repository.get_email_settings()
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
send_via_smtp(smtp_client, content)
if imap_client is not None:
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
append_message(
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
)
else:
settings = repository.get_email_settings()
with imap_session(settings) as client:
ensure_imap_folder(client, settings["imap_drafts_folder"])
append_message(client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)")
os.replace(archive_temp, archive_path)
finally:
archive_temp.unlink(missing_ok=True)
except Exception:
# The mail was not (verifiably) delivered/filed -- roll the reminder back to
# "draft" so it does not sit in the "sent" state without ever having gone out,
# and so it can be corrected and sent again.
repository.revert_reminder_sent(member_id, claim_id, reminder_id)
raise
relative_path = archive_path.relative_to(
repository.members_root / member_id / "files"
).as_posix()
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_temp.write_bytes(content)
if delivery_mode == "local":
export_temp = target.with_name(f".{target.name}.tmp")
try:
export_temp.write_bytes(content)
os.replace(export_temp, target)
finally:
export_temp.unlink(missing_ok=True)
export_path = target
delivered = True
elif delivery_mode == "send":
settings = repository.get_email_settings()
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
send_via_smtp(smtp_client, content)
delivered = True
if imap_client is not None:
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
append_message(
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
)
else:
settings = repository.get_email_settings()
with imap_session(settings) as client:
ensure_imap_folder(client, settings["imap_drafts_folder"])
append_message(client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)")
delivered = True
os.replace(archive_temp, archive_path)
except Exception as exc:
archive_temp.unlink(missing_ok=True)
if not delivered:
repository.revert_reminder_sent(member_id, claim_id, reminder_id)
raise
archive_failure = exc
digest = hashlib.sha256(content).hexdigest()
references = {"claim_id": claim_id, "reminder_id": reminder_id}
if archive_failure is None:
references["document"] = archive_path.relative_to(
repository.members_root / member_id / "files"
).as_posix()
data = {
"recipient": member.email,
"sha256": digest,
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
"delivery_mode": delivery_mode,
}
if archive_failure is not None:
data["archive_error"] = str(archive_failure)
# Log the event even when archiving failed -- the mail was already delivered/filed,
# so this must stay a traceable "sent" record instead of silently vanishing.
repository.append_event(
member_id,
event_type="reminder_email_sent",
summary=f"Mahnung per E-Mail versandt: {sent_reminder.get('name', '')}",
actor_type="user",
actor_name="Vorstand",
references={
"claim_id": claim_id,
"reminder_id": reminder_id,
"document": relative_path,
},
data={
"recipient": member.email,
"sha256": digest,
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
"delivery_mode": delivery_mode,
},
references=references,
data=data,
)
if archive_failure is not None:
raise RepositoryError(
"Die Mahnung wurde zugestellt bzw. abgelegt, konnte aber nicht vollständig "
f"archiviert werden: {archive_failure}. Die Mahnung bleibt als versandt gebucht "
"-- bitte die Mitgliedsakte manuell prüfen."
) from archive_failure
return GeneratedReminderMail(export_path, archive_path, digest)
+51 -28
View File
@@ -166,43 +166,66 @@ def generate_debit_mails(
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)"
)
# 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:
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)