Files
certain/backend/app/main.py
T
GuidoandClaude Opus 5 fc6ee2f345 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>
2026-08-22 19:58:06 +02:00

179 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FastAPI-Anwendung.
Ein einzelner Prozess liefert sowohl die JSON-API unter ``/api/*`` als auch das
gebaute Frontend (``web/dist``) aus – hinter dem Nginx Proxy Manager des Nutzers.
Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
"""
import logging
import threading
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import APIRouter, Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .branding import APP_NAME, VERSION, git_sha
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,
backup,
budgets,
categories,
dashboard,
documents,
imports,
policy,
inbox,
mail,
quick,
rates,
settings as settings_router,
transactions,
users,
)
from .seed import ensure_first_admin
from .services import ecb_rates
settings = get_settings()
# Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier).
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DIST_DIR = REPO_ROOT / "web" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
# Schema auf den aktuellen Stand bringen (idempotent).
upgrade_to_head()
db = SessionLocal()
try:
ensure_first_admin(db)
finally:
db.close()
# EZB-Kurse im HINTERGRUND laden – der Start darf nie darauf warten
# (langsames/geblocktes Netz oder hängendes DNS würde den Dienst sonst
# blockieren und der Health-Check nie grün werden).
if settings.ecb_fetch_on_start:
def _laden() -> None:
s = SessionLocal()
try:
ecb_rates.ensure_rates_on_startup(s, True)
finally:
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)
# CORS nur, wenn ausdrücklich konfiguriert (Standard: gleiche Herkunft, aus).
if settings.cors_origins.strip():
origins = (
["*"]
if settings.cors_origins.strip() == "*"
else [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
)
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- API -------------------------------------------------------------------
# Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt:
# Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an
# keiner Stelle vorbei, auch nicht an neu hinzukommenden.
api = APIRouter(prefix="/api", dependencies=[Depends(enforce_account_state)])
@api.get("/health", tags=["meta"])
def health() -> dict:
return {"status": "ok"}
@api.get("/version", tags=["meta"])
def version() -> dict:
return {"name": APP_NAME, "version": VERSION, "git_sha": git_sha()}
api.include_router(auth.router)
api.include_router(users.router)
api.include_router(settings_router.router)
api.include_router(policy.router)
api.include_router(accounts.router)
api.include_router(categories.router)
api.include_router(transactions.router)
api.include_router(budgets.router)
api.include_router(dashboard.router)
api.include_router(imports.router)
api.include_router(documents.router)
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)
# --- Frontend (SPA) --------------------------------------------------------
# Gebaute, gehashte Assets unter /assets aggressiv cachebar ausliefern; alle
# übrigen (nicht-/api) Pfade auf index.html abbilden, damit Client-seitige
# Routen wie /accounts direkt aufrufbar sind (verlinkbar).
if DIST_DIR.exists():
assets_dir = DIST_DIR / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa(full_path: str):
# /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA).
if full_path.startswith("api"):
raise HTTPException(status_code=404, detail="Not Found")
candidate = DIST_DIR / full_path
if full_path and candidate.is_file():
return FileResponse(candidate)
return FileResponse(DIST_DIR / "index.html", headers={"Cache-Control": "no-cache"})