fix: Erkennungstext greift auch in Betreff und Text

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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 20:27:54 +02:00
co-authored by Claude Opus 5
parent bfb6fa6b6d
commit 615be0f0f5
5 changed files with 73 additions and 9 deletions
+21 -5
View File
@@ -133,11 +133,27 @@ def _absenderfeld(msg) -> str:
return " ".join(t for t in teile if t) return " ".join(t for t in teile if t)
def _passender_absender(absender: list[MailSender], von: str, betreff: str) -> MailSender | None: # Wie weit in den Text hinein gesucht wird. Bei einer weitergeleiteten Mail
"""Erste Zuordnung, deren Filter greifen. Ohne Filter passt sie auf alles.""" # steht der Originalabsender im zitierten Kopf ganz oben.
von_klein, betreff_klein = von.lower(), betreff.lower() 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: 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 continue
if a.subject_contains and a.subject_contains.lower() not in betreff_klein: if a.subject_contains and a.subject_contains.lower() not in betreff_klein:
continue continue
@@ -207,7 +223,7 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
betreff, text = _text_der_mail(msg) betreff, text = _text_der_mail(msg)
von = _absenderfeld(msg) von = _absenderfeld(msg)
zuordnung = _passender_absender(absender, von, betreff) zuordnung = _passender_absender(absender, von, betreff, text)
if zuordnung is None: if zuordnung is None:
uebersprungen += 1 uebersprungen += 1
if len(ohne_zuordnung) < 5: if len(ohne_zuordnung) < 5:
+45
View File
@@ -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() ergebnis = admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json()
assert ergebnis["gefunden"] == 1 assert ergebnis["gefunden"] == 1
assert ergebnis["neu"] == 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 <alex@example.com>\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
+3 -2
View File
@@ -522,7 +522,7 @@
"deleteConfirm": "Postfach „{{name}}“ entfernen? Bereits erfasste Zahlungen bleiben.", "deleteConfirm": "Postfach „{{name}}“ entfernen? Bereits erfasste Zahlungen bleiben.",
"noSenders": "Noch kein Absender zugeordnet – ohne Zuordnung wird nichts übernommen.", "noSenders": "Noch kein Absender zugeordnet – ohne Zuordnung wird nichts übernommen.",
"noAccount": "kein Konto", "noAccount": "kein Konto",
"fromPlaceholder": "Absender enthält, z. B. raiffeisen.ch", "fromPlaceholder": "Erkennungstext, z. B. raiffeisen",
"security": "Verschlüsselung", "security": "Verschlüsselung",
"secSsl": "SSL/TLS (üblich, Port 993)", "secSsl": "SSL/TLS (üblich, Port 993)",
"secStarttls": "STARTTLS (Port 143)", "secStarttls": "STARTTLS (Port 143)",
@@ -532,6 +532,7 @@
"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 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."
} }
} }
+3 -2
View File
@@ -522,7 +522,7 @@
"deleteConfirm": "Remove mailbox “{{name}}”? Payments already captured stay.", "deleteConfirm": "Remove mailbox “{{name}}”? Payments already captured stay.",
"noSenders": "No sender mapped yet – without a mapping nothing is taken over.", "noSenders": "No sender mapped yet – without a mapping nothing is taken over.",
"noAccount": "no account", "noAccount": "no account",
"fromPlaceholder": "Sender contains, e.g. raiffeisen.ch", "fromPlaceholder": "Match text, e.g. raiffeisen",
"security": "Encryption", "security": "Encryption",
"secSsl": "SSL/TLS (usual, port 993)", "secSsl": "SSL/TLS (usual, port 993)",
"secStarttls": "STARTTLS (port 143)", "secStarttls": "STARTTLS (port 143)",
@@ -532,6 +532,7 @@
"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 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."
} }
} }
+1
View File
@@ -269,6 +269,7 @@ function SenderForm({ konten, onAdd, t }) {
<option value="">{t("mailbox.noAccount")}</option> <option value="">{t("mailbox.noAccount")}</option>
{konten.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)} {konten.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)}
</select> </select>
<p className="muted small" style={{ margin: "4px 0 0" }}>{t("mailbox.senderHint")}</p>
<button type="button" className="btn sm" style={{ marginTop: 4 }} disabled={!von.trim()} <button type="button" className="btn sm" style={{ marginTop: 4 }} disabled={!von.trim()}
onClick={() => { onAdd({ from_contains: von.trim(), account_id: konto ? Number(konto) : null }); setVon(""); }}> onClick={() => { onAdd({ from_contains: von.trim(), account_id: konto ? Number(konto) : null }); setVon(""); }}>
<Icon name="plus" size={14} /> {t("mailbox.addSender")} <Icon name="plus" size={14} /> {t("mailbox.addSender")}