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 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")