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
+34
View File
@@ -5,6 +5,7 @@ gebaute Frontend (``web/dist``) aus – hinter dem Nginx Proxy Manager des Nutze
Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
"""
import logging
import threading
from contextlib import asynccontextmanager
from pathlib import Path
@@ -19,6 +20,8 @@ from .config import get_settings
from .database import SessionLocal
from .deps import enforce_account_state
from .migrate import upgrade_to_head
log = logging.getLogger(__name__)
from .routers import (
accounts,
auth,
@@ -30,6 +33,7 @@ from .routers import (
imports,
policy,
inbox,
mail,
quick,
rates,
settings as settings_router,
@@ -68,7 +72,36 @@ async def lifespan(app: FastAPI):
s.close()
threading.Thread(target=_laden, name="ecb-rates", daemon=True).start()
# Zahlungs-Mails regelmässig abholen. Wie beim Kursabruf im Hintergrund:
# Ein langsames oder nicht erreichbares Postfach darf den Dienst nicht
# blockieren. Fehler landen sichtbar an der Quelle, nicht im Log-Nirwana.
stopp = threading.Event()
def _mails() -> None:
from datetime import datetime, timedelta, timezone
from .models import MailSource
from .services import mail_import
while not stopp.wait(60):
s = SessionLocal()
try:
jetzt = datetime.now(timezone.utc)
for quelle in s.query(MailSource).filter(MailSource.is_active.is_(True)):
faellig = quelle.last_poll_at is None or quelle.last_poll_at + timedelta(
minutes=quelle.poll_minutes
) <= jetzt
if faellig:
mail_import.abruf_mit_fehlerbehandlung(s, quelle)
except Exception:
log.exception("Mail-Abruf fehlgeschlagen")
finally:
s.close()
threading.Thread(target=_mails, name="mail-abruf", daemon=True).start()
yield
stopp.set()
app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan)
@@ -120,6 +153,7 @@ api.include_router(backup.router)
api.include_router(rates.router)
api.include_router(quick.router)
api.include_router(inbox.router)
api.include_router(mail.router)
app.include_router(api)