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}")
|
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
|
||||||
|
|
||||||
sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id)
|
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
|
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:
|
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)
|
archive_temp.write_bytes(content)
|
||||||
if delivery_mode == "local":
|
if delivery_mode == "local":
|
||||||
export_temp = target.with_name(f".{target.name}.tmp")
|
export_temp = target.with_name(f".{target.name}.tmp")
|
||||||
@@ -190,11 +200,13 @@ def generate_and_send_reminder_mail(
|
|||||||
finally:
|
finally:
|
||||||
export_temp.unlink(missing_ok=True)
|
export_temp.unlink(missing_ok=True)
|
||||||
export_path = target
|
export_path = target
|
||||||
|
delivered = True
|
||||||
elif delivery_mode == "send":
|
elif delivery_mode == "send":
|
||||||
settings = repository.get_email_settings()
|
settings = repository.get_email_settings()
|
||||||
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
|
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:
|
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
|
||||||
send_via_smtp(smtp_client, content)
|
send_via_smtp(smtp_client, content)
|
||||||
|
delivered = True
|
||||||
if imap_client is not None:
|
if imap_client is not None:
|
||||||
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
|
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
|
||||||
append_message(
|
append_message(
|
||||||
@@ -205,29 +217,45 @@ def generate_and_send_reminder_mail(
|
|||||||
with imap_session(settings) as client:
|
with imap_session(settings) as client:
|
||||||
ensure_imap_folder(client, settings["imap_drafts_folder"])
|
ensure_imap_folder(client, settings["imap_drafts_folder"])
|
||||||
append_message(client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)")
|
append_message(client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)")
|
||||||
|
delivered = True
|
||||||
os.replace(archive_temp, archive_path)
|
os.replace(archive_temp, archive_path)
|
||||||
finally:
|
except Exception as exc:
|
||||||
archive_temp.unlink(missing_ok=True)
|
if archive_temp is not None:
|
||||||
relative_path = archive_path.relative_to(
|
archive_temp.unlink(missing_ok=True)
|
||||||
repository.members_root / member_id / "files"
|
if not delivered:
|
||||||
).as_posix()
|
repository.revert_reminder_sent(member_id, claim_id, reminder_id)
|
||||||
|
raise
|
||||||
|
archive_failure = exc
|
||||||
|
|
||||||
digest = hashlib.sha256(content).hexdigest()
|
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(
|
repository.append_event(
|
||||||
member_id,
|
member_id,
|
||||||
event_type="reminder_email_sent",
|
event_type="reminder_email_sent",
|
||||||
summary=f"Mahnung per E-Mail versandt: {sent_reminder.get('name', '')}",
|
summary=f"Mahnung per E-Mail versandt: {sent_reminder.get('name', '')}",
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
actor_name="Vorstand",
|
actor_name="Vorstand",
|
||||||
references={
|
references=references,
|
||||||
"claim_id": claim_id,
|
data=data,
|
||||||
"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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
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)
|
return GeneratedReminderMail(export_path, archive_path, digest)
|
||||||
|
|||||||
@@ -127,61 +127,87 @@ def generate_debit_mails(
|
|||||||
if not member.email.strip():
|
if not member.email.strip():
|
||||||
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
|
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
|
||||||
continue
|
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
|
export_path: Path | None = None
|
||||||
if delivery_mode == "local":
|
archive_path: Path | None = None
|
||||||
export_path = _available_path(output, filename)
|
# Once the mail has actually left the building for this debit -- SMTP
|
||||||
export_path.write_bytes(content)
|
# accepted it, it's filed in the IMAP folder, or the local export file was
|
||||||
elif delivery_mode == "send":
|
# written -- a later archiving failure must not abort the whole batch and
|
||||||
send_via_smtp(smtp_client, content)
|
# lose track of the fact that this one was already delivered; nor may it
|
||||||
if imap_client is not None:
|
# abort earlier/later debits that have nothing to do with this failure.
|
||||||
append_message(
|
delivered = False
|
||||||
imap_client, content, folder=email_settings["imap_sent_folder"], flags=r"(\Seen)"
|
archive_failure: Exception | None = None
|
||||||
)
|
|
||||||
else:
|
|
||||||
append_message(
|
|
||||||
imap_client, content, folder=email_settings["imap_drafts_folder"], flags=r"(\Draft)"
|
|
||||||
)
|
|
||||||
try:
|
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)
|
archive_path.write_bytes(content)
|
||||||
except OSError:
|
except (OSError, RepositoryError) as exc:
|
||||||
if export_path is not None:
|
if not delivered:
|
||||||
export_path.unlink(missing_ok=True)
|
if export_path is not None:
|
||||||
raise
|
export_path.unlink(missing_ok=True)
|
||||||
relative_path = archive_path.relative_to(
|
warnings.append(f"{member.member_number or member.display_name}: {exc}")
|
||||||
repository.members_root / member.member_id / "files"
|
continue
|
||||||
).as_posix()
|
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()
|
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(
|
repository.append_event(
|
||||||
member.member_id,
|
member.member_id,
|
||||||
event_type="sepa_notification_generated",
|
event_type="sepa_notification_generated",
|
||||||
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
|
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
actor_name="Vorstand",
|
actor_name="Vorstand",
|
||||||
references={"document": relative_path},
|
references=references,
|
||||||
data={
|
data=data,
|
||||||
"sha256": digest,
|
|
||||||
"recipient": member.email,
|
|
||||||
"collection_date": collection_date.isoformat(),
|
|
||||||
"amount": f"{debit.amount:.2f}",
|
|
||||||
"delivery_mode": delivery_mode,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
generated.append(
|
generated.append(
|
||||||
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
|
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
|
||||||
|
|||||||
@@ -175,12 +175,20 @@ def generate_and_send_welcome_mail(
|
|||||||
sender_email=sender_email,
|
sender_email=sender_email,
|
||||||
signature=signature,
|
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
|
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:
|
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)
|
archive_temp.write_bytes(content)
|
||||||
if delivery_mode == "local":
|
if delivery_mode == "local":
|
||||||
export_temp = target.with_name(f".{target.name}.tmp")
|
export_temp = target.with_name(f".{target.name}.tmp")
|
||||||
@@ -190,11 +198,13 @@ def generate_and_send_welcome_mail(
|
|||||||
finally:
|
finally:
|
||||||
export_temp.unlink(missing_ok=True)
|
export_temp.unlink(missing_ok=True)
|
||||||
export_path = target
|
export_path = target
|
||||||
|
delivered = True
|
||||||
elif delivery_mode == "send":
|
elif delivery_mode == "send":
|
||||||
settings = repository.get_email_settings()
|
settings = repository.get_email_settings()
|
||||||
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
|
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:
|
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
|
||||||
send_via_smtp(smtp_client, content)
|
send_via_smtp(smtp_client, content)
|
||||||
|
delivered = True
|
||||||
if imap_client is not None:
|
if imap_client is not None:
|
||||||
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
|
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
|
||||||
append_message(
|
append_message(
|
||||||
@@ -207,27 +217,44 @@ def generate_and_send_welcome_mail(
|
|||||||
append_message(
|
append_message(
|
||||||
client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)"
|
client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)"
|
||||||
)
|
)
|
||||||
|
delivered = True
|
||||||
os.replace(archive_temp, archive_path)
|
os.replace(archive_temp, archive_path)
|
||||||
finally:
|
except Exception as exc:
|
||||||
archive_temp.unlink(missing_ok=True)
|
if archive_temp is not None:
|
||||||
relative_path = archive_path.relative_to(
|
archive_temp.unlink(missing_ok=True)
|
||||||
repository.members_root / member_id / "files"
|
if not delivered:
|
||||||
).as_posix()
|
raise
|
||||||
|
archive_failure = exc
|
||||||
|
|
||||||
digest = hashlib.sha256(content).hexdigest()
|
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(
|
repository.append_event(
|
||||||
member_id,
|
member_id,
|
||||||
event_type="welcome_email_sent",
|
event_type="welcome_email_sent",
|
||||||
summary="Willkommens- und Erstrechnungsmail erzeugt",
|
summary="Willkommens- und Erstrechnungsmail erzeugt",
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
actor_name="Vorstand",
|
actor_name="Vorstand",
|
||||||
references={"document": relative_path},
|
references=references,
|
||||||
data={
|
data=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:
|
||||||
|
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)
|
return GeneratedWelcomeMail(export_path, archive_path, digest)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ from contextlib import contextmanager
|
|||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
from email.policy import default
|
from email.policy import default
|
||||||
|
|
||||||
|
import pytest
|
||||||
from test_reminders import _overdue_claim_repository
|
from test_reminders import _overdue_claim_repository
|
||||||
|
|
||||||
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
||||||
|
from ccma.storage.repository import RepositoryError
|
||||||
|
|
||||||
|
|
||||||
def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
||||||
@@ -200,3 +202,214 @@ def test_reminder_mail_send_mode_also_copies_to_sent_folder_when_enabled(tmp_pat
|
|||||||
|
|
||||||
assert ensured == [("imap-client", "INBOX.Gesendet")]
|
assert ensured == [("imap-client", "INBOX.Gesendet")]
|
||||||
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")]
|
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminder_mail_send_failure_reverts_sent_status(tmp_path, monkeypatch):
|
||||||
|
import ccma.services.reminder_mail as reminder_mail_module
|
||||||
|
|
||||||
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||||
|
member.email = "reminder@example.org"
|
||||||
|
repository.save_member(member)
|
||||||
|
repository.save_email_settings(
|
||||||
|
delivery_mode="send",
|
||||||
|
smtp_host="mail.example.org",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_security="none",
|
||||||
|
smtp_username="",
|
||||||
|
smtp_password="",
|
||||||
|
imap_host="",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security="ssl",
|
||||||
|
imap_username="",
|
||||||
|
imap_password="",
|
||||||
|
imap_drafts_folder="",
|
||||||
|
)
|
||||||
|
reminder = repository.create_reminder_draft(
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
level=1,
|
||||||
|
name="Zahlungserinnerung",
|
||||||
|
payment_deadline_days=14,
|
||||||
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _fake_smtp_session(settings):
|
||||||
|
yield "smtp-client"
|
||||||
|
|
||||||
|
monkeypatch.setattr(reminder_mail_module, "smtp_session", _fake_smtp_session)
|
||||||
|
|
||||||
|
def _failing_send(client, content):
|
||||||
|
raise RepositoryError("E-Mail konnte nicht versandt werden: connection reset")
|
||||||
|
|
||||||
|
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", _failing_send)
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError):
|
||||||
|
generate_and_send_reminder_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
reminder["reminder_id"],
|
||||||
|
delivery_mode="send",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
stored_reminder = data.reminders[0]
|
||||||
|
assert stored_reminder["status"] == "draft"
|
||||||
|
assert not stored_reminder.get("fee_item_ids")
|
||||||
|
assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent_reverted"
|
||||||
|
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||||
|
assert not any(
|
||||||
|
item.get("description") == "Mahngebühr" for item in claim.get("items", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminder_mail_local_write_failure_reverts_sent_status(tmp_path, monkeypatch):
|
||||||
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||||
|
member.email = "reminder@example.org"
|
||||||
|
repository.save_member(member)
|
||||||
|
reminder = repository.create_reminder_draft(
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
level=1,
|
||||||
|
name="Zahlungserinnerung",
|
||||||
|
payment_deadline_days=14,
|
||||||
|
)
|
||||||
|
|
||||||
|
import ccma.services.reminder_mail as reminder_mail_module
|
||||||
|
|
||||||
|
def _boom(*args, **kwargs):
|
||||||
|
raise OSError("disk full")
|
||||||
|
|
||||||
|
monkeypatch.setattr(reminder_mail_module.os, "replace", _boom)
|
||||||
|
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
generate_and_send_reminder_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
reminder["reminder_id"],
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Zahlungserinnerung.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
assert data.reminders[0]["status"] == "draft"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminder_mail_archiving_failure_after_smtp_success_does_not_revert(tmp_path, monkeypatch):
|
||||||
|
import ccma.services.reminder_mail as reminder_mail_module
|
||||||
|
|
||||||
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||||
|
member.email = "reminder@example.org"
|
||||||
|
repository.save_member(member)
|
||||||
|
repository.save_email_settings(
|
||||||
|
delivery_mode="send",
|
||||||
|
smtp_host="mail.example.org",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_security="none",
|
||||||
|
smtp_username="",
|
||||||
|
smtp_password="",
|
||||||
|
imap_host="",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security="ssl",
|
||||||
|
imap_username="",
|
||||||
|
imap_password="",
|
||||||
|
imap_drafts_folder="",
|
||||||
|
)
|
||||||
|
reminder = repository.create_reminder_draft(
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
level=1,
|
||||||
|
name="Zahlungserinnerung",
|
||||||
|
payment_deadline_days=14,
|
||||||
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _fake_smtp_session(settings):
|
||||||
|
yield "smtp-client"
|
||||||
|
|
||||||
|
monkeypatch.setattr(reminder_mail_module, "smtp_session", _fake_smtp_session)
|
||||||
|
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
|
||||||
|
|
||||||
|
real_replace = reminder_mail_module.os.replace
|
||||||
|
|
||||||
|
def _flaky_replace(src, dst):
|
||||||
|
# Only the final archive move (into the "Mahnungen" folder) should fail --
|
||||||
|
# the mail has already been handed to send_via_smtp by that point.
|
||||||
|
if str(dst).endswith(".eml") and "Mahnungen" in str(dst):
|
||||||
|
raise OSError("disk full")
|
||||||
|
return real_replace(src, dst)
|
||||||
|
|
||||||
|
monkeypatch.setattr(reminder_mail_module.os, "replace", _flaky_replace)
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="zugestellt"):
|
||||||
|
generate_and_send_reminder_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
reminder["reminder_id"],
|
||||||
|
delivery_mode="send",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
stored_reminder = data.reminders[0]
|
||||||
|
assert stored_reminder["status"] == "sent", "mail was already sent -- must not be un-sent"
|
||||||
|
assert stored_reminder.get("fee_item_ids")
|
||||||
|
events = repository.get_events(member.member_id)
|
||||||
|
assert events[-1].event_type == "reminder_email_sent"
|
||||||
|
assert events[-1].data["archive_error"]
|
||||||
|
assert "document" not in events[-1].references
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminder_mail_archive_dir_creation_failure_reverts_sent_status(tmp_path, monkeypatch):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||||
|
member.email = "reminder@example.org"
|
||||||
|
repository.save_member(member)
|
||||||
|
reminder = repository.create_reminder_draft(
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
level=1,
|
||||||
|
name="Zahlungserinnerung",
|
||||||
|
payment_deadline_days=14,
|
||||||
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
original_mkdir = Path.mkdir
|
||||||
|
|
||||||
|
def _flaky_mkdir(self, *args, **kwargs):
|
||||||
|
if self.name == "Mahnungen":
|
||||||
|
raise OSError("permission denied")
|
||||||
|
return original_mkdir(self, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "mkdir", _flaky_mkdir)
|
||||||
|
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
generate_and_send_reminder_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
reminder["reminder_id"],
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Zahlungserinnerung.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
stored_reminder = data.reminders[0]
|
||||||
|
assert stored_reminder["status"] == "draft"
|
||||||
|
assert not stored_reminder.get("fee_item_ids")
|
||||||
|
|||||||
@@ -388,3 +388,288 @@ def test_generated_mail_send_mode_copies_to_sent_folder_reusing_one_imap_connect
|
|||||||
assert len(imap_connections) == 1, "one IMAP connection should be reused for the whole batch"
|
assert len(imap_connections) == 1, "one IMAP connection should be reused for the whole batch"
|
||||||
assert ensured == ["INBOX.Gesendet"]
|
assert ensured == ["INBOX.Gesendet"]
|
||||||
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")] * 2
|
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")] * 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_mail_archiving_failure_after_smtp_success_keeps_batch_going(tmp_path, monkeypatch):
|
||||||
|
import ccma.services.sepa_mail as sepa_mail_module
|
||||||
|
|
||||||
|
repository, ada = _repository(tmp_path)
|
||||||
|
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
|
||||||
|
second.status = "active"
|
||||||
|
second.email = "grace@example.org"
|
||||||
|
second.account_holder = "Grace Hopper"
|
||||||
|
second.iban = "DE89370400440532013000"
|
||||||
|
second.bic = "COBADEFFXXX"
|
||||||
|
second.mandate_reference = "MANDAT-43"
|
||||||
|
second.mandate_signed_at = "2025-01-10"
|
||||||
|
second.mandate_active = True
|
||||||
|
repository.save_member(second)
|
||||||
|
repository.save_contributions(
|
||||||
|
second.member_id,
|
||||||
|
ContributionData(
|
||||||
|
claims=[
|
||||||
|
{
|
||||||
|
"claim_id": "due",
|
||||||
|
"title": "Mitgliedsbeitrag 2026",
|
||||||
|
"amount": "150.00",
|
||||||
|
"due_date": "2026-01-31",
|
||||||
|
"status": "open",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
organization = repository.get_configuration()["organization"]
|
||||||
|
organization.update(
|
||||||
|
{
|
||||||
|
"name": "Chaos Computer Club Mannheim e.V.",
|
||||||
|
"email": "verwaltung@example.org",
|
||||||
|
"iban": "DE89370400440532013000",
|
||||||
|
"bic": "COBADEFFXXX",
|
||||||
|
"creditor_id": "DE98ZZZ09999999999",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
repository.save_organization(organization)
|
||||||
|
repository.save_email_settings(
|
||||||
|
delivery_mode="send",
|
||||||
|
smtp_host="mail.example.org",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_security="none",
|
||||||
|
smtp_username="",
|
||||||
|
smtp_password="",
|
||||||
|
imap_host="",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security="ssl",
|
||||||
|
imap_username="",
|
||||||
|
imap_password="",
|
||||||
|
imap_drafts_folder="",
|
||||||
|
)
|
||||||
|
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||||
|
assert len(debits) == 2
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
||||||
|
)
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", lambda client, content: sent.append(content))
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
original_write_bytes = Path.write_bytes
|
||||||
|
|
||||||
|
def _flaky_write_bytes(self, data):
|
||||||
|
if "documents" in str(self) and "C3-43" in self.name:
|
||||||
|
raise OSError("disk full")
|
||||||
|
return original_write_bytes(self, data)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "write_bytes", _flaky_write_bytes)
|
||||||
|
|
||||||
|
generated, warnings = generate_debit_mails(
|
||||||
|
repository,
|
||||||
|
debits,
|
||||||
|
collection_date=date(2026, 8, 3),
|
||||||
|
delivery_mode="send",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both mails were handed to SMTP -- a broken archive write for one of them must
|
||||||
|
# not swallow the other, nor pretend the failing one was never sent.
|
||||||
|
assert len(sent) == 2
|
||||||
|
assert len(generated) == 2
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "C3-43" in warnings[0] or "Grace" in warnings[0]
|
||||||
|
|
||||||
|
failing = next(g for g in generated if g.member_id == second.member_id)
|
||||||
|
assert not failing.archive_path.is_file()
|
||||||
|
succeeding = next(g for g in generated if g.member_id == ada.member_id)
|
||||||
|
assert succeeding.archive_path.is_file()
|
||||||
|
|
||||||
|
grace_event = repository.get_events(second.member_id)[-1]
|
||||||
|
assert grace_event.event_type == "sepa_notification_generated"
|
||||||
|
assert grace_event.data["archive_error"]
|
||||||
|
assert "document" not in grace_event.references
|
||||||
|
ada_event = repository.get_events(ada.member_id)[-1]
|
||||||
|
assert "document" in ada_event.references
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_mail_smtp_failure_for_one_debit_does_not_abort_the_batch(tmp_path, monkeypatch):
|
||||||
|
import ccma.services.sepa_mail as sepa_mail_module
|
||||||
|
from ccma.storage.repository import RepositoryError
|
||||||
|
|
||||||
|
repository, ada = _repository(tmp_path)
|
||||||
|
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
|
||||||
|
second.status = "active"
|
||||||
|
second.email = "grace@example.org"
|
||||||
|
second.account_holder = "Grace Hopper"
|
||||||
|
second.iban = "DE89370400440532013000"
|
||||||
|
second.bic = "COBADEFFXXX"
|
||||||
|
second.mandate_reference = "MANDAT-43"
|
||||||
|
second.mandate_signed_at = "2025-01-10"
|
||||||
|
second.mandate_active = True
|
||||||
|
repository.save_member(second)
|
||||||
|
repository.save_contributions(
|
||||||
|
second.member_id,
|
||||||
|
ContributionData(
|
||||||
|
claims=[
|
||||||
|
{
|
||||||
|
"claim_id": "due",
|
||||||
|
"title": "Mitgliedsbeitrag 2026",
|
||||||
|
"amount": "150.00",
|
||||||
|
"due_date": "2026-01-31",
|
||||||
|
"status": "open",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
organization = repository.get_configuration()["organization"]
|
||||||
|
organization.update(
|
||||||
|
{
|
||||||
|
"name": "Chaos Computer Club Mannheim e.V.",
|
||||||
|
"email": "verwaltung@example.org",
|
||||||
|
"iban": "DE89370400440532013000",
|
||||||
|
"bic": "COBADEFFXXX",
|
||||||
|
"creditor_id": "DE98ZZZ09999999999",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
repository.save_organization(organization)
|
||||||
|
repository.save_email_settings(
|
||||||
|
delivery_mode="send",
|
||||||
|
smtp_host="mail.example.org",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_security="none",
|
||||||
|
smtp_username="",
|
||||||
|
smtp_password="",
|
||||||
|
imap_host="",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security="ssl",
|
||||||
|
imap_username="",
|
||||||
|
imap_password="",
|
||||||
|
imap_drafts_folder="",
|
||||||
|
)
|
||||||
|
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||||
|
assert len(debits) == 2
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
||||||
|
)
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
def _flaky_send(client, content):
|
||||||
|
if b"ada@example.org" in content:
|
||||||
|
raise RepositoryError("E-Mail konnte nicht versandt werden: connection reset")
|
||||||
|
sent.append(content)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", _flaky_send)
|
||||||
|
|
||||||
|
generated, warnings = generate_debit_mails(
|
||||||
|
repository,
|
||||||
|
debits,
|
||||||
|
collection_date=date(2026, 8, 3),
|
||||||
|
delivery_mode="send",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(sent) == 1
|
||||||
|
assert len(generated) == 1
|
||||||
|
assert generated[0].member_id == second.member_id
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "connection reset" in warnings[0]
|
||||||
|
ada_events = repository.get_events(ada.member_id)
|
||||||
|
assert all(event.event_type != "sepa_notification_generated" for event in ada_events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_mail_archive_dir_creation_failure_for_one_debit_does_not_abort_the_batch(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
repository, ada = _repository(tmp_path)
|
||||||
|
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
|
||||||
|
second.status = "active"
|
||||||
|
second.email = "grace@example.org"
|
||||||
|
second.account_holder = "Grace Hopper"
|
||||||
|
second.iban = "DE89370400440532013000"
|
||||||
|
second.bic = "COBADEFFXXX"
|
||||||
|
second.mandate_reference = "MANDAT-43"
|
||||||
|
second.mandate_signed_at = "2025-01-10"
|
||||||
|
second.mandate_active = True
|
||||||
|
repository.save_member(second)
|
||||||
|
repository.save_contributions(
|
||||||
|
second.member_id,
|
||||||
|
ContributionData(
|
||||||
|
claims=[
|
||||||
|
{
|
||||||
|
"claim_id": "due",
|
||||||
|
"title": "Mitgliedsbeitrag 2026",
|
||||||
|
"amount": "150.00",
|
||||||
|
"due_date": "2026-01-31",
|
||||||
|
"status": "open",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
organization = repository.get_configuration()["organization"]
|
||||||
|
organization.update(
|
||||||
|
{
|
||||||
|
"name": "Chaos Computer Club Mannheim e.V.",
|
||||||
|
"email": "verwaltung@example.org",
|
||||||
|
"iban": "DE89370400440532013000",
|
||||||
|
"bic": "COBADEFFXXX",
|
||||||
|
"creditor_id": "DE98ZZZ09999999999",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
repository.save_organization(organization)
|
||||||
|
repository.save_email_settings(
|
||||||
|
delivery_mode="send",
|
||||||
|
smtp_host="mail.example.org",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_security="none",
|
||||||
|
smtp_username="",
|
||||||
|
smtp_password="",
|
||||||
|
imap_host="",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security="ssl",
|
||||||
|
imap_username="",
|
||||||
|
imap_password="",
|
||||||
|
imap_drafts_folder="",
|
||||||
|
)
|
||||||
|
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||||
|
assert len(debits) == 2
|
||||||
|
|
||||||
|
import ccma.services.sepa_mail as sepa_mail_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
||||||
|
)
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", lambda client, content: sent.append(content))
|
||||||
|
|
||||||
|
original_mkdir = Path.mkdir
|
||||||
|
|
||||||
|
def _flaky_mkdir(self, *args, **kwargs):
|
||||||
|
if self.name == "SEPA" and second.member_id in str(self):
|
||||||
|
raise OSError("permission denied")
|
||||||
|
return original_mkdir(self, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "mkdir", _flaky_mkdir)
|
||||||
|
|
||||||
|
generated, warnings = generate_debit_mails(
|
||||||
|
repository,
|
||||||
|
debits,
|
||||||
|
collection_date=date(2026, 8, 3),
|
||||||
|
delivery_mode="send",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Grace's archive directory could not even be created -- nothing was sent to her,
|
||||||
|
# so she must NOT show up as delivered, but Ada's mail must still go out.
|
||||||
|
assert len(sent) == 1
|
||||||
|
assert len(generated) == 1
|
||||||
|
assert generated[0].member_id == ada.member_id
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "Grace" in warnings[0] or "C3-43" in warnings[0]
|
||||||
|
|||||||
@@ -219,3 +219,51 @@ def test_member_without_email_is_refused(tmp_path):
|
|||||||
sender_email="verwaltung@example.org",
|
sender_email="verwaltung@example.org",
|
||||||
signature="Der Vorstand",
|
signature="Der Vorstand",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_archiving_failure_after_smtp_success_still_logs_the_sent_mail(tmp_path, monkeypatch):
|
||||||
|
import ccma.services.welcome_mail as welcome_mail_module
|
||||||
|
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
repository.save_email_settings(
|
||||||
|
delivery_mode="send",
|
||||||
|
smtp_host="mail.example.org",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_security="none",
|
||||||
|
smtp_username="",
|
||||||
|
smtp_password="",
|
||||||
|
imap_host="",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security="ssl",
|
||||||
|
imap_username="",
|
||||||
|
imap_password="",
|
||||||
|
imap_drafts_folder="",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(welcome_mail_module, "send_via_smtp", lambda client, content: None)
|
||||||
|
real_replace = welcome_mail_module.os.replace
|
||||||
|
|
||||||
|
def _flaky_replace(src, dst):
|
||||||
|
# Only the final move into the member file fails -- the mail is out by then.
|
||||||
|
if str(dst).endswith(".eml") and "Willkommen" in str(dst):
|
||||||
|
raise OSError("disk full")
|
||||||
|
return real_replace(src, dst)
|
||||||
|
|
||||||
|
monkeypatch.setattr(welcome_mail_module.os, "replace", _flaky_replace)
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="zugestellt"):
|
||||||
|
generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="send",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
event = repository.get_events(member.member_id)[-1]
|
||||||
|
assert event.event_type == "welcome_email_sent"
|
||||||
|
assert event.data["archive_error"]
|
||||||
|
assert "document" not in event.references
|
||||||
|
|||||||
Reference in New Issue
Block a user