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"]
+3 -1
View File
@@ -529,6 +529,8 @@
"secNone": "keine",
"secNoneWarn": "Ohne Verschlüsselung gehen Benutzername und Passwort im Klartext über das Netz. Nur sinnvoll, wenn der Mailserver auf demselben Rechner läuft.",
"addSender": "Absender zuordnen",
"edit": "Postfach bearbeiten"
"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."
}
}
+3 -1
View File
@@ -529,6 +529,8 @@
"secNone": "none",
"secNoneWarn": "Without encryption the username and password travel in clear text. Only sensible when the mail server runs on the same machine.",
"addSender": "Map sender",
"edit": "Edit mailbox"
"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."
}
}
+15
View File
@@ -32,6 +32,9 @@ export default function Postfaecher() {
const [ordner, setOrdner] = useState(null); // Ergebnis des Verbindungstests
const [absender, setAbsender] = useState({});
const [busy, setBusy] = useState(false);
// Was beim letzten Abruf durchgefallen ist – beim Einrichten die einzige
// Möglichkeit zu sehen, was in den Absender-Filter gehört.
const [uebergangen, setUebergangen] = useState([]);
async function laden() {
try {
@@ -85,6 +88,7 @@ export default function Postfaecher() {
try {
const r = await api.fetchMailSource(quelle.id);
toast(t("mailbox.fetched", { neu: r.neu, unklar: r.unklar }));
setUebergangen(r.ohne_zuordnung || []);
laden();
} catch (e) {
toast(e.message, "error");
@@ -134,6 +138,17 @@ export default function Postfaecher() {
}).join(" · ")}
</div>
<SenderForm konten={konten} onAdd={(d) => absenderAnlegen(q, d)} t={t} />
{uebergangen.length > 0 && (
<div className="alert" style={{ display: "block", marginTop: "var(--sp-2)" }}>
<strong className="small">{t("mailbox.skippedTitle")}</strong>
<p className="muted small">{t("mailbox.skippedHint")}</p>
<ul className="muted small" style={{ paddingLeft: "1.2em", margin: 0 }}>
{uebergangen.map((m, i) => (
<li key={i} className="selectable">{m.from} — {m.subject}</li>
))}
</ul>
</div>
)}
<div className="mail-actions">
<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>