mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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:
@@ -158,29 +158,39 @@ 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)
|
||||
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
content = reminder_mail_bytes(
|
||||
repository,
|
||||
member=member,
|
||||
reminder=sent_reminder,
|
||||
claim=updated_claim,
|
||||
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
|
||||
organization=organization,
|
||||
sender_name=sender_name,
|
||||
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
|
||||
# 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 {}
|
||||
content = reminder_mail_bytes(
|
||||
repository,
|
||||
member=member,
|
||||
reminder=sent_reminder,
|
||||
claim=updated_claim,
|
||||
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
|
||||
organization=organization,
|
||||
sender_name=sender_name,
|
||||
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")
|
||||
archive_temp.write_bytes(content)
|
||||
if delivery_mode == "local":
|
||||
export_temp = target.with_name(f".{target.name}.tmp")
|
||||
@@ -190,11 +200,13 @@ def generate_and_send_reminder_mail(
|
||||
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(
|
||||
@@ -205,29 +217,45 @@ def generate_and_send_reminder_mail(
|
||||
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)
|
||||
finally:
|
||||
archive_temp.unlink(missing_ok=True)
|
||||
relative_path = archive_path.relative_to(
|
||||
repository.members_root / member_id / "files"
|
||||
).as_posix()
|
||||
except Exception as exc:
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -175,12 +175,20 @@ def generate_and_send_welcome_mail(
|
||||
sender_email=sender_email,
|
||||
signature=signature,
|
||||
)
|
||||
archive_dir = repository.members_root / member_id / "files" / "documents" / "Willkommen"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = _available_path(archive_dir, f"{date.today().isoformat()}-Willkommen.eml")
|
||||
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
|
||||
export_path: Path | None = None
|
||||
archive_path: Path | None = None
|
||||
archive_temp: Path | None = None
|
||||
# Same rule as the dunning/SEPA mails: once the mail has reached the SMTP server,
|
||||
# the IMAP folder or the local export file, a later archiving failure must not
|
||||
# hide that it went out -- the event is written either way and the archiving
|
||||
# problem is reported afterwards.
|
||||
delivered = False
|
||||
archive_failure: Exception | None = None
|
||||
try:
|
||||
archive_dir = repository.members_root / member_id / "files" / "documents" / "Willkommen"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = _available_path(archive_dir, f"{date.today().isoformat()}-Willkommen.eml")
|
||||
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")
|
||||
@@ -190,11 +198,13 @@ def generate_and_send_welcome_mail(
|
||||
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(
|
||||
@@ -207,27 +217,44 @@ def generate_and_send_welcome_mail(
|
||||
append_message(
|
||||
client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)"
|
||||
)
|
||||
delivered = True
|
||||
os.replace(archive_temp, archive_path)
|
||||
finally:
|
||||
archive_temp.unlink(missing_ok=True)
|
||||
relative_path = archive_path.relative_to(
|
||||
repository.members_root / member_id / "files"
|
||||
).as_posix()
|
||||
except Exception as exc:
|
||||
if archive_temp is not None:
|
||||
archive_temp.unlink(missing_ok=True)
|
||||
if not delivered:
|
||||
raise
|
||||
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_id / "files"
|
||||
).as_posix()
|
||||
event_data = {
|
||||
"recipient": member.email,
|
||||
"sha256": digest,
|
||||
"claim_ids": [str(claim.get("claim_id", "")) for claim in claims],
|
||||
"delivery_mode": delivery_mode,
|
||||
}
|
||||
if archive_failure is not None:
|
||||
event_data["archive_error"] = str(archive_failure)
|
||||
repository.append_event(
|
||||
member_id,
|
||||
event_type="welcome_email_sent",
|
||||
summary="Willkommens- und Erstrechnungsmail erzeugt",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references={"document": relative_path},
|
||||
data={
|
||||
"recipient": member.email,
|
||||
"sha256": digest,
|
||||
"claim_ids": [str(claim.get("claim_id", "")) for claim in claims],
|
||||
"delivery_mode": delivery_mode,
|
||||
},
|
||||
references=references,
|
||||
data=event_data,
|
||||
)
|
||||
if archive_failure is not None:
|
||||
raise RepositoryError(
|
||||
"Die Willkommensmail wurde zugestellt bzw. abgelegt, konnte aber nicht "
|
||||
f"vollständig archiviert werden: {archive_failure}. Bitte die Mitgliedsakte "
|
||||
"manuell prüfen."
|
||||
) from archive_failure
|
||||
return GeneratedWelcomeMail(export_path, archive_path, digest)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user