fix: Abruf sucht nach Datum statt 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 fuer immer gelesen. Genau das ist passiert, als die Mails vor dem ersten Abruf schon in Gmail geoeffnet waren: Ergebnis "0 neu", ohne jeden Hinweis auf die Ursache. Gesucht wird jetzt ab dem letzten Abruf mit einem Tag Ueberlappung; dass nichts doppelt ankommt, sichert die Message-ID ab. Die Meldung nennt ausserdem, wie viele Mails ueberhaupt geprueft wurden. "0 neu" allein liess offen, ob nichts da war oder nichts passte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -146,11 +146,18 @@ def _passender_absender(absender: list[MailSender], von: str, betreff: str) -> M
|
|||||||
|
|
||||||
|
|
||||||
def _suchkriterium(quelle: MailSource) -> str:
|
def _suchkriterium(quelle: MailSource) -> str:
|
||||||
"""Ungelesene, sonst ab dem letzten Abruf – nie das ganze Postfach."""
|
"""Nach Datum suchen, nie nach „ungelesen".
|
||||||
if quelle.mark_seen and not quelle.done_folder:
|
|
||||||
return "(UNSEEN)"
|
Auf ``UNSEEN`` zu filtern klingt sparsam, ist aber eine Falle: Wer eine
|
||||||
seit = quelle.last_poll_at.date() if quelle.last_poll_at else (
|
Mail in seinem Mailprogramm ansieht, bevor Certain sie geholt hat, verliert
|
||||||
date.today() - timedelta(days=ERSTER_ABRUF_TAGE)
|
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")}")'
|
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)}
|
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == quelle.owner_id)}
|
||||||
|
|
||||||
verbindung = _verbinden(quelle)
|
verbindung = _verbinden(quelle)
|
||||||
neu = uebersprungen = unklar = 0
|
neu = uebersprungen = unklar = gefunden = 0
|
||||||
# Was durchgefallen ist, mit Absender und Betreff – sonst raet man beim
|
# Was durchgefallen ist, mit Absender und Betreff – sonst raet man beim
|
||||||
# Einrichten, was in den Filter gehoert.
|
# Einrichten, was in den Filter gehoert.
|
||||||
ohne_zuordnung: list[dict] = []
|
ohne_zuordnung: list[dict] = []
|
||||||
@@ -176,7 +183,9 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
|
|||||||
if code != "OK":
|
if code != "OK":
|
||||||
raise MailFehler("Suche im Postfach fehlgeschlagen")
|
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)")
|
code, roh = verbindung.fetch(uid, "(RFC822)")
|
||||||
if code != "OK" or not roh or not isinstance(roh[0], tuple):
|
if code != "OK" or not roh or not isinstance(roh[0], tuple):
|
||||||
continue
|
continue
|
||||||
@@ -250,8 +259,8 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
|
|||||||
verbindung.logout()
|
verbindung.logout()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"neu": neu, "uebersprungen": uebersprungen, "unklar": unklar,
|
return {"gefunden": gefunden, "neu": neu, "uebersprungen": uebersprungen,
|
||||||
"ohne_zuordnung": ohne_zuordnung}
|
"unklar": unklar, "ohne_zuordnung": ohne_zuordnung}
|
||||||
|
|
||||||
|
|
||||||
def _nachbereiten(verbindung: imaplib.IMAP4, quelle: MailSource, uid: bytes) -> None:
|
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_error = str(e)[:500]
|
||||||
quelle.last_poll_at = datetime.now(timezone.utc)
|
quelle.last_poll_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"neu": 0, "uebersprungen": 0, "unklar": 0,
|
return {"gefunden": 0, "neu": 0, "uebersprungen": 0, "unklar": 0,
|
||||||
"ohne_zuordnung": [], "fehler": str(e)}
|
"ohne_zuordnung": [], "fehler": str(e)}
|
||||||
|
|||||||
@@ -249,3 +249,30 @@ def test_uebergangene_mails_nennen_absender_und_betreff(admin, monkeypatch):
|
|||||||
assert ergebnis["uebersprungen"] == 1
|
assert ergebnis["uebersprungen"] == 1
|
||||||
assert "raiffeisen.ch" in ergebnis["ohne_zuordnung"][0]["from"]
|
assert "raiffeisen.ch" in ergebnis["ohne_zuordnung"][0]["from"]
|
||||||
assert "Zahlungsstatus" in ergebnis["ohne_zuordnung"][0]["subject"]
|
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"<raiffeisen-muster-1@", b"<schon-gelesen-1@")
|
||||||
|
monkeypatch.setattr(mail_import, "_verbinden", lambda q: NurGelesene([roh]))
|
||||||
|
|
||||||
|
ergebnis = admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json()
|
||||||
|
assert ergebnis["gefunden"] == 1
|
||||||
|
assert ergebnis["neu"] == 1
|
||||||
|
|||||||
@@ -514,7 +514,7 @@
|
|||||||
"test": "Verbindung testen",
|
"test": "Verbindung testen",
|
||||||
"testOk": "Verbindung steht – {{n}} Ordner gefunden.",
|
"testOk": "Verbindung steht – {{n}} Ordner gefunden.",
|
||||||
"fetchNow": "Jetzt abrufen",
|
"fetchNow": "Jetzt abrufen",
|
||||||
"fetched": "{{neu}} neu, davon {{unklar}} unklar.",
|
"fetched": "{{gefunden}} Mails geprüft · {{neu}} neu · {{unklar}} unklar.",
|
||||||
"lastPoll": "zuletzt abgerufen",
|
"lastPoll": "zuletzt abgerufen",
|
||||||
"neverPolled": "noch nie abgerufen",
|
"neverPolled": "noch nie abgerufen",
|
||||||
"lastError": "Fehler",
|
"lastError": "Fehler",
|
||||||
@@ -531,6 +531,7 @@
|
|||||||
"addSender": "Absender zuordnen",
|
"addSender": "Absender zuordnen",
|
||||||
"edit": "Postfach bearbeiten",
|
"edit": "Postfach bearbeiten",
|
||||||
"skippedTitle": "Diese Mails wurden übergangen",
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,7 +514,7 @@
|
|||||||
"test": "Test connection",
|
"test": "Test connection",
|
||||||
"testOk": "Connection works – {{n}} folders found.",
|
"testOk": "Connection works – {{n}} folders found.",
|
||||||
"fetchNow": "Fetch now",
|
"fetchNow": "Fetch now",
|
||||||
"fetched": "{{neu}} new, {{unklar}} unclear.",
|
"fetched": "{{gefunden}} emails checked · {{neu}} new · {{unklar}} unclear.",
|
||||||
"lastPoll": "last fetched",
|
"lastPoll": "last fetched",
|
||||||
"neverPolled": "never fetched",
|
"neverPolled": "never fetched",
|
||||||
"lastError": "Error",
|
"lastError": "Error",
|
||||||
@@ -531,6 +531,7 @@
|
|||||||
"addSender": "Map sender",
|
"addSender": "Map sender",
|
||||||
"edit": "Edit mailbox",
|
"edit": "Edit mailbox",
|
||||||
"skippedTitle": "These emails were skipped",
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ export default function Postfaecher() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await api.fetchMailSource(quelle.id);
|
const r = await api.fetchMailSource(quelle.id);
|
||||||
toast(t("mailbox.fetched", { neu: r.neu, unklar: r.unklar }));
|
// „0 neu" allein sagt nicht, ob nichts da war oder nichts passte.
|
||||||
|
toast(r.gefunden === 0
|
||||||
|
? t("mailbox.fetchedNone")
|
||||||
|
: t("mailbox.fetched", { gefunden: r.gefunden, neu: r.neu, unklar: r.unklar }));
|
||||||
setUebergangen(r.ohne_zuordnung || []);
|
setUebergangen(r.ohne_zuordnung || []);
|
||||||
laden();
|
laden();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user