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:
@@ -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:
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ function SenderForm({ konten, onAdd, t }) {
|
||||
<option value="">{t("mailbox.noAccount")}</option>
|
||||
{konten.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)}
|
||||
</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()}
|
||||
onClick={() => { onAdd({ from_contains: von.trim(), account_id: konto ? Number(konto) : null }); setVon(""); }}>
|
||||
<Icon name="plus" size={14} /> {t("mailbox.addSender")}
|
||||
|
||||
Reference in New Issue
Block a user