feat: Weitergeleitete Bank-Mails erkennen und Uebergangenes zeigen

Wer Bank-Mails in ein Sammelpostfach weiterleitet, hat im From je nach
Methode die eigene Adresse stehen. Geprueft werden deshalb auch Reply-To,
Return-Path, Sender und X-Forwarded-For – bei automatischer Weiterleitung
bleibt der Originalabsender dort erhalten.

Ausserdem meldet der Abruf jetzt, welche Mails er uebergangen hat, mit
Absender und Betreff. Beim Einrichten war das sonst Raten: Man sah nur
"3 uebersprungen" und wusste nicht, was in den Filter gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 20:23:35 +02:00
co-authored by Claude Opus 5
parent 31f550901a
commit f3a08f2937
5 changed files with 84 additions and 5 deletions
+27 -3
View File
@@ -119,6 +119,20 @@ def _text_der_mail(msg) -> tuple[str, str]:
return betreff, ""
def _absenderfeld(msg) -> str:
"""Alles, was den ursprünglichen Absender verraten kann.
Leitet ein Postfach automatisch weiter, bleibt ``From`` meist der
Originalabsender – verlassen kann man sich darauf aber nicht. Gmail und
andere setzen zusätzlich ``X-Forwarded-For``; bei manueller Weiterleitung
steht die eigene Adresse im ``From`` und die ursprüngliche höchstens im
``Reply-To``. Geprüft wird deshalb alles zusammen.
"""
teile = [str(msg[k] or "") for k in
("From", "Reply-To", "Return-Path", "X-Forwarded-For", "X-Original-From", "Sender")]
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()
@@ -151,6 +165,9 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
verbindung = _verbinden(quelle)
neu = uebersprungen = unklar = 0
# Was durchgefallen ist, mit Absender und Betreff – sonst raet man beim
# Einrichten, was in den Filter gehoert.
ohne_zuordnung: list[dict] = []
try:
code, _ = verbindung.select(f'"{quelle.folder}"')
if code != "OK":
@@ -180,10 +197,15 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
continue
betreff, text = _text_der_mail(msg)
von = str(msg["From"] or "")
von = _absenderfeld(msg)
zuordnung = _passender_absender(absender, von, betreff)
if zuordnung is None:
uebersprungen += 1
if len(ohne_zuordnung) < 5:
ohne_zuordnung.append({
"from": str(msg["From"] or "")[:120],
"subject": betreff[:120],
})
continue
gesendet = None
@@ -228,7 +250,8 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
verbindung.logout()
except Exception:
pass
return {"neu": neu, "uebersprungen": uebersprungen, "unklar": unklar}
return {"neu": neu, "uebersprungen": uebersprungen, "unklar": unklar,
"ohne_zuordnung": ohne_zuordnung}
def _nachbereiten(verbindung: imaplib.IMAP4, quelle: MailSource, uid: bytes) -> None:
@@ -253,4 +276,5 @@ def abruf_mit_fehlerbehandlung(db: Session, quelle: MailSource) -> dict:
quelle.last_error = str(e)[:500]
quelle.last_poll_at = datetime.now(timezone.utc)
db.commit()
return {"neu": 0, "uebersprungen": 0, "unklar": 0, "fehler": str(e)}
return {"neu": 0, "uebersprungen": 0, "unklar": 0,
"ohne_zuordnung": [], "fehler": str(e)}
+36
View File
@@ -213,3 +213,39 @@ def test_verschieben_statt_nur_markieren(admin, fake_imap):
admin.post(f"/api/mail/sources/{quelle['id']}/fetch")
assert server.kopiert and "Belege" in server.kopiert[0][1]
def test_weitergeleitete_mail_wird_am_originalabsender_erkannt(admin, fake_imap, monkeypatch):
"""Leitet man Bank-Mails in ein Sammelpostfach weiter, steht die eigene
Adresse im ``From``. Der Originalabsender bleibt aber in den
Weiterleitungs-Koepfen erhalten – daran muss die Zuordnung greifen."""
konto = _konto(admin)
quelle = _postfach(admin)
admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={
"from_contains": "raiffeisen.ch", "account_id": konto["id"]})
roh = (MUSTER / "raiffeisen_zahlungsstatus.eml").read_bytes()
weitergeleitet = roh.replace(
b"From: noreply@ebanking.raiffeisen.ch",
b"From: ich@gmail.com\nX-Forwarded-For: noreply@ebanking.raiffeisen.ch ich@gmail.com",
).replace(b"<raiffeisen-muster-1@", b"<weitergeleitet-1@")
server = FakeImap([weitergeleitet])
monkeypatch.setattr(mail_import, "_verbinden", lambda q: server)
assert admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json()["neu"] == 1
def test_uebergangene_mails_nennen_absender_und_betreff(admin, monkeypatch):
"""Beim Einrichten muss man sehen, was in den Filter gehoert."""
quelle = _postfach(admin)
admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={"from_contains": "passt.nie"})
# Eigene Message-ID: die Testdatenbank lebt ueber den ganzen Lauf, sonst
# gilt die Mail als bereits erfasst und die Zuordnung wird nie geprueft.
roh = (MUSTER / "raiffeisen_zahlungsstatus.eml").read_bytes().replace(
b"<raiffeisen-muster-1@", b"<ohne-zuordnung-1@")
monkeypatch.setattr(mail_import, "_verbinden", lambda q: FakeImap([roh]))
ergebnis = admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json()
assert ergebnis["uebersprungen"] == 1
assert "raiffeisen.ch" in ergebnis["ohne_zuordnung"][0]["from"]
assert "Zahlungsstatus" in ergebnis["ohne_zuordnung"][0]["subject"]