"""Postfächer, Verschlüsselung und der Abruf gegen einen IMAP-Doppelgänger. Ein echter Mailserver wäre im Test weder verfügbar noch wünschenswert. Der Doppelgänger unten spricht nur die Handvoll IMAP-Befehle, die ``mail_import`` benutzt, und liefert die Muster-Mails aus ``fixtures/mail`` aus. """ import pathlib import pytest from app.services import geheimnis, mail_import MUSTER = pathlib.Path(__file__).parent / "fixtures" / "mail" class FakeImap: """Minimaler IMAP-Server: nur was ``mail_import`` tatsächlich aufruft.""" def __init__(self, mails: list[bytes]): self.mails = mails self.gesehen: list[bytes] = [] self.kopiert: list[tuple[bytes, str]] = [] self.abgemeldet = False def login(self, user, passwort): return "OK", [b""] def list(self): return "OK", [b'(\\HasNoChildren) "/" "INBOX"', b'(\\HasNoChildren) "/" "Belege"'] def select(self, ordner): return ("OK", [b"1"]) if "INBOX" in ordner or "Belege" in ordner else ("NO", [b""]) def search(self, charset, kriterium): return "OK", [b" ".join(str(i + 1).encode() for i in range(len(self.mails)))] def fetch(self, uid, teile): return "OK", [(b"1 (RFC822 {1})", self.mails[int(uid) - 1])] def store(self, uid, befehl, flags): if "Seen" in flags: self.gesehen.append(uid) return "OK", [b""] def copy(self, uid, ordner): self.kopiert.append((uid, ordner)) return "OK", [b""] def logout(self): self.abgemeldet = True return "BYE", [b""] @pytest.fixture() def fake_imap(monkeypatch): """Ersetzt die Verbindung; gibt eine Funktion zum Bestücken zurück.""" gehalten = {} def bestuecken(*dateinamen): mails = [(MUSTER / n).read_bytes() for n in dateinamen] server = FakeImap(mails) gehalten["server"] = server monkeypatch.setattr(mail_import, "_verbinden", lambda quelle: server) return server return bestuecken def _konto(admin, **kw): body = {"name": "Raiffeisen", "type": "girokonto", "currency": "CHF", "opening_balance_cents": 0} body.update(kw) return admin.post("/api/accounts", json=body).json() def _postfach(admin, **kw): body = {"name": "Belege", "host": "imap.example.com", "port": 993, "username": "kundin@example.com", "password": "geheim123", "folder": "INBOX"} body.update(kw) r = admin.post("/api/mail/sources", json=body) assert r.status_code == 201, r.text return r.json() def test_passwort_wird_verschluesselt_abgelegt_und_nie_ausgeliefert(admin): from app.database import SessionLocal from app.models import MailSource quelle = _postfach(admin) assert "password" not in quelle assert quelle["password_set"] is True db = SessionLocal() try: zeile = db.get(MailSource, quelle["id"]) assert zeile.password_enc != "geheim123", "Klartext in der Datenbank" assert geheimnis.entschluesseln(zeile.password_enc) == "geheim123" finally: db.close() def test_unlesbares_passwort_meldet_sich_verstaendlich(admin, monkeypatch): """Getauschtes Server-Geheimnis darf nicht in einem Stacktrace enden.""" from app.database import SessionLocal from app.models import MailSource quelle = _postfach(admin) db = SessionLocal() try: zeile = db.get(MailSource, quelle["id"]) zeile.password_enc = "kaputt" db.commit() with pytest.raises(mail_import.MailFehler) as fehler: mail_import._verbinden(zeile) assert "entschlüsseln" in str(fehler.value) finally: db.close() def test_passwort_bleibt_wenn_es_beim_aendern_weggelassen_wird(admin): from app.database import SessionLocal from app.models import MailSource quelle = _postfach(admin) r = admin.put(f"/api/mail/sources/{quelle['id']}", json={ "host": "imap.example.com", "username": "kundin@example.com", "folder": "Belege"}) assert r.status_code == 200, r.text assert r.json()["folder"] == "Belege" db = SessionLocal() try: assert geheimnis.entschluesseln(db.get(MailSource, quelle["id"]).password_enc) == "geheim123" finally: db.close() def test_abruf_legt_erkannte_zahlung_in_den_eingang(admin, fake_imap): konto = _konto(admin) quelle = _postfach(admin) admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "name": "Raiffeisen", "from_contains": "raiffeisen.ch", "account_id": konto["id"]}) server = fake_imap("raiffeisen_zahlungsstatus.eml") r = admin.post(f"/api/mail/sources/{quelle['id']}/fetch") assert r.status_code == 200, r.text assert r.json()["neu"] == 1 treffer = [e for e in admin.get("/api/inbox").json() if e["payee"] == "Sunrise GmbH / Yallo"] assert len(treffer) == 1 assert treffer[0]["amount_cents"] == -1490 assert treffer[0]["currency"] == "CHF" assert treffer[0]["date"] == "2026-07-31" assert treffer[0]["account_id"] == konto["id"] # Nichts wird stillschweigend gebucht. assert treffer[0]["status"] == "pending" assert treffer[0]["transaction_id"] is None assert server.gesehen, "Mail muss als gelesen markiert werden" def test_dieselbe_mail_zweimal_ergibt_einen_eintrag(admin, fake_imap): """Die Message-ID zählt, nicht die UID – die vergibt der Server neu.""" konto = _konto(admin) quelle = _postfach(admin) admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "from_contains": "raiffeisen.ch", "account_id": konto["id"]}) fake_imap("raiffeisen_zahlungsstatus.eml") admin.post(f"/api/mail/sources/{quelle['id']}/fetch") vorher = len(admin.get("/api/inbox").json()) zweiter = admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json() assert zweiter["neu"] == 0 assert zweiter["uebersprungen"] == 1 assert len(admin.get("/api/inbox").json()) == vorher def test_mail_ohne_passende_zuordnung_wird_uebergangen(admin, fake_imap): quelle = _postfach(admin) admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "from_contains": "sparkasse.de"}) fake_imap("raiffeisen_zahlungsstatus.eml") ergebnis = admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json() assert ergebnis["neu"] == 0 assert ergebnis["uebersprungen"] == 1 def test_zuordnung_ohne_konto_landet_als_unklar(admin, fake_imap): """Ohne Zielkonto lässt sich nicht buchen – das muss sichtbar sein.""" quelle = _postfach(admin) admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "from_contains": "revolut.com"}) fake_imap("revolut_ueberweisung.eml") assert admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json()["unklar"] == 1 def test_verbindungstest_liefert_die_ordner(admin, fake_imap): quelle = _postfach(admin) fake_imap() antwort = admin.post(f"/api/mail/sources/{quelle['id']}/test") assert antwort.status_code == 200, antwort.text assert "Belege" in antwort.json()["folders"] def test_verschieben_statt_nur_markieren(admin, fake_imap): konto = _konto(admin) quelle = _postfach(admin, done_folder="Belege") admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "from_contains": "raiffeisen.ch", "account_id": konto["id"]}) server = fake_imap("raiffeisen_zahlungsstatus.eml") admin.post(f"/api/mail/sources/{quelle['id']}/fetch") assert server.kopiert and "Belege" in server.kopiert[0][1] def test_weitergeleitete_mail_wird_am_originalabsender_erkannt(admin, fake_imap, monkeypatch): """Leitet man Bank-Mails in ein Sammelpostfach weiter, steht die eigene Adresse im ``From``. Der Originalabsender bleibt aber in den Weiterleitungs-Koepfen erhalten – daran muss die Zuordnung greifen.""" konto = _konto(admin) quelle = _postfach(admin) admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "from_contains": "raiffeisen.ch", "account_id": konto["id"]}) roh = (MUSTER / "raiffeisen_zahlungsstatus.eml").read_bytes() weitergeleitet = roh.replace( b"From: noreply@ebanking.raiffeisen.ch", b"From: ich@gmail.com\nX-Forwarded-For: noreply@ebanking.raiffeisen.ch ich@gmail.com", ).replace(b" bytes: """Baut aus einer Bank-Mail eine von Hand weitergeleitete nach. Dabei steht die EIGENE Adresse im From, der Betreff bekommt ein „WG:“ und der Originalkopf wandert in den Text – genau wie bei Gmail und Proton. """ roh = (MUSTER / quelldatei).read_bytes().decode("utf-8") kopf, koerper = roh.split("\n\n", 1) zitat = ("---------- Weitergeleitete Nachricht ----------\n" "Von: noreply@ebanking.raiffeisen.ch\n" "Betreff: Raiffeisen E-Banking - Änderung Zahlungsstatus\n\n") neu = ( "From: Alex Muster \n" "To: sammel@example.com\n" "Date: Fri, 31 Jul 2026 08:00:00 +0200\n" f"Message-Id: <{message_id.decode()}@example.invalid>\n" "Subject: =?utf-8?Q?WG=3A_Raiffeisen_E-Banking?=\n" "Mime-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "\n" + zitat + koerper.replace("=\n", "").replace("=C3=84", "Ä") .replace("=C3=BC", "ü").replace("=C3=A4", "ä").replace("=C3=BC", "ü") .replace("=C3=BCber", "über").replace("Beg=C3=BCnstigter", "Begünstigter") .replace("Ausf=C3=BChrungsdatum", "Ausführungsdatum") ) return neu.encode("utf-8") def test_von_hand_weitergeleitete_mail_wird_am_betreff_erkannt(admin, monkeypatch): """Der Nutzer trägt „raiffeisen“ ein – das steht bei einer manuellen Weiterleitung im Betreff, nicht im Absender. Genau dort muss es greifen.""" konto = _konto(admin) quelle = _postfach(admin) admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "from_contains": "raiffeisen", "account_id": konto["id"]}) monkeypatch.setattr(mail_import, "_verbinden", lambda q: FakeImap([_weitergeleitet("raiffeisen_zahlungsstatus.eml", b"wg-1")])) ergebnis = admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json() assert ergebnis["neu"] == 1, ergebnis treffer = [e for e in admin.get("/api/inbox").json() if e["payee"] == "Sunrise GmbH / Yallo"] assert treffer, "Die Werte müssen auch aus der weitergeleiteten Mail lesbar sein" assert treffer[0]["amount_cents"] == -1490 def test_zuordnung_laesst_sich_aendern_und_loeschen(admin): konto = _konto(admin) zweites = _konto(admin, name="Zweitkonto") quelle = _postfach(admin) a = admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={"from_contains": "alt", "account_id": konto["id"]}).json() r = admin.put(f"/api/mail/senders/{a['id']}", json={"from_contains": "neu", "account_id": zweites["id"]}) assert r.status_code == 200, r.text assert r.json()["from_contains"] == "neu" assert r.json()["account_id"] == zweites["id"] assert admin.delete(f"/api/mail/senders/{a['id']}").status_code == 204 uebrig = admin.get(f"/api/mail/sources/{quelle['id']}/senders").json() assert not [x for x in uebrig if x["id"] == a["id"]] def test_waehrung_entscheidet_ueber_das_konto(admin, monkeypatch): """Revolut schickt für Euro und Franken dieselbe Mail vom selben Absender. Erst die erkannte Währung sagt, auf welches der beiden Revolut-Konten die Zahlung gehört – ohne dieses Kriterium landet alles auf demselben. """ eur = _konto(admin, name="Revolut Euro", currency="EUR") chf = _konto(admin, name="Revolut CHF", currency="CHF") quelle = _postfach(admin) admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "from_contains": "revolut", "currency": "EUR", "account_id": eur["id"]}) admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "from_contains": "revolut", "currency": "CHF", "account_id": chf["id"]}) def mail(message_id: str, betrag: str, waehrung: str) -> bytes: return ( f"From: Revolut \n" f"To: kundin@example.com\n" f"Date: Sat, 01 Aug 2026 10:41:51 +0000\n" f"Message-Id: <{message_id}@example.invalid>\n" f"Subject: Zahlung\n" f"Content-Type: text/plain; charset=utf-8\n" f"\nDu hast Geld gesendet.\nGesendet\n{betrag} {waehrung}\nAn\nAlex Muster\n" ).encode("utf-8") monkeypatch.setattr(mail_import, "_verbinden", lambda q: FakeImap([mail("waehrung-eur", "324.00", "EUR"), mail("waehrung-chf", "50.00", "CHF")])) assert admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json()["neu"] == 2 eintraege = admin.get("/api/inbox").json() in_eur = [e for e in eintraege if e["amount_cents"] == -32400] in_chf = [e for e in eintraege if e["amount_cents"] == -5000] assert in_eur and in_eur[0]["account_id"] == eur["id"], "EUR gehört aufs Euro-Konto" assert in_chf and in_chf[0]["account_id"] == chf["id"], "CHF gehört aufs Franken-Konto" def test_zuordnung_mit_waehrung_schlaegt_die_allgemeine(admin, monkeypatch): """Wer eine genaue Regel neben eine allgemeine stellt, meint die genaue.""" eur = _konto(admin, name="Genau EUR", currency="EUR") sammel = _konto(admin, name="Sammelkonto", currency="CHF") quelle = _postfach(admin) # Die allgemeine zuerst anlegen – sie darf trotzdem nicht gewinnen. admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "from_contains": "revolut", "account_id": sammel["id"]}) admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "from_contains": "revolut", "currency": "EUR", "account_id": eur["id"]}) roh = ("From: Revolut \nTo: k@example.com\n" "Date: Sat, 01 Aug 2026 10:41:51 +0000\n" "Message-Id: \nSubject: Zahlung\n" "Content-Type: text/plain; charset=utf-8\n" "\nDu hast Geld gesendet.\nGesendet\n12.00 EUR\nAn\nAlex Muster\n").encode("utf-8") monkeypatch.setattr(mail_import, "_verbinden", lambda q: FakeImap([roh])) admin.post(f"/api/mail/sources/{quelle['id']}/fetch") treffer = [e for e in admin.get("/api/inbox").json() if e["amount_cents"] == -1200] assert treffer and treffer[0]["account_id"] == eur["id"] def test_mailvorschlag_wird_erst_auf_knopfdruck_gebucht(admin, fake_imap): """Eine Mail bucht nie von selbst – aber sie muss sich buchen lassen.""" konto = _konto(admin, name="Buchbar", opening_balance_cents=0) quelle = _postfach(admin) admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ "from_contains": "raiffeisen", "account_id": konto["id"]}) roh = (MUSTER / "raiffeisen_zahlungsstatus.eml").read_bytes().replace( b"