From 615be0f0f51b3cb6b52cb8bc1cc8862c5e16b33b Mon Sep 17 00:00:00 2001 From: Guido Date: Sat, 22 Aug 2026 20:27:54 +0200 Subject: [PATCH] fix: Erkennungstext greift auch in Betreff und Text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei einer von Hand weitergeleiteten Bank-Mail steht die eigene Adresse im From; "Raiffeisen" findet sich nur noch im Betreff ("WG: Raiffeisen …") und im zitierten Kopf. Das Feld hiess "Absender enthaelt" und prueft nur die Absender-Koepfe – die Mail wurde also uebergangen, obwohl das Suchwort sichtbar darin stand. Eine Falle, kein Bedienfehler. Gesucht wird jetzt in Absender-Koepfen, Betreff und den ersten 2000 Zeichen des Textes; die Beschriftung heisst entsprechend "Erkennungstext". Ein Test mit einer nachgebauten Weiterleitung haelt fest, dass sowohl die Zuordnung greift als auch Betrag und Empfaenger daraus lesbar bleiben. Co-Authored-By: Claude Opus 5 --- backend/app/services/mail_import.py | 26 ++++++++++++--- backend/tests/test_mail_import.py | 45 ++++++++++++++++++++++++++ web/src/locales/de.json | 5 +-- web/src/locales/en.json | 5 +-- web/src/pages/settings/Postfaecher.jsx | 1 + 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/backend/app/services/mail_import.py b/backend/app/services/mail_import.py index 09d4ecd..288a2fa 100644 --- a/backend/app/services/mail_import.py +++ b/backend/app/services/mail_import.py @@ -133,11 +133,27 @@ def _absenderfeld(msg) -> str: return " ".join(t for t in teile if t) -def _passender_absender(absender: list[MailSender], von: str, betreff: str) -> MailSender | None: - """Erste Zuordnung, deren Filter greifen. Ohne Filter passt sie auf alles.""" - von_klein, betreff_klein = von.lower(), betreff.lower() +# Wie weit in den Text hinein gesucht wird. Bei einer weitergeleiteten Mail +# steht der Originalabsender im zitierten Kopf ganz oben. +ERKENNUNG_TEXTLAENGE = 2000 + + +def _passender_absender( + absender: list[MailSender], von: str, betreff: str, text: str = "" +) -> MailSender | None: + """Erste Zuordnung, deren Erkennungstext greift. + + Gesucht wird in Absender-Köpfen, Betreff UND dem Anfang des Textes. Wer + eine Bank-Mail von Hand weiterleitet, hat im ``From`` die eigene Adresse; + „Raiffeisen“ steht dann nur noch im Betreff („WG: Raiffeisen …“) und im + zitierten Kopf. Ein Feld, das nach dem Absender fragt und Betreff wie Text + ignoriert, würde genau dort versagen – und der Nutzer säße vor einer Mail, + in der sein Suchwort sichtbar drinsteht. + """ + heu = f"{von}\n{betreff}\n{text[:ERKENNUNG_TEXTLAENGE]}".lower() + betreff_klein = betreff.lower() for a in absender: - if a.from_contains and a.from_contains.lower() not in von_klein: + if a.from_contains and a.from_contains.lower() not in heu: continue if a.subject_contains and a.subject_contains.lower() not in betreff_klein: continue @@ -207,7 +223,7 @@ def hole_neue(db: Session, quelle: MailSource) -> dict: betreff, text = _text_der_mail(msg) von = _absenderfeld(msg) - zuordnung = _passender_absender(absender, von, betreff) + zuordnung = _passender_absender(absender, von, betreff, text) if zuordnung is None: uebersprungen += 1 if len(ohne_zuordnung) < 5: diff --git a/backend/tests/test_mail_import.py b/backend/tests/test_mail_import.py index 79a9198..6c4ccd0 100644 --- a/backend/tests/test_mail_import.py +++ b/backend/tests/test_mail_import.py @@ -276,3 +276,48 @@ def test_bereits_gelesene_mail_wird_trotzdem_geholt(admin, monkeypatch): ergebnis = admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json() assert ergebnis["gefunden"] == 1 assert ergebnis["neu"] == 1 + + +def _weitergeleitet(quelldatei: str, message_id: bytes) -> bytes: + """Baut aus einer Bank-Mail eine von Hand weitergeleitete nach. + + Dabei steht die EIGENE Adresse im From, der Betreff bekommt ein „WG:“ und + der Originalkopf wandert in den Text – genau wie bei Gmail und Proton. + """ + roh = (MUSTER / quelldatei).read_bytes().decode("utf-8") + kopf, koerper = roh.split("\n\n", 1) + zitat = ("---------- Weitergeleitete Nachricht ----------\n" + "Von: noreply@ebanking.raiffeisen.ch\n" + "Betreff: Raiffeisen E-Banking - Änderung Zahlungsstatus\n\n") + neu = ( + "From: Alex Muster \n" + "To: sammel@example.com\n" + "Date: Fri, 31 Jul 2026 08:00:00 +0200\n" + f"Message-Id: <{message_id.decode()}@example.invalid>\n" + "Subject: =?utf-8?Q?WG=3A_Raiffeisen_E-Banking?=\n" + "Mime-Version: 1.0\n" + "Content-Type: text/plain; charset=utf-8\n" + "\n" + zitat + koerper.replace("=\n", "").replace("=C3=84", "Ä") + .replace("=C3=BC", "ü").replace("=C3=A4", "ä").replace("=C3=BC", "ü") + .replace("=C3=BCber", "über").replace("Beg=C3=BCnstigter", "Begünstigter") + .replace("Ausf=C3=BChrungsdatum", "Ausführungsdatum") + ) + return neu.encode("utf-8") + + +def test_von_hand_weitergeleitete_mail_wird_am_betreff_erkannt(admin, monkeypatch): + """Der Nutzer trägt „raiffeisen“ ein – das steht bei einer manuellen + Weiterleitung im Betreff, nicht im Absender. Genau dort muss es greifen.""" + konto = _konto(admin) + quelle = _postfach(admin) + admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ + "from_contains": "raiffeisen", "account_id": konto["id"]}) + + monkeypatch.setattr(mail_import, "_verbinden", + lambda q: FakeImap([_weitergeleitet("raiffeisen_zahlungsstatus.eml", b"wg-1")])) + ergebnis = admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json() + assert ergebnis["neu"] == 1, ergebnis + + treffer = [e for e in admin.get("/api/inbox").json() if e["payee"] == "Sunrise GmbH / Yallo"] + assert treffer, "Die Werte müssen auch aus der weitergeleiteten Mail lesbar sein" + assert treffer[0]["amount_cents"] == -1490 diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 18e0038..6be29ff 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -522,7 +522,7 @@ "deleteConfirm": "Postfach „{{name}}“ entfernen? Bereits erfasste Zahlungen bleiben.", "noSenders": "Noch kein Absender zugeordnet – ohne Zuordnung wird nichts übernommen.", "noAccount": "kein Konto", - "fromPlaceholder": "Absender enthält, z. B. raiffeisen.ch", + "fromPlaceholder": "Erkennungstext, z. B. raiffeisen", "security": "Verschlüsselung", "secSsl": "SSL/TLS (üblich, Port 993)", "secStarttls": "STARTTLS (Port 143)", @@ -532,6 +532,7 @@ "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 im gewählten Ordner gefunden. Stimmt der Ordner? Beim ersten Abruf werden die letzten 14 Tage durchsucht.", + "senderHint": "Gesucht wird in Absender, Betreff und im Anfang des Textes. Damit greift es auch bei weitergeleiteten Mails, wo im Absender deine eigene Adresse steht." } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 43737a3..cf2d7b8 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -522,7 +522,7 @@ "deleteConfirm": "Remove mailbox “{{name}}”? Payments already captured stay.", "noSenders": "No sender mapped yet – without a mapping nothing is taken over.", "noAccount": "no account", - "fromPlaceholder": "Sender contains, e.g. raiffeisen.ch", + "fromPlaceholder": "Match text, e.g. raiffeisen", "security": "Encryption", "secSsl": "SSL/TLS (usual, port 993)", "secStarttls": "STARTTLS (port 143)", @@ -532,6 +532,7 @@ "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 emails found in the selected folder. Is the folder right? The first fetch looks at the last 14 days.", + "senderHint": "Searched in sender, subject and the start of the body. That way it also works for forwarded emails, where the sender is your own address." } } diff --git a/web/src/pages/settings/Postfaecher.jsx b/web/src/pages/settings/Postfaecher.jsx index 21a226b..39e4da4 100644 --- a/web/src/pages/settings/Postfaecher.jsx +++ b/web/src/pages/settings/Postfaecher.jsx @@ -269,6 +269,7 @@ function SenderForm({ konten, onAdd, t }) { {konten.map((k) => )} +

{t("mailbox.senderHint")}