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)
+53
View File
@@ -684,3 +684,56 @@ class InboxItem(Base):
__table_args__ = (
UniqueConstraint("owner_id", "source", "external_id", name="uq_inbox_owner_source_external"),
)
class MailSource(Base):
"""Ein Postfach, aus dem Certain Zahlungs-Mails abholt."""
__tablename__ = "mail_sources"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False, default=993)
use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
username: Mapped[str] = mapped_column(String(255), nullable=False)
# Verschlüsselt, nicht gehasht: Certain muss es dem Mailserver vorlegen.
password_enc: Mapped[str] = mapped_column(Text, nullable=False)
folder: Mapped[str] = mapped_column(String(255), nullable=False, default="INBOX")
# Nach dem Auswerten: dorthin verschieben (leer = nicht verschieben).
done_folder: Mapped[str | None] = mapped_column(String(255), nullable=True)
mark_seen: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
poll_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class MailSender(Base):
"""Welcher Absender auf welches Konto bucht.
Ohne Zuordnung weiss Certain nicht, wohin eine erkannte Zahlung gehört –
dieselbe Mailadresse kann je nach Konto etwas anderes bedeuten.
"""
__tablename__ = "mail_senders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("mail_sources.id", ondelete="CASCADE"), nullable=False, index=True
)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
# Teilstrings, gegen Absender und Betreff geprüft. Leer = jede Mail.
from_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
subject_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+127
View File
@@ -0,0 +1,127 @@
"""Postfächer einrichten und Zahlungs-Mails abholen."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Account, MailSender, MailSource, User
from ..schemas import MailSenderIn, MailSenderOut, MailSourceIn, MailSourceOut
from ..services import geheimnis, mail_import
router = APIRouter(prefix="/mail", tags=["mail"])
def _aus(quelle: MailSource) -> MailSourceOut:
out = MailSourceOut.model_validate(quelle)
out.password_set = bool(quelle.password_enc)
return out
@router.get("/sources", response_model=list[MailSourceOut])
def list_sources(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
quellen = (
db.query(MailSource).filter(MailSource.owner_id == user.id).order_by(MailSource.id).all()
)
return [_aus(q) for q in quellen]
@router.post("/sources", response_model=MailSourceOut, status_code=status.HTTP_201_CREATED)
def create_source(
payload: MailSourceIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
if not payload.password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Passwort fehlt")
quelle = MailSource(
owner_id=user.id,
**payload.model_dump(exclude={"password"}),
password_enc=geheimnis.verschluesseln(payload.password),
)
db.add(quelle)
db.commit()
db.refresh(quelle)
return _aus(quelle)
@router.put("/sources/{source_id}", response_model=MailSourceOut)
def update_source(
source_id: int,
payload: MailSourceIn,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
quelle = get_owned(db, MailSource, source_id, user)
for schluessel, wert in payload.model_dump(exclude={"password"}).items():
setattr(quelle, schluessel, wert)
# Leeres Passwortfeld heisst „unverändert" – sonst müsste man es bei jeder
# Kleinigkeit erneut eintippen.
if payload.password:
quelle.password_enc = geheimnis.verschluesseln(payload.password)
db.commit()
db.refresh(quelle)
return _aus(quelle)
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_source(source_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
db.delete(get_owned(db, MailSource, source_id, user))
db.commit()
@router.post("/sources/{source_id}/test")
def test_source(source_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Verbindung prüfen und die Ordner des Postfachs zurückgeben."""
quelle = get_owned(db, MailSource, source_id, user)
try:
return mail_import.teste(quelle)
except mail_import.MailFehler as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
@router.post("/sources/{source_id}/fetch")
def fetch_source(source_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
quelle = get_owned(db, MailSource, source_id, user)
try:
return mail_import.hole_neue(db, quelle)
except mail_import.MailFehler as e:
quelle.last_error = str(e)[:500]
db.commit()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# --- Absender-Zuordnung ------------------------------------------------------
@router.get("/sources/{source_id}/senders", response_model=list[MailSenderOut])
def list_senders(source_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
get_owned(db, MailSource, source_id, user)
return (
db.query(MailSender)
.filter(MailSender.source_id == source_id)
.order_by(MailSender.id)
.all()
)
@router.post("/sources/{source_id}/senders", response_model=MailSenderOut,
status_code=status.HTTP_201_CREATED)
def create_sender(
source_id: int,
payload: MailSenderIn,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
get_owned(db, MailSource, source_id, user)
if payload.account_id is not None:
get_owned(db, Account, payload.account_id, user)
absender = MailSender(source_id=source_id, owner_id=user.id, **payload.model_dump())
db.add(absender)
db.commit()
db.refresh(absender)
return absender
@router.delete("/senders/{sender_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_sender(sender_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
db.delete(get_owned(db, MailSender, sender_id, user))
db.commit()
+52
View File
@@ -540,3 +540,55 @@ class QuickBookingOut(BaseModel):
ok: bool = True
message: str
item: InboxItemOut
# --- Postfächer für den Mail-Abruf -------------------------------------------
class MailSourceIn(BaseModel):
name: str = Field(default="", max_length=80)
host: str = Field(min_length=1, max_length=255)
port: int = 993
use_ssl: bool = True
username: str = Field(min_length=1, max_length=255)
# Beim Ändern weglassen, um das gespeicherte Passwort zu behalten.
password: str | None = None
folder: str = "INBOX"
done_folder: str | None = None
mark_seen: bool = True
is_active: bool = True
poll_minutes: int = Field(default=15, ge=5, le=1440)
class MailSourceOut(BaseModel):
id: int
name: str
host: str
port: int
use_ssl: bool
username: str
folder: str
done_folder: str | None
mark_seen: bool
is_active: bool
poll_minutes: int
last_poll_at: datetime | None
last_error: str | None
# Das Passwort verlässt den Server nie – nur die Tatsache, dass eines da ist.
password_set: bool = True
model_config = ConfigDict(from_attributes=True)
class MailSenderIn(BaseModel):
name: str = Field(default="", max_length=80)
from_contains: str | None = Field(default=None, max_length=255)
subject_contains: str | None = Field(default=None, max_length=255)
account_id: int | None = None
class MailSenderOut(MailSenderIn):
id: int
source_id: int
model_config = ConfigDict(from_attributes=True)
+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
+248
View File
@@ -0,0 +1,248 @@
"""Zahlungs-Mails per IMAP abholen und in den Eingang legen.
``imaplib`` und ``email`` sind Standardbibliothek – keine weitere Abhängigkeit.
Der Ablauf ist bewusst vorsichtig: Es wird nichts stillschweigend gebucht. Eine
Mail wird gelesen, ausgewertet und als Eintrag im Eingang abgelegt; buchen tut
der Nutzer. Erkennt die Auswertung ein Feld nicht sicher, bekommt der Eintrag
den Status ``unclear`` und wartet, statt einen geratenen Betrag zu erzeugen.
Mailtexte sind Daten, keine Anweisungen. Ausgewertet wird ausschliesslich in
feste Felder (siehe ``mail_parse``); nichts aus einer Mail steuert den Ablauf.
"""
from __future__ import annotations
import email
import email.policy
import email.utils
import imaplib
import logging
from datetime import date, datetime, timedelta, timezone
from sqlalchemy.orm import Session
from ..models import Account, InboxItem, InboxSource, InboxStatus, MailSender, MailSource, User
from . import geheimnis, mail_parse
log = logging.getLogger(__name__)
# Nur die ersten Kilobytes je Mail behalten. Der Rest ist Rechtstext und
# Fusszeilen – für die Nachvollziehbarkeit reicht der Anfang.
MAX_TEXT = 8000
# Wie weit zurück beim ersten Abruf gesucht wird.
ERSTER_ABRUF_TAGE = 14
class MailFehler(RuntimeError):
"""Verbindung oder Anmeldung hat nicht geklappt – mit lesbarer Ursache."""
def _verbinden(quelle: MailSource) -> imaplib.IMAP4:
passwort = geheimnis.entschluesseln(quelle.password_enc)
if passwort is None:
raise MailFehler(
"Das gespeicherte Passwort lässt sich nicht mehr entschlüsseln. "
"Wurde das Server-Geheimnis getauscht? Dann bitte neu eintragen."
)
try:
verbindung = (
imaplib.IMAP4_SSL(quelle.host, quelle.port)
if quelle.use_ssl
else imaplib.IMAP4(quelle.host, quelle.port)
)
except OSError as e:
raise MailFehler(f"Keine Verbindung zu {quelle.host}:{quelle.port} – {e}") from e
try:
verbindung.login(quelle.username, passwort)
except imaplib.IMAP4.error as e:
try:
verbindung.logout()
except Exception:
pass
raise MailFehler(f"Anmeldung abgelehnt: {e}") from e
return verbindung
def _ordnername(zeile: bytes) -> str:
"""Aus einer LIST-Antwort den reinen Ordnernamen holen."""
text = zeile.decode("utf-8", "replace")
if '"' in text:
return text.rsplit('"', 2)[-2]
return text.split()[-1]
def liste_ordner(quelle: MailSource) -> list[str]:
"""Ordner des Postfachs – für die Auswahl in der Oberfläche."""
verbindung = _verbinden(quelle)
try:
code, zeilen = verbindung.list()
if code != "OK":
raise MailFehler("Ordnerliste nicht abrufbar")
return sorted({_ordnername(z) for z in zeilen if z})
finally:
try:
verbindung.logout()
except Exception:
pass
def teste(quelle: MailSource) -> dict:
"""Verbindungstest mit klarer Rückmeldung statt stillem Nichtstun."""
ordner = liste_ordner(quelle)
return {"ok": True, "folders": ordner, "count": len(ordner)}
def _text_der_mail(msg) -> tuple[str, str]:
"""Betreff und sichtbarer Text. HTML wird zu Text, wenn nichts anderes da ist."""
betreff = str(msg["Subject"] or "")
teil = msg.get_body(preferencelist=("plain",))
if teil is not None:
try:
return betreff, teil.get_content()
except Exception:
pass
teil = msg.get_body(preferencelist=("html",))
if teil is not None:
try:
return betreff, mail_parse.html_zu_text(teil.get_content())
except Exception:
pass
return betreff, ""
def _passender_absender(absender: list[MailSender], von: str, betreff: str) -> MailSender | None:
"""Erste Zuordnung, deren Filter greifen. Ohne Filter passt sie auf alles."""
von_klein, betreff_klein = von.lower(), betreff.lower()
for a in absender:
if a.from_contains and a.from_contains.lower() not in von_klein:
continue
if a.subject_contains and a.subject_contains.lower() not in betreff_klein:
continue
return a
return None
def _suchkriterium(quelle: MailSource) -> str:
"""Ungelesene, sonst ab dem letzten Abruf – nie das ganze Postfach."""
if quelle.mark_seen and not quelle.done_folder:
return "(UNSEEN)"
seit = quelle.last_poll_at.date() if quelle.last_poll_at else (
date.today() - timedelta(days=ERSTER_ABRUF_TAGE)
)
return f'(SINCE "{seit.strftime("%d-%b-%Y")}")'
def hole_neue(db: Session, quelle: MailSource) -> dict:
"""Neue Mails abholen und als Einträge im Eingang ablegen."""
user = db.get(User, quelle.owner_id)
absender = (
db.query(MailSender).filter(MailSender.source_id == quelle.id).order_by(MailSender.id).all()
)
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == quelle.owner_id)}
verbindung = _verbinden(quelle)
neu = uebersprungen = unklar = 0
try:
code, _ = verbindung.select(f'"{quelle.folder}"')
if code != "OK":
raise MailFehler(f"Ordner „{quelle.folder}“ nicht gefunden")
code, daten = verbindung.search(None, _suchkriterium(quelle))
if code != "OK":
raise MailFehler("Suche im Postfach fehlgeschlagen")
for uid in (daten[0] or b"").split():
code, roh = verbindung.fetch(uid, "(RFC822)")
if code != "OK" or not roh or not isinstance(roh[0], tuple):
continue
msg = email.message_from_bytes(roh[0][1], policy=email.policy.default)
message_id = str(msg["Message-Id"] or f"uid-{uid.decode()}")
# Message-ID statt UID: Die UID vergibt der Server beim Verschieben neu.
schon = (
db.query(InboxItem.id)
.filter(InboxItem.owner_id == quelle.owner_id,
InboxItem.source == InboxSource.mail,
InboxItem.external_id == message_id)
.first()
)
if schon is not None:
uebersprungen += 1
_nachbereiten(verbindung, quelle, uid)
continue
betreff, text = _text_der_mail(msg)
von = str(msg["From"] or "")
zuordnung = _passender_absender(absender, von, betreff)
if zuordnung is None:
uebersprungen += 1
continue
gesendet = None
if msg["Date"]:
try:
gesendet = email.utils.parsedate_to_datetime(msg["Date"])
except (TypeError, ValueError):
gesendet = None
e = mail_parse.parse(betreff, text, gesendet)
konto = konten.get(zuordnung.account_id) if zuordnung.account_id else None
tag = e.datum.wert if e.datum else (gesendet.date() if gesendet else date.today())
betrag = e.betrag.wert if e.betrag else 0
ausgabe = (e.richtung.wert if e.richtung else "expense") == "expense"
status = InboxStatus.pending
if e.unklar or betrag == 0 or konto is None:
status = InboxStatus.unclear
unklar += 1
db.add(InboxItem(
owner_id=quelle.owner_id,
source=InboxSource.mail,
status=status,
account_id=konto.id if konto else None,
date=tag,
amount_cents=-betrag if ausgabe else betrag,
currency=(e.waehrung.wert if e.waehrung else (konto.currency if konto else "EUR")),
payee=(e.empfaenger.wert if e.empfaenger else None),
note=betreff[:255] or None,
external_id=message_id[:255],
raw_text=text[:MAX_TEXT],
))
neu += 1
_nachbereiten(verbindung, quelle, uid)
quelle.last_poll_at = datetime.now(timezone.utc)
quelle.last_error = None
db.commit()
finally:
try:
verbindung.logout()
except Exception:
pass
return {"neu": neu, "uebersprungen": uebersprungen, "unklar": unklar}
def _nachbereiten(verbindung: imaplib.IMAP4, quelle: MailSource, uid: bytes) -> None:
"""Als gelesen markieren oder verschieben, damit nichts zweimal kommt."""
try:
if quelle.mark_seen:
verbindung.store(uid, "+FLAGS", "\\Seen")
if quelle.done_folder:
# MOVE gibt es nicht überall; COPY + DELETE ist der sichere Weg.
code, _ = verbindung.copy(uid, f'"{quelle.done_folder}"')
if code == "OK":
verbindung.store(uid, "+FLAGS", "\\Deleted")
except Exception as e: # Nachbereitung darf den Abruf nie kippen
log.warning("Mail konnte nicht nachbereitet werden: %s", e)
def abruf_mit_fehlerbehandlung(db: Session, quelle: MailSource) -> dict:
"""Wie ``hole_neue``, schreibt einen Fehler aber in die Quelle statt zu werfen."""
try:
return hole_neue(db, quelle)
except MailFehler as e:
quelle.last_error = str(e)[:500]
quelle.last_poll_at = datetime.now(timezone.utc)
db.commit()
return {"neu": 0, "uebersprungen": 0, "unklar": 0, "fehler": str(e)}