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:
Guido
2026-08-27 12:59:08 +02:00
co-authored by Claude Opus 5
parent ca63fef5c4
commit 60b8178c57
7 changed files with 115 additions and 21 deletions
+8 -2
View File
@@ -79,10 +79,16 @@ def test_source(source_id: int, db: Session = Depends(get_db), user: User = Depe
@router.post("/sources/{source_id}/fetch") @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) 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: try:
return mail_import.hole_neue(db, quelle) return mail_import.hole_neue(db, quelle, tage)
except mail_import.MailFehler as e: except mail_import.MailFehler as e:
quelle.last_error = str(e)[:500] quelle.last_error = str(e)[:500]
db.commit() db.commit()
+33 -10
View File
@@ -166,7 +166,19 @@ def _passender_absender(
return None 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". """Nach Datum suchen, nie nach „ungelesen".
Auf ``UNSEEN`` zu filtern klingt sparsam, ist aber eine Falle: Wer eine 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 sie dauerhaft – sie ist dann für immer gelesen. Gesucht wird deshalb ab dem
letzten Abruf, mit einem Tag Überlappung gegen Zeitzonen-Verschiebungen. letzten Abruf, mit einem Tag Überlappung gegen Zeitzonen-Verschiebungen.
Dass nichts doppelt ankommt, sichert die Message-ID ab. 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 = ( if tage is not None:
quelle.last_poll_at.date() - timedelta(days=1) seit = date.today() - timedelta(days=tage)
if quelle.last_poll_at elif quelle.last_poll_at:
else date.today() - timedelta(days=ERSTER_ABRUF_TAGE) seit = quelle.last_poll_at.date() - timedelta(days=1)
) else:
return f'(SINCE "{seit.strftime("%d-%b-%Y")}")' 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.""" """Neue Mails abholen und als Einträge im Eingang ablegen."""
user = db.get(User, quelle.owner_id) user = db.get(User, quelle.owner_id)
absender = ( absender = (
@@ -200,7 +218,8 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
code, _ = verbindung.select(f'"{quelle.folder}"') code, _ = verbindung.select(f'"{quelle.folder}"')
if code != "OK": if code != "OK":
raise MailFehler(f"Ordner „{quelle.folder}“ nicht gefunden") 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": if code != "OK":
raise MailFehler("Suche im Postfach fehlgeschlagen") raise MailFehler("Suche im Postfach fehlgeschlagen")
@@ -287,8 +306,11 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
verbindung.logout() verbindung.logout()
except Exception: except Exception:
pass 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, 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: 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) quelle.last_poll_at = datetime.now(timezone.utc)
db.commit() db.commit()
return {"gefunden": 0, "neu": 0, "uebersprungen": 0, "unklar": 0, return {"gefunden": 0, "neu": 0, "uebersprungen": 0, "unklar": 0,
"seit": None,
"ohne_zuordnung": [], "fehler": str(e)} "ohne_zuordnung": [], "fehler": str(e)}
+50
View File
@@ -444,3 +444,53 @@ def test_unklare_vorschlaege_werden_nicht_mitgebucht(admin, fake_imap):
admin.post("/api/inbox/book-all") admin.post("/api/inbox/book-all")
danach = admin.get("/api/inbox").json() danach = admin.get("/api/inbox").json()
assert all(e["status"] == "unclear" for e in danach if e["id"] == unklare[0]["id"]) 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
+5 -1
View File
@@ -184,7 +184,11 @@ export const api = {
updateMailSource: (id, body) => request(`/mail/sources/${id}`, { method: "PUT", body }), updateMailSource: (id, body) => request(`/mail/sources/${id}`, { method: "PUT", body }),
deleteMailSource: (id) => request(`/mail/sources/${id}`, { method: "DELETE" }), deleteMailSource: (id) => request(`/mail/sources/${id}`, { method: "DELETE" }),
testMailSource: (id) => request(`/mail/sources/${id}/test`, { method: "POST" }), 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`), listMailSenders: (id) => request(`/mail/sources/${id}/senders`),
createMailSender: (id, body) => request(`/mail/sources/${id}/senders`, { method: "POST", body }), createMailSender: (id, body) => request(`/mail/sources/${id}/senders`, { method: "POST", body }),
updateMailSender: (id, body) => request(`/mail/senders/${id}`, { method: "PUT", body }), updateMailSender: (id, body) => request(`/mail/senders/${id}`, { method: "PUT", body }),
+3 -2
View File
@@ -568,11 +568,12 @@
"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.", "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.", "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", "deleteSender": "Zuordnung entfernen",
"deleteSenderConfirm": "Zuordnung „{{text}}“ entfernen? Bereits erfasste Zahlungen bleiben.", "deleteSenderConfirm": "Zuordnung „{{text}}“ entfernen? Bereits erfasste Zahlungen bleiben.",
"anyCurrency": "jede Währung" "anyCurrency": "jede Währung",
"fetchOlder": "Älteres nachholen"
}, },
"bank": { "bank": {
"title": "Bankkonten Anbindung", "title": "Bankkonten Anbindung",
+3 -2
View File
@@ -568,11 +568,12 @@
"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.", "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.", "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", "deleteSender": "Remove mapping",
"deleteSenderConfirm": "Remove mapping “{{text}}”? Payments already captured stay.", "deleteSenderConfirm": "Remove mapping “{{text}}”? Payments already captured stay.",
"anyCurrency": "any currency" "anyCurrency": "any currency",
"fetchOlder": "Catch up on older"
}, },
"bank": { "bank": {
"title": "Bank account connection", "title": "Bank account connection",
+13 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../../api"; import { api } from "../../api";
import { formatDate } from "../../format";
import { useToast } from "../../toast"; import { useToast } from "../../toast";
import { useConfirm } from "../../confirm"; import { useConfirm } from "../../confirm";
import Icon from "../../components/Icon"; import Icon from "../../components/Icon";
@@ -86,13 +87,15 @@ export default function Postfaecher() {
} }
} }
async function abrufen(quelle) { async function abrufen(quelle, tage) {
setBusy(true); setBusy(true);
try { try {
const r = await api.fetchMailSource(quelle.id); const r = await api.fetchMailSource(quelle.id, tage);
// „0 neu" allein sagt nicht, ob nichts da war oder nichts passte. // „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 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 })); : t("mailbox.fetched", { gefunden: r.gefunden, neu: r.neu, unklar: r.unklar }));
setUebergangen(r.ohne_zuordnung || []); setUebergangen(r.ohne_zuordnung || []);
laden(); laden();
@@ -174,6 +177,12 @@ export default function Postfaecher() {
<div className="mail-actions"> <div className="mail-actions">
<button className="btn sm" disabled={busy} onClick={() => testen(q)}>{t("mailbox.test")}</button> <button className="btn sm" disabled={busy} onClick={() => testen(q)}>{t("mailbox.test")}</button>
<button className="btn sm primary" disabled={busy} onClick={() => abrufen(q)}>{t("mailbox.fetchNow")}</button> <button className="btn sm primary" disabled={busy} onClick={() => abrufen(q)}>{t("mailbox.fetchNow")}</button>
{/* 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. */}
<button className="btn sm" disabled={busy} onClick={() => abrufen(q, 90)}>
{t("mailbox.fetchOlder")}
</button>
<button className="btn sm ghost" title={t("common.edit")} <button className="btn sm ghost" title={t("common.edit")}
onClick={() => { setForm({ ...LEER, ...q, password: "" }); setOrdner(null); }}> onClick={() => { setForm({ ...LEER, ...q, password: "" }); setOrdner(null); }}>
<Icon name="edit" size={15} /> <Icon name="edit" size={15} />