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, ""
|
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:
|
def _passender_absender(absender: list[MailSender], von: str, betreff: str) -> MailSender | None:
|
||||||
"""Erste Zuordnung, deren Filter greifen. Ohne Filter passt sie auf alles."""
|
"""Erste Zuordnung, deren Filter greifen. Ohne Filter passt sie auf alles."""
|
||||||
von_klein, betreff_klein = von.lower(), betreff.lower()
|
von_klein, betreff_klein = von.lower(), betreff.lower()
|
||||||
@@ -151,6 +165,9 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
|
|||||||
|
|
||||||
verbindung = _verbinden(quelle)
|
verbindung = _verbinden(quelle)
|
||||||
neu = uebersprungen = unklar = 0
|
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:
|
try:
|
||||||
code, _ = verbindung.select(f'"{quelle.folder}"')
|
code, _ = verbindung.select(f'"{quelle.folder}"')
|
||||||
if code != "OK":
|
if code != "OK":
|
||||||
@@ -180,10 +197,15 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
betreff, text = _text_der_mail(msg)
|
betreff, text = _text_der_mail(msg)
|
||||||
von = str(msg["From"] or "")
|
von = _absenderfeld(msg)
|
||||||
zuordnung = _passender_absender(absender, von, betreff)
|
zuordnung = _passender_absender(absender, von, betreff)
|
||||||
if zuordnung is None:
|
if zuordnung is None:
|
||||||
uebersprungen += 1
|
uebersprungen += 1
|
||||||
|
if len(ohne_zuordnung) < 5:
|
||||||
|
ohne_zuordnung.append({
|
||||||
|
"from": str(msg["From"] or "")[:120],
|
||||||
|
"subject": betreff[:120],
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
gesendet = None
|
gesendet = None
|
||||||
@@ -228,7 +250,8 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
|
|||||||
verbindung.logout()
|
verbindung.logout()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
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_error = str(e)[:500]
|
||||||
quelle.last_poll_at = datetime.now(timezone.utc)
|
quelle.last_poll_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
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")
|
admin.post(f"/api/mail/sources/{quelle['id']}/fetch")
|
||||||
assert server.kopiert and "Belege" in server.kopiert[0][1]
|
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",
|
"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.",
|
"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",
|
"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",
|
"secNone": "none",
|
||||||
"secNoneWarn": "Without encryption the username and password travel in clear text. Only sensible when the mail server runs on the same machine.",
|
"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",
|
"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 [ordner, setOrdner] = useState(null); // Ergebnis des Verbindungstests
|
||||||
const [absender, setAbsender] = useState({});
|
const [absender, setAbsender] = useState({});
|
||||||
const [busy, setBusy] = useState(false);
|
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() {
|
async function laden() {
|
||||||
try {
|
try {
|
||||||
@@ -85,6 +88,7 @@ export default function Postfaecher() {
|
|||||||
try {
|
try {
|
||||||
const r = await api.fetchMailSource(quelle.id);
|
const r = await api.fetchMailSource(quelle.id);
|
||||||
toast(t("mailbox.fetched", { neu: r.neu, unklar: r.unklar }));
|
toast(t("mailbox.fetched", { neu: r.neu, unklar: r.unklar }));
|
||||||
|
setUebergangen(r.ohne_zuordnung || []);
|
||||||
laden();
|
laden();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, "error");
|
toast(e.message, "error");
|
||||||
@@ -134,6 +138,17 @@ export default function Postfaecher() {
|
|||||||
}).join(" · ")}
|
}).join(" · ")}
|
||||||
</div>
|
</div>
|
||||||
<SenderForm konten={konten} onAdd={(d) => absenderAnlegen(q, d)} t={t} />
|
<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">
|
<div className="mail-actions">
|
||||||
<button className="btn sm" disabled={busy} onClick={() => testen(q)}>{t("mailbox.test")}</button>
|
<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>
|
<button className="btn sm primary" disabled={busy} onClick={() => abrufen(q)}>{t("mailbox.fetchNow")}</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user