diff --git a/backend/app/services/mail_import.py b/backend/app/services/mail_import.py index d7c9fbb..09d4ecd 100644 --- a/backend/app/services/mail_import.py +++ b/backend/app/services/mail_import.py @@ -146,11 +146,18 @@ def _passender_absender(absender: list[MailSender], von: str, betreff: str) -> M 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) + """Nach Datum suchen, nie nach „ungelesen". + + Auf ``UNSEEN`` zu filtern klingt sparsam, ist aber eine Falle: Wer eine + Mail in seinem Mailprogramm ansieht, bevor Certain sie geholt hat, verliert + 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. + """ + 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")}")' @@ -164,7 +171,7 @@ def hole_neue(db: Session, quelle: MailSource) -> dict: konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == quelle.owner_id)} verbindung = _verbinden(quelle) - neu = uebersprungen = unklar = 0 + neu = uebersprungen = unklar = gefunden = 0 # Was durchgefallen ist, mit Absender und Betreff – sonst raet man beim # Einrichten, was in den Filter gehoert. ohne_zuordnung: list[dict] = [] @@ -176,7 +183,9 @@ def hole_neue(db: Session, quelle: MailSource) -> dict: if code != "OK": raise MailFehler("Suche im Postfach fehlgeschlagen") - for uid in (daten[0] or b"").split(): + uids = (daten[0] or b"").split() + gefunden = len(uids) + for uid in uids: code, roh = verbindung.fetch(uid, "(RFC822)") if code != "OK" or not roh or not isinstance(roh[0], tuple): continue @@ -250,8 +259,8 @@ def hole_neue(db: Session, quelle: MailSource) -> dict: verbindung.logout() except Exception: pass - return {"neu": neu, "uebersprungen": uebersprungen, "unklar": unklar, - "ohne_zuordnung": ohne_zuordnung} + return {"gefunden": gefunden, "neu": neu, "uebersprungen": uebersprungen, + "unklar": unklar, "ohne_zuordnung": ohne_zuordnung} def _nachbereiten(verbindung: imaplib.IMAP4, quelle: MailSource, uid: bytes) -> None: @@ -276,5 +285,5 @@ def abruf_mit_fehlerbehandlung(db: Session, quelle: MailSource) -> dict: quelle.last_error = str(e)[:500] quelle.last_poll_at = datetime.now(timezone.utc) db.commit() - return {"neu": 0, "uebersprungen": 0, "unklar": 0, + return {"gefunden": 0, "neu": 0, "uebersprungen": 0, "unklar": 0, "ohne_zuordnung": [], "fehler": str(e)} diff --git a/backend/tests/test_mail_import.py b/backend/tests/test_mail_import.py index 3a28d08..79a9198 100644 --- a/backend/tests/test_mail_import.py +++ b/backend/tests/test_mail_import.py @@ -249,3 +249,30 @@ def test_uebergangene_mails_nennen_absender_und_betreff(admin, monkeypatch): assert ergebnis["uebersprungen"] == 1 assert "raiffeisen.ch" in ergebnis["ohne_zuordnung"][0]["from"] assert "Zahlungsstatus" in ergebnis["ohne_zuordnung"][0]["subject"] + + +def test_bereits_gelesene_mail_wird_trotzdem_geholt(admin, monkeypatch): + """Wer die Mail vorher im Mailprogramm ansieht, darf sie nicht verlieren. + + Frueher wurde auf UNSEEN gefiltert – eine einmal geoeffnete Mail war damit + fuer Certain unsichtbar, und das ohne jeden Hinweis. + """ + konto = _konto(admin) + quelle = _postfach(admin) + admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ + "from_contains": "raiffeisen.ch", "account_id": konto["id"]}) + + class NurGelesene(FakeImap): + def search(self, charset, kriterium): + # Ein Server, der auf UNSEEN nichts liefert – wie bei einer Mail, + # die man schon angesehen hat. + assert "UNSEEN" not in kriterium, f"Es darf nicht auf UNSEEN gefiltert werden: {kriterium}" + return "OK", [b"1"] + + roh = (MUSTER / "raiffeisen_zahlungsstatus.eml").read_bytes().replace( + b"