mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
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.
373 lines
12 KiB
Python
373 lines
12 KiB
Python
from contextlib import contextmanager
|
|
from email.parser import BytesParser
|
|
from email.policy import default
|
|
|
|
import pytest
|
|
from test_reminders import _overdue_claim_repository
|
|
|
|
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):
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
organization = repository.get_configuration()["organization"]
|
|
organization.update(
|
|
{
|
|
"name": "Chaos Computer Club Mannheim e.V.",
|
|
"email": "verwaltung@example.org",
|
|
"iban": "DE98670505050038907751",
|
|
"bic": "MANSDE66XXX",
|
|
}
|
|
)
|
|
repository.save_organization(organization)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
detail="Bitte Mitgliedsnummer angeben.",
|
|
)
|
|
export_path = tmp_path / "Zahlungserinnerung.eml"
|
|
|
|
generated = generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="local",
|
|
output_path=export_path,
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
assert generated.export_path.read_bytes() == generated.archive_path.read_bytes()
|
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
|
assert message["To"] == "reminder@example.org"
|
|
assert "Zahlungserinnerung" in message["Subject"]
|
|
assert "100.00 Euro" in message.get_content()
|
|
assert "DE98670505050038907751" in message.get_content()
|
|
data = repository.get_contributions(member.member_id)
|
|
assert data.reminders[0]["status"] == "sent"
|
|
assert data.reminders[0]["payment_deadline"]
|
|
assert repository.get_events(member.member_id)[-1].event_type == "reminder_email_sent"
|
|
assert generated.archive_path.parent.name == "Mahnungen"
|
|
|
|
|
|
def test_reminder_mail_includes_fee_in_open_balance(tmp_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="Erste Mahnung",
|
|
payment_deadline_days=14,
|
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
|
)
|
|
|
|
generated = generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Mahnung.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
|
assert "Offener Betrag: 105.00 Euro" in message.get_content()
|
|
assert "Mahngebühr: 5.00 Euro" in message.get_content()
|
|
|
|
|
|
def test_reminder_mail_send_mode_delivers_via_smtp_without_local_file(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,
|
|
)
|
|
|
|
sent = []
|
|
fake_smtp_session = contextmanager(lambda settings: iter([object()]))
|
|
monkeypatch.setattr(reminder_mail_module, "smtp_session", fake_smtp_session)
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
|
)
|
|
|
|
generated = 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",
|
|
)
|
|
|
|
assert generated.export_path is None
|
|
assert generated.archive_path.is_file()
|
|
assert len(sent) == 1
|
|
data = repository.get_contributions(member.member_id)
|
|
assert data.reminders[0]["status"] == "sent"
|
|
|
|
|
|
def test_reminder_mail_send_mode_also_copies_to_sent_folder_when_enabled(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="mail.example.org",
|
|
imap_port=993,
|
|
imap_security="ssl",
|
|
imap_username="",
|
|
imap_password="",
|
|
imap_drafts_folder="",
|
|
imap_sent_enabled=True,
|
|
imap_sent_folder="INBOX.Gesendet",
|
|
)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
|
)
|
|
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "imap_session", contextmanager(lambda settings: iter(["imap-client"]))
|
|
)
|
|
ensured = []
|
|
appended = []
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "ensure_imap_folder", lambda client, folder: ensured.append((client, folder))
|
|
)
|
|
monkeypatch.setattr(
|
|
reminder_mail_module,
|
|
"append_message",
|
|
lambda client, content, *, folder, flags: appended.append((client, folder, flags)),
|
|
)
|
|
|
|
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",
|
|
)
|
|
|
|
assert ensured == [("imap-client", "INBOX.Gesendet")]
|
|
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
|