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
+45
View File
@@ -0,0 +1,45 @@
"""Verschlüsselung für Zugangsdaten, die Certain im Klartext braucht.
Ein IMAP-Passwort lässt sich nicht hashen: Certain muss es dem Mailserver
vorlegen können. Es liegt deshalb verschlüsselt in der Datenbank – wer die
Datei in die Hände bekommt, hat damit noch nichts.
Der Schlüssel wird aus ``session_secret`` abgeleitet und nirgends gespeichert.
Das hat eine Folge, die im Formular auch so stehen muss: Wird das
Server-Geheimnis getauscht, sind die abgelegten Passwörter unlesbar und die
Postfächer müssen neu eingerichtet werden. Das ist der gewollte Preis dafür,
dass ein Datenbank-Diebstahl allein nicht reicht.
"""
from functools import lru_cache
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from ..config import get_settings
# Trennt diesen Verwendungszweck von jedem anderen, der dasselbe Geheimnis nutzt.
_INFO = b"certain-finance/mail-credentials/v1"
@lru_cache
def _schluessel() -> Fernet:
roh = HKDF(
algorithm=hashes.SHA256(), length=32, salt=None, info=_INFO
).derive(get_settings().session_secret.encode("utf-8"))
import base64
return Fernet(base64.urlsafe_b64encode(roh))
def verschluesseln(klartext: str) -> str:
return _schluessel().encrypt(klartext.encode("utf-8")).decode("ascii")
def entschluesseln(geheim: str) -> str | None:
"""Klartext zurück, oder None wenn der Schlüssel nicht mehr passt."""
try:
return _schluessel().decrypt(geheim.encode("ascii")).decode("utf-8")
except (InvalidToken, ValueError):
return None