From fc6ee2f34549e8b2fb39812b47d8feba1cb5536c Mon Sep 17 00:00:00 2001 From: Guido Date: Sat, 22 Aug 2026 19:58:06 +0200 Subject: [PATCH] feat: Postfaecher einrichten und Zahlungs-Mails abholen (Etappe B, Teil 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/main.py | 34 +++ backend/app/models.py | 53 ++++ backend/app/routers/mail.py | 127 +++++++++ backend/app/schemas.py | 52 ++++ backend/app/services/geheimnis.py | 45 ++++ backend/app/services/mail_import.py | 248 ++++++++++++++++++ .../migrations/versions/0016_mail_sources.py | 64 +++++ backend/requirements.txt | 1 + backend/tests/test_mail_import.py | 215 +++++++++++++++ 9 files changed, 839 insertions(+) create mode 100644 backend/app/routers/mail.py create mode 100644 backend/app/services/geheimnis.py create mode 100644 backend/app/services/mail_import.py create mode 100644 backend/migrations/versions/0016_mail_sources.py create mode 100644 backend/tests/test_mail_import.py diff --git a/backend/app/main.py b/backend/app/main.py index 3656207..54121ae 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index eede165..f921b9c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/routers/mail.py b/backend/app/routers/mail.py new file mode 100644 index 0000000..23a1c58 --- /dev/null +++ b/backend/app/routers/mail.py @@ -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() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d9ecb72..79b885a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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) diff --git a/backend/app/services/geheimnis.py b/backend/app/services/geheimnis.py new file mode 100644 index 0000000..448f6fa --- /dev/null +++ b/backend/app/services/geheimnis.py @@ -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 diff --git a/backend/app/services/mail_import.py b/backend/app/services/mail_import.py new file mode 100644 index 0000000..ffdd332 --- /dev/null +++ b/backend/app/services/mail_import.py @@ -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)} diff --git a/backend/migrations/versions/0016_mail_sources.py b/backend/migrations/versions/0016_mail_sources.py new file mode 100644 index 0000000..00a3461 --- /dev/null +++ b/backend/migrations/versions/0016_mail_sources.py @@ -0,0 +1,64 @@ +"""Postfaecher und Absender-Zuordnung fuer den Mail-Abruf. + +Raiffeisen und Revolut melden Ueberweisungen per E-Mail. Diese Tabellen halten +fest, aus welchem Postfach abgeholt wird und welcher Absender auf welches Konto +bucht. Das Passwort liegt verschluesselt (nicht gehasht): Certain muss es dem +Mailserver vorlegen koennen. + +Revision ID: 0016 +Revises: 0015 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0016" +down_revision = "0015" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "mail_sources", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=80), nullable=False, server_default=""), + sa.Column("host", sa.String(length=255), nullable=False), + sa.Column("port", sa.Integer(), nullable=False, server_default="993"), + sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("username", sa.String(length=255), nullable=False), + sa.Column("password_enc", sa.Text(), nullable=False), + sa.Column("folder", sa.String(length=255), nullable=False, server_default="INBOX"), + sa.Column("done_folder", sa.String(length=255), nullable=True), + sa.Column("mark_seen", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("poll_minutes", sa.Integer(), nullable=False, server_default="15"), + sa.Column("last_poll_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"), + ) + op.create_index("ix_mail_sources_owner_id", "mail_sources", ["owner_id"]) + + op.create_table( + "mail_senders", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("source_id", sa.Integer(), nullable=False), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=80), nullable=False, server_default=""), + sa.Column("from_contains", sa.String(length=255), nullable=True), + sa.Column("subject_contains", sa.String(length=255), nullable=True), + sa.Column("account_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["source_id"], ["mail_sources.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="SET NULL"), + ) + op.create_index("ix_mail_senders_source_id", "mail_senders", ["source_id"]) + op.create_index("ix_mail_senders_owner_id", "mail_senders", ["owner_id"]) + + +def downgrade() -> None: + op.drop_table("mail_senders") + op.drop_table("mail_sources") diff --git a/backend/requirements.txt b/backend/requirements.txt index 7f804ff..d74947b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,3 +18,4 @@ pytesseract==0.3.13 pillow==11.1.0 pytest==8.3.4 +cryptography==50.0.0 diff --git a/backend/tests/test_mail_import.py b/backend/tests/test_mail_import.py new file mode 100644 index 0000000..dcd4169 --- /dev/null +++ b/backend/tests/test_mail_import.py @@ -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]