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.
|
Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -19,6 +20,8 @@ from .config import get_settings
|
|||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .deps import enforce_account_state
|
from .deps import enforce_account_state
|
||||||
from .migrate import upgrade_to_head
|
from .migrate import upgrade_to_head
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
from .routers import (
|
from .routers import (
|
||||||
accounts,
|
accounts,
|
||||||
auth,
|
auth,
|
||||||
@@ -30,6 +33,7 @@ from .routers import (
|
|||||||
imports,
|
imports,
|
||||||
policy,
|
policy,
|
||||||
inbox,
|
inbox,
|
||||||
|
mail,
|
||||||
quick,
|
quick,
|
||||||
rates,
|
rates,
|
||||||
settings as settings_router,
|
settings as settings_router,
|
||||||
@@ -68,7 +72,36 @@ async def lifespan(app: FastAPI):
|
|||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
threading.Thread(target=_laden, name="ecb-rates", daemon=True).start()
|
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
|
yield
|
||||||
|
stopp.set()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan)
|
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(rates.router)
|
||||||
api.include_router(quick.router)
|
api.include_router(quick.router)
|
||||||
api.include_router(inbox.router)
|
api.include_router(inbox.router)
|
||||||
|
api.include_router(mail.router)
|
||||||
|
|
||||||
app.include_router(api)
|
app.include_router(api)
|
||||||
|
|
||||||
|
|||||||
@@ -684,3 +684,56 @@ class InboxItem(Base):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("owner_id", "source", "external_id", name="uq_inbox_owner_source_external"),
|
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
|
ok: bool = True
|
||||||
message: str
|
message: str
|
||||||
item: InboxItemOut
|
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
|
pillow==11.1.0
|
||||||
pytest==8.3.4
|
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