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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
@@ -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")
|
||||
@@ -18,3 +18,4 @@ pytesseract==0.3.13
|
||||
pillow==11.1.0
|
||||
pytest==8.3.4
|
||||
|
||||
cryptography==50.0.0
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user