From 60b8178c57f1488630302f0be1b366f5d2e77b7d Mon Sep 17 00:00:00 2001 From: Guido Date: Thu, 27 Aug 2026 12:59:08 +0200 Subject: [PATCH] fix: Mails vor der Einrichtung waren fuer immer ausserhalb des Suchfensters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/routers/mail.py | 10 ++++-- backend/app/services/mail_import.py | 43 ++++++++++++++++------ backend/tests/test_mail_import.py | 50 ++++++++++++++++++++++++++ web/src/api.js | 6 +++- web/src/locales/de.json | 5 +-- web/src/locales/en.json | 5 +-- web/src/pages/settings/Postfaecher.jsx | 17 ++++++--- 7 files changed, 115 insertions(+), 21 deletions(-) diff --git a/backend/app/routers/mail.py b/backend/app/routers/mail.py index e6d5d26..5485981 100644 --- a/backend/app/routers/mail.py +++ b/backend/app/routers/mail.py @@ -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() diff --git a/backend/app/services/mail_import.py b/backend/app/services/mail_import.py index c64dda5..268d370 100644 --- a/backend/app/services/mail_import.py +++ b/backend/app/services/mail_import.py @@ -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)} diff --git a/backend/tests/test_mail_import.py b/backend/tests/test_mail_import.py index a301818..efa81fc 100644 --- a/backend/tests/test_mail_import.py +++ b/backend/tests/test_mail_import.py @@ -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 diff --git a/web/src/api.js b/web/src/api.js index 8a981d2..4b13883 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -184,7 +184,11 @@ export const api = { updateMailSource: (id, body) => request(`/mail/sources/${id}`, { method: "PUT", body }), deleteMailSource: (id) => request(`/mail/sources/${id}`, { method: "DELETE" }), testMailSource: (id) => request(`/mail/sources/${id}/test`, { method: "POST" }), - fetchMailSource: (id) => request(`/mail/sources/${id}/fetch`, { method: "POST" }), + // `tage` uebergeht den letzten Abruf – fuer Mails, die schon vor der + // Einrichtung im Postfach lagen und sonst ausserhalb des Fensters blieben. + fetchMailSource: (id, tage) => + request(`/mail/sources/${id}/fetch${tage ? `?tage=${tage}` : ""}`, + { method: "POST" }), listMailSenders: (id) => request(`/mail/sources/${id}/senders`), createMailSender: (id, body) => request(`/mail/sources/${id}/senders`, { method: "POST", body }), updateMailSender: (id, body) => request(`/mail/senders/${id}`, { method: "PUT", body }), diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 875de1d..0c88780 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -568,11 +568,12 @@ "edit": "Postfach bearbeiten", "skippedTitle": "Diese Mails wurden übergangen", "skippedHint": "Für sie passt keine Absender-Zuordnung. Trag einen Teil der Adresse oder des Betreffs unten ein, dann werden sie beim nächsten Abruf übernommen.", - "fetchedNone": "Keine Mails im gewählten Ordner gefunden. Stimmt der Ordner? Beim ersten Abruf werden die letzten 14 Tage durchsucht.", + "fetchedNone": "Keine Mails seit {{seit}} gefunden. Liegen die Mails länger zurück, nimm „Älteres nachholen“ – oder prüfe den Ordner.", "senderHint": "Gesucht wird in Absender, Betreff und im Anfang des Textes – das greift auch bei weitergeleiteten Mails. Die Währung ist optional: Revolut schickt für Euro und Franken dieselbe Mail, erst sie sagt, welches Konto gemeint ist. Zuordnungen mit Währung gehen den allgemeinen vor.", "deleteSender": "Zuordnung entfernen", "deleteSenderConfirm": "Zuordnung „{{text}}“ entfernen? Bereits erfasste Zahlungen bleiben.", - "anyCurrency": "jede Währung" + "anyCurrency": "jede Währung", + "fetchOlder": "Älteres nachholen" }, "bank": { "title": "Bankkonten Anbindung", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index a69858a..249231b 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -568,11 +568,12 @@ "edit": "Edit mailbox", "skippedTitle": "These emails were skipped", "skippedHint": "No sender mapping matched them. Enter part of the address or the subject below and they will be picked up on the next fetch.", - "fetchedNone": "No emails found in the selected folder. Is the folder right? The first fetch looks at the last 14 days.", + "fetchedNone": "No mail found since {{seit}}. If the mails are older, use “Catch up on older” – or check the folder.", "senderHint": "Searched in sender, subject and the start of the body – that also works for forwarded emails. The currency is optional: Revolut sends the same email for euros and francs, and only it tells which account is meant. Mappings with a currency take precedence over general ones.", "deleteSender": "Remove mapping", "deleteSenderConfirm": "Remove mapping “{{text}}”? Payments already captured stay.", - "anyCurrency": "any currency" + "anyCurrency": "any currency", + "fetchOlder": "Catch up on older" }, "bank": { "title": "Bank account connection", diff --git a/web/src/pages/settings/Postfaecher.jsx b/web/src/pages/settings/Postfaecher.jsx index 1ac90dc..8b0e204 100644 --- a/web/src/pages/settings/Postfaecher.jsx +++ b/web/src/pages/settings/Postfaecher.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../../api"; +import { formatDate } from "../../format"; import { useToast } from "../../toast"; import { useConfirm } from "../../confirm"; import Icon from "../../components/Icon"; @@ -86,13 +87,15 @@ export default function Postfaecher() { } } - async function abrufen(quelle) { + async function abrufen(quelle, tage) { setBusy(true); try { - const r = await api.fetchMailSource(quelle.id); - // „0 neu" allein sagt nicht, ob nichts da war oder nichts passte. + const r = await api.fetchMailSource(quelle.id, tage); + // „0 neu" allein sagt nicht, ob nichts da war oder nichts passte. Und + // wenn nichts da war, ist die Frage nicht „stimmt der Ordner", sondern + // „wie weit wurde überhaupt gesucht" – deshalb steht das Datum dabei. toast(r.gefunden === 0 - ? t("mailbox.fetchedNone") + ? t("mailbox.fetchedNone", { seit: r.seit ? formatDate(r.seit) : "?" }) : t("mailbox.fetched", { gefunden: r.gefunden, neu: r.neu, unklar: r.unklar })); setUebergangen(r.ohne_zuordnung || []); laden(); @@ -174,6 +177,12 @@ export default function Postfaecher() {
+ {/* Der Zeitstempel wird auch gesetzt, wenn ein Abruf nichts fand. + Ohne diesen Weg blieben ältere Mails für immer ausserhalb des + Fensters, obwohl sie im Postfach liegen. */} +