from contextlib import contextmanager from email.parser import BytesParser from email.policy import default import pytest from ccma.services.welcome_mail import generate_and_send_welcome_mail, open_claims from ccma.storage.repository import MemberRepository, RepositoryError def _new_member_repository(tmp_path): repository = MemberRepository(tmp_path) repository.initialize() 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) member = repository.create_member( first_name="Ada", last_name="Lovelace", birth_date="1990-01-01" ) member.email = "ada@example.org" member.accepted_at = "2026-08-20" repository.save_member(member) repository.create_manual_claim( member.member_id, title="Aufnahmegebühr", amount="15.00", due_date="2026-09-17" ) repository.create_manual_claim( member.member_id, title="Mitgliedsbeitrag 2. Halbjahr 2026", amount="60.00", due_date="2026-09-17", ) return repository, member def test_welcome_mail_lists_open_claims_and_bank_details(tmp_path): repository, member = _new_member_repository(tmp_path) export_path = tmp_path / "Willkommen.eml" generated = generate_and_send_welcome_mail( repository, member.member_id, delivery_mode="local", output_path=export_path, sender_name="Verwaltung C3MA", sender_email="verwaltung@example.org", signature="Der Vorstand", ) message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes()) content = message.get_content() assert message["To"] == "ada@example.org" assert "Willkommen" in message["Subject"] assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in content assert "Gesamtbetrag: 75.00 Euro" in content assert "DE98670505050038907751" in content assert content.rstrip().endswith("Der Vorstand") # No "Mitglied seit" is stored yet -- a member accepted in August starts on 1 September. assert "Ab dem 01.09.2026 bist du offiziell Mitglied" in content def test_welcome_mail_is_archived_and_logged(tmp_path): repository, member = _new_member_repository(tmp_path) generated = generate_and_send_welcome_mail( repository, member.member_id, delivery_mode="local", output_path=tmp_path / "Willkommen.eml", sender_name="Verwaltung C3MA", sender_email="verwaltung@example.org", signature="Der Vorstand", ) assert generated.archive_path.parent.name == "Willkommen" assert generated.archive_path.read_bytes() == generated.export_path.read_bytes() event = repository.get_events(member.member_id)[-1] assert event.event_type == "welcome_email_sent" assert event.data["recipient"] == "ada@example.org" assert len(event.data["claim_ids"]) == 2 def test_only_selected_claims_are_billed(tmp_path): repository, member = _new_member_repository(tmp_path) admission = next( claim for claim in open_claims(repository, member.member_id) if claim["title"] == "Aufnahmegebühr" ) generated = generate_and_send_welcome_mail( repository, member.member_id, claim_ids=[admission["claim_id"]], delivery_mode="local", output_path=tmp_path / "Willkommen.eml", sender_name="Verwaltung C3MA", sender_email="verwaltung@example.org", signature="Der Vorstand", ) content = BytesParser(policy=default).parsebytes( generated.export_path.read_bytes() ).get_content() assert "Aufnahmegebühr" in content assert "Mitgliedsbeitrag 2. Halbjahr 2026" not in content assert "Gesamtbetrag: 15.00 Euro" in content def test_members_with_mandate_are_told_the_money_is_collected(tmp_path): repository, member = _new_member_repository(tmp_path) member.account_holder = "Ada Lovelace" member.iban = "DE98670505050038907751" member.mandate_reference = "MANDAT-42" member.mandate_signed_at = "2026-08-20" member.mandate_active = True repository.save_member(member) generated = generate_and_send_welcome_mail( repository, member.member_id, delivery_mode="local", output_path=tmp_path / "Willkommen.eml", sender_name="Verwaltung C3MA", sender_email="verwaltung@example.org", signature="Der Vorstand", ) content = BytesParser(policy=default).parsebytes( generated.export_path.read_bytes() ).get_content() assert "SEPA-Lastschriftmandat erteilt (Mandatsreferenz MANDAT-42)" in content assert "Bitte überweise" not in content def test_edited_template_is_used_for_the_mail(tmp_path): repository, member = _new_member_repository(tmp_path) repository.save_mail_template( "welcome", subject="Servus {{member.first_name}}", body="Du schuldest uns {{claims.total}} Euro.\n{{#claims}}* {{claim.title}}{{/claims}}", ) generated = generate_and_send_welcome_mail( repository, member.member_id, delivery_mode="local", output_path=tmp_path / "Willkommen.eml", sender_name="Verwaltung C3MA", sender_email="verwaltung@example.org", signature="Der Vorstand", ) message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes()) assert message["Subject"] == "Servus Ada" assert message.get_content().replace("\r\n", "\n").strip() == ( "Du schuldest uns 75.00 Euro.\n* Aufnahmegebühr\n* Mitgliedsbeitrag 2. Halbjahr 2026" ) def test_send_mode_delivers_via_smtp_without_local_file(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="smtp.example.org", smtp_port=587, smtp_security="starttls", smtp_username="verwaltung", smtp_password="secret", imap_host="", imap_port=993, imap_security="ssl", imap_username="", imap_password="", imap_drafts_folder="INBOX.Entwürfe", ) sent = [] monkeypatch.setattr( welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"])) ) monkeypatch.setattr( welcome_mail_module, "send_via_smtp", lambda client, content: sent.append(content) ) generated = generate_and_send_welcome_mail( repository, member.member_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.read_bytes() == sent[0] assert repository.get_events(member.member_id)[-1].data["delivery_mode"] == "send" def test_member_without_email_is_refused(tmp_path): repository, member = _new_member_repository(tmp_path) member.email = "" repository.save_member(member) with pytest.raises(RepositoryError, match="E-Mail-Adresse"): generate_and_send_welcome_mail( repository, member.member_id, delivery_mode="local", output_path=tmp_path / "Willkommen.eml", sender_name="Verwaltung C3MA", sender_email="verwaltung@example.org", 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