fix: Mails vor der Einrichtung waren fuer immer ausserhalb des Suchfensters
Der Zeitstempel des letzten Abrufs wird auch dann gesetzt, wenn ein Abruf
nichts fand – und danach sucht Certain nur noch ab gestern. Wer das Postfach
einrichtet, nachdem die Mails schon da waren, findet sie nie: Der erste,
ergebnislose Abruf schiebt das Fenster an ihnen vorbei.
Jetzt gibt es "Aelteres nachholen": Es sucht die letzten 90 Tage und uebergeht
den Zeitstempel. Dass dabei nichts doppelt ankommt, sichert wie bisher die
Message-ID.
Ausserdem sagt die Meldung jetzt, AB WANN gesucht wurde. Bisher stand dort
"Stimmt der Ordner?" – die Frage war fast immer die falsche.
Und ein latenter Fehler, der im August nicht auffaellt: Das Suchdatum wurde mit
strftime("%b") gebaut, also mit dem Monatskuerzel in der Sprache des Servers.
IMAP verlangt die englischen; auf einem deutschen System haette die Suche im
Maerz, Mai, Oktober und Dezember nichts gefunden. Die Kuerzel stehen jetzt fest
im Code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,10 +79,16 @@ def test_source(source_id: int, db: Session = Depends(get_db), user: User = Depe
|
||||
|
||||
|
||||
@router.post("/sources/{source_id}/fetch")
|
||||
def fetch_source(source_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
def fetch_source(source_id: int, tage: int | None = None,
|
||||
db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Mails abholen. Mit ``tage`` ab heute minus so vielen Tagen statt ab dem
|
||||
letzten Abruf – für Mails, die schon vor der Einrichtung im Postfach lagen."""
|
||||
quelle = get_owned(db, MailSource, source_id, user)
|
||||
if tage is not None and not 1 <= tage <= 365:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Der Zeitraum muss zwischen 1 und 365 Tagen liegen.")
|
||||
try:
|
||||
return mail_import.hole_neue(db, quelle)
|
||||
return mail_import.hole_neue(db, quelle, tage)
|
||||
except mail_import.MailFehler as e:
|
||||
quelle.last_error = str(e)[:500]
|
||||
db.commit()
|
||||
|
||||
@@ -166,7 +166,19 @@ def _passender_absender(
|
||||
return None
|
||||
|
||||
|
||||
def _suchkriterium(quelle: MailSource) -> str:
|
||||
# IMAP verlangt die ENGLISCHEN Monatskürzel. ``strftime("%b")`` richtet sich
|
||||
# nach der Sprache des Servers – auf einem deutschen System käme „Mär", „Mai",
|
||||
# „Okt", „Dez" heraus, und die Suche fände nichts. Im August fällt das nicht
|
||||
# auf, weil das Kürzel dort in beiden Sprachen gleich lautet.
|
||||
MONATE = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
|
||||
|
||||
def _imap_datum(tag: date) -> str:
|
||||
return f"{tag.day:02d}-{MONATE[tag.month - 1]}-{tag.year}"
|
||||
|
||||
|
||||
def _suchkriterium(quelle: MailSource, tage: int | None = None) -> tuple[str, date]:
|
||||
"""Nach Datum suchen, nie nach „ungelesen".
|
||||
|
||||
Auf ``UNSEEN`` zu filtern klingt sparsam, ist aber eine Falle: Wer eine
|
||||
@@ -174,16 +186,22 @@ def _suchkriterium(quelle: MailSource) -> str:
|
||||
sie dauerhaft – sie ist dann für immer gelesen. Gesucht wird deshalb ab dem
|
||||
letzten Abruf, mit einem Tag Überlappung gegen Zeitzonen-Verschiebungen.
|
||||
Dass nichts doppelt ankommt, sichert die Message-ID ab.
|
||||
|
||||
``tage`` übergeht den letzten Abruf und sucht ab heute minus so vielen
|
||||
Tagen. Das ist nötig, weil der Zeitstempel auch dann gesetzt wird, wenn ein
|
||||
Abruf nichts fand: Ältere Mails wären danach für immer ausserhalb des
|
||||
Fensters, obwohl sie im Postfach liegen.
|
||||
"""
|
||||
seit = (
|
||||
quelle.last_poll_at.date() - timedelta(days=1)
|
||||
if quelle.last_poll_at
|
||||
else date.today() - timedelta(days=ERSTER_ABRUF_TAGE)
|
||||
)
|
||||
return f'(SINCE "{seit.strftime("%d-%b-%Y")}")'
|
||||
if tage is not None:
|
||||
seit = date.today() - timedelta(days=tage)
|
||||
elif quelle.last_poll_at:
|
||||
seit = quelle.last_poll_at.date() - timedelta(days=1)
|
||||
else:
|
||||
seit = date.today() - timedelta(days=ERSTER_ABRUF_TAGE)
|
||||
return f'(SINCE "{_imap_datum(seit)}")', seit
|
||||
|
||||
|
||||
def hole_neue(db: Session, quelle: MailSource) -> dict:
|
||||
def hole_neue(db: Session, quelle: MailSource, tage: int | None = None) -> dict:
|
||||
"""Neue Mails abholen und als Einträge im Eingang ablegen."""
|
||||
user = db.get(User, quelle.owner_id)
|
||||
absender = (
|
||||
@@ -200,7 +218,8 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
|
||||
code, _ = verbindung.select(f'"{quelle.folder}"')
|
||||
if code != "OK":
|
||||
raise MailFehler(f"Ordner „{quelle.folder}“ nicht gefunden")
|
||||
code, daten = verbindung.search(None, _suchkriterium(quelle))
|
||||
kriterium, seit = _suchkriterium(quelle, tage)
|
||||
code, daten = verbindung.search(None, kriterium)
|
||||
if code != "OK":
|
||||
raise MailFehler("Suche im Postfach fehlgeschlagen")
|
||||
|
||||
@@ -287,8 +306,11 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
|
||||
verbindung.logout()
|
||||
except Exception:
|
||||
pass
|
||||
# Das Suchdatum gehoert in die Antwort: Findet sich nichts, ist die Frage
|
||||
# nicht "stimmt der Ordner", sondern "wie weit wurde ueberhaupt gesucht".
|
||||
return {"gefunden": gefunden, "neu": neu, "uebersprungen": uebersprungen,
|
||||
"unklar": unklar, "ohne_zuordnung": ohne_zuordnung}
|
||||
"unklar": unklar, "seit": seit.isoformat(),
|
||||
"ohne_zuordnung": ohne_zuordnung}
|
||||
|
||||
|
||||
def _nachbereiten(verbindung: imaplib.IMAP4, quelle: MailSource, uid: bytes) -> None:
|
||||
@@ -314,4 +336,5 @@ def abruf_mit_fehlerbehandlung(db: Session, quelle: MailSource) -> dict:
|
||||
quelle.last_poll_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"gefunden": 0, "neu": 0, "uebersprungen": 0, "unklar": 0,
|
||||
"seit": None,
|
||||
"ohne_zuordnung": [], "fehler": str(e)}
|
||||
|
||||
@@ -444,3 +444,53 @@ def test_unklare_vorschlaege_werden_nicht_mitgebucht(admin, fake_imap):
|
||||
admin.post("/api/inbox/book-all")
|
||||
danach = admin.get("/api/inbox").json()
|
||||
assert all(e["status"] == "unclear" for e in danach if e["id"] == unklare[0]["id"])
|
||||
|
||||
|
||||
def test_suchdatum_ist_sprachunabhaengig():
|
||||
"""IMAP verlangt die englischen Monatskuerzel. strftime("%b") richtet sich
|
||||
nach der Sprache des Servers – auf Deutsch kaeme "Mär"/"Okt"/"Dez" heraus
|
||||
und die Suche faende nichts. Im August faellt das nicht auf."""
|
||||
from datetime import date
|
||||
|
||||
from app.services.mail_import import _imap_datum
|
||||
|
||||
assert _imap_datum(date(2026, 3, 9)) == "09-Mar-2026"
|
||||
assert _imap_datum(date(2026, 5, 1)) == "01-May-2026"
|
||||
assert _imap_datum(date(2026, 10, 31)) == "31-Oct-2026"
|
||||
assert _imap_datum(date(2026, 12, 24)) == "24-Dec-2026"
|
||||
|
||||
|
||||
def test_zeitraum_uebergeht_den_letzten_abruf(admin, monkeypatch):
|
||||
"""Der Zeitstempel wird auch gesetzt, wenn ein Abruf nichts fand. Aeltere
|
||||
Mails waeren danach fuer immer ausserhalb des Fensters – genau deshalb
|
||||
muss sich der Zeitraum uebersteuern lassen."""
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import MailSource
|
||||
from app.services import mail_import
|
||||
|
||||
konto = _konto(admin, name="Nachholkonto", currency="CHF")
|
||||
quelle = _postfach(admin)
|
||||
admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={
|
||||
"from_contains": "raiffeisen", "account_id": konto["id"]})
|
||||
|
||||
# Ein Abruf hat schon stattgefunden – ohne Treffer.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.get(MailSource, quelle["id"])
|
||||
q.last_poll_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
q = db.get(MailSource, quelle["id"])
|
||||
eng, seit_eng = mail_import._suchkriterium(q)
|
||||
weit, seit_weit = mail_import._suchkriterium(q, 90)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert seit_eng >= date.today() - timedelta(days=2), "sucht nur noch ab gestern"
|
||||
assert seit_weit == date.today() - timedelta(days=90)
|
||||
assert "SINCE" in eng and "SINCE" in weit
|
||||
|
||||
# Und der Endpunkt nimmt den Zeitraum an.
|
||||
assert admin.post(f"/api/mail/sources/{quelle['id']}/fetch",
|
||||
params={"tage": 400}).status_code == 400
|
||||
|
||||
Reference in New Issue
Block a user