feat: Postfaecher einrichten und Zahlungs-Mails abholen (Etappe B, Teil 2)

- Verschluesselung fuer Zugangsdaten: Ein IMAP-Passwort laesst sich nicht
  hashen, Certain muss es dem Mailserver vorlegen. Es liegt deshalb mit
  Fernet verschluesselt in der Datenbank, der Schluessel wird per HKDF aus
  session_secret abgeleitet und nirgends gespeichert. Wird das Geheimnis
  getauscht, meldet Certain das verstaendlich statt mit einem Stacktrace.
- IMAP-Abruf ueber imaplib/email (Standardbibliothek). Ordnerliste,
  Verbindungstest mit klarer Fehlermeldung, Absender-Zuordnung auf Konten,
  danach als gelesen markieren oder verschieben.
- Wiedererkennung ueber die Message-ID statt der UID – die vergibt der
  Server beim Verschieben neu.
- Nichts wird stillschweigend gebucht: Eine Mail wird zum Eingangs-Eintrag.
  Fehlt das Zielkonto oder ist ein Feld unsicher, gilt sie als unklar.
- Hintergrund-Thread beim Start, nach dem Muster des EZB-Kursabrufs: ein
  langsames Postfach darf den Dienst nie blockieren.

Getestet gegen einen IMAP-Doppelgaenger, damit die Suite ohne Netz laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 19:58:06 +02:00
co-authored by Claude Opus 5
parent 259013eafe
commit fc6ee2f345
9 changed files with 839 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
"""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]