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:
@@ -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)}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user