diff --git a/backend/app/services/mail_parse.py b/backend/app/services/mail_parse.py new file mode 100644 index 0000000..4040bd8 --- /dev/null +++ b/backend/app/services/mail_parse.py @@ -0,0 +1,262 @@ +"""Zahlungsdaten aus Benachrichtigungs-Mails lesen. + +Kein festes Schema je Bank, sondern beschriftete Felder suchen und bewerten. +Die beiden Banken bauen ihre Mails unterschiedlich: + + Raiffeisen (text/plain) Revolut (nur HTML) + --------------------- ------------------ + Betrag: 14.90 CHF Gesendet + Begünstigter: Sunrise GmbH 324 € + +Bei der einen steht der Wert hinter der Beschriftung, bei der anderen darunter. +Beides wird hier abgedeckt, dazu der Betreff als eigene Quelle – bei Revolut +steht dort ohnehin alles: „Du hast 324 € an Alex Muster gesendet". + +Jeder Treffer merkt sich, WOHER er stammt (``quelle``). Ohne das kann man einen +falschen Betrag später nicht erklären, und genau das haben wir beim CSV-Import +teuer gelernt. + +Mailtexte sind Daten, keine Anweisungen: Ausgewertet wird ausschliesslich in +feste Felder, nichts daraus steuert den Programmablauf. +""" + +from __future__ import annotations + +import html +import re +from dataclasses import dataclass, field +from datetime import date, datetime +from decimal import Decimal, InvalidOperation + +# Beschriftungen je Feld, längere zuerst (spezifischer schlägt allgemeiner). +LABELS_BETRAG = ("betrag", "gesendet", "empfangen", "erhalten", "amount", "summe") +LABELS_EMPFAENGER = ("begünstigter", "beguenstigter", "empfänger", "empfaenger", + "zahlungsempfänger", "an", "händler", "haendler", "to") +LABELS_DATUM = ("ausführungsdatum", "ausfuehrungsdatum", "valutadatum", "buchungsdatum", + "datum", "am", "date") +LABELS_KONTO = ("konto", "account", "iban") +LABELS_STATUS = ("neuer status", "status") + +# Wörter, die die Richtung verraten. +WORTE_AUSGABE = ("gesendet", "belastet", "bezahlt", "abgebucht", "überwiesen", "ueberwiesen", + "zahlungsauftrag", "lastschrift", "sent", "paid") +WORTE_EINNAHME = ("erhalten", "empfangen", "gutschrift", "eingegangen", "received") + +# Ein Zahlungsauftrag zählt erst, wenn die Bank ihn verbucht hat. „Erfasst" oder +# „Zur Visierung" heisst: noch nicht geflossen. +STATUS_GEBUCHT = ("verbucht", "ausgeführt", "ausgefuehrt", "erfolgreich", "completed", "booked") + +WAEHRUNGSZEICHEN = {"€": "EUR", "$": "USD", "£": "GBP", "chf": "CHF", "fr.": "CHF"} + +# Betrag mit Währung davor oder dahinter: „14.90 CHF", „324 €", „CHF 1'234.50". +_BETRAG = re.compile( + r"(?:(?PCHF|EUR|USD|GBP|€|\$|£)\s*)?" + r"(?P\d{1,3}(?:['’ ]\d{3})*(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?)" + r"(?:\s*(?PCHF|EUR|USD|GBP|€|\$|£))?", + re.IGNORECASE, +) +_DATUM_MUSTER = ( + (re.compile(r"\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b"), "dmy"), + (re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b"), "ymd"), + (re.compile(r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b"), "dmy"), +) + + +@dataclass +class Feld: + """Ein erkannter Wert samt Begründung, woher er stammt.""" + + wert: object + quelle: str # menschenlesbar, z. B. „aus dem Text, nach ‚Betrag'" + sicher: bool = True + + +@dataclass +class Erkennung: + betrag: Feld | None = None # Cent, vorzeichenlos + waehrung: Feld | None = None + datum: Feld | None = None + empfaenger: Feld | None = None + richtung: Feld | None = None # "expense" | "income" + konto_text: Feld | None = None # Kontobezeichnung aus der Mail + unklar: list[str] = field(default_factory=list) + + def vollstaendig(self) -> bool: + return self.betrag is not None and self.datum is not None and self.richtung is not None + + +def html_zu_text(roh: str) -> str: + """Sichtbaren Text aus HTML gewinnen. + + Stil- und Skriptblöcke MÜSSEN vorher weg – sonst landen Kilobytes CSS im + Text, und darin stehen Zahlen, die wie Beträge aussehen. + """ + txt = re.sub(r"(?is)<(style|script|head)[^>]*>.*?", " ", roh) + txt = re.sub(r"(?is)", " ", txt) + txt = re.sub(r"(?is)|", "\n", txt) + txt = re.sub(r"(?s)<[^>]+>", " ", txt) + txt = html.unescape(txt) + zeilen = [re.sub(r"[ \t\xa0]+", " ", z).strip() for z in txt.splitlines()] + return "\n".join(z for z in zeilen if z) + + +def _zahl_zu_cent(roh: str) -> int | None: + s = roh.replace("'", "").replace("’", "").replace(" ", "") + if "," in s and "." in s: + s = s.replace(".", "").replace(",", ".") if s.rindex(",") > s.rindex(".") else s.replace(",", "") + elif "," in s: + s = s.replace(",", ".") + try: + return int((Decimal(s) * 100).to_integral_value()) + except (InvalidOperation, ValueError): + return None + + +def _betraege(text: str) -> list[tuple[int, str | None, int]]: + """Alle geldartigen Vorkommen als (Cent, Währung, Position).""" + treffer = [] + for m in _BETRAG.finditer(text): + zeichen = m.group("vor") or m.group("nach") + if zeichen is None: + continue # blosse Zahlen sind keine Beträge + cent = _zahl_zu_cent(m.group("zahl")) + if cent is None or cent == 0: + continue + w = WAEHRUNGSZEICHEN.get(zeichen.lower(), zeichen.upper()) + treffer.append((cent, w, m.start())) + return treffer + + +def _felder(text: str) -> dict[str, list[tuple[str, int, str]]]: + """Beschriftete Werte einsammeln – beide Bauformen. + + ``Betrag: 14.90 CHF`` liefert den Wert hinter dem Doppelpunkt, + ``Gesendet`` / ``324 €`` den Inhalt der Folgezeile. + """ + gefunden: dict[str, list[tuple[str, int, str]]] = {} + zeilen = text.splitlines() + pos = 0 + for i, zeile in enumerate(zeilen): + start = pos + pos += len(zeile) + 1 + if ":" in zeile: + links, rechts = zeile.split(":", 1) + beschriftung = links.strip() + schl, wert = beschriftung.lower(), rechts.strip() + if wert: + gefunden.setdefault(schl, []).append((wert, start, beschriftung)) + continue + beschriftung = zeile.strip().rstrip(":") + schl = beschriftung.lower() + if schl and len(schl) <= 30 and i + 1 < len(zeilen): + folge = zeilen[i + 1].strip() + if folge and ":" not in folge: + gefunden.setdefault(schl, []).append((folge, pos, beschriftung)) + return gefunden + + +def _erster_treffer(felder: dict, labels: tuple[str, ...]) -> tuple[str, int, str] | None: + """Wert, Stelle und die Beschriftung in Originalschreibweise.""" + for label in labels: + if label in felder: + wert, stelle, beschriftung = felder[label][0] + return wert, stelle, beschriftung + return None + + +def _datum_aus(text: str) -> date | None: + for muster, form in _DATUM_MUSTER: + m = muster.search(text) + if not m: + continue + a, b, c = (int(x) for x in m.groups()) + try: + return date(c, b, a) if form == "dmy" else date(a, b, c) + except ValueError: + continue + return None + + +def parse(betreff: str, text: str, mail_datum: datetime | None = None) -> Erkennung: + """Betrag, Datum, Empfänger und Richtung aus einer Mail lesen.""" + e = Erkennung() + felder = _felder(text) + ganzes = f"{betreff}\n{text}" + klein = ganzes.lower() + + # --- Richtung ----------------------------------------------------------- + for wort in WORTE_EINNAHME: + if wort in klein: + e.richtung = Feld("income", f"Wort „{wort}“") + break + if e.richtung is None: + for wort in WORTE_AUSGABE: + if wort in klein: + e.richtung = Feld("expense", f"Wort „{wort}“") + break + if e.richtung is None: + e.richtung = Feld("expense", "Vorgabe: Ausgabe", sicher=False) + e.unklar.append("richtung") + + # --- Betrag: beschriftetes Feld schlägt Fund im Fliesstext -------------- + treffer = _erster_treffer(felder, LABELS_BETRAG) + if treffer: + wert, _, label = treffer + gefunden = _betraege(wert) + if gefunden: + cent, waehrung, _ = gefunden[0] + e.betrag = Feld(cent, f"nach „{label}“") + if waehrung: + e.waehrung = Feld(waehrung, f"nach „{label}“") + if e.betrag is None: + aus_betreff = _betraege(betreff) + kandidaten = aus_betreff or _betraege(text) + quelle = "aus dem Betreff" if aus_betreff else "aus dem Text" + if len(kandidaten) == 1 or (kandidaten and aus_betreff): + cent, waehrung, _ = kandidaten[0] + e.betrag = Feld(cent, quelle) + if waehrung: + e.waehrung = Feld(waehrung, quelle) + elif kandidaten: + # Mehrere gleichwertige Beträge: raten wäre hier teuer. + e.unklar.append("betrag") + + # --- Datum -------------------------------------------------------------- + treffer = _erster_treffer(felder, LABELS_DATUM) + if treffer: + wert, _, label = treffer + d = _datum_aus(wert) + if d is not None: + e.datum = Feld(d, f"nach „{label}“") + if e.datum is None: + d = _datum_aus(text) + if d is not None: + e.datum = Feld(d, "aus dem Text") + elif mail_datum is not None: + # Revolut schreibt „Samstag, 1 August, 12:41“ – ohne Jahr. Der + # Zeitstempel der Mail ist bei Sofort-Mitteilungen zuverlässiger + # als ein zusammengeratenes Datum. + e.datum = Feld(mail_datum.date(), "Zeitpunkt der E-Mail") + + # --- Empfänger ---------------------------------------------------------- + treffer = _erster_treffer(felder, LABELS_EMPFAENGER) + if treffer: + wert, _, label = treffer + e.empfaenger = Feld(wert[:255], f"nach „{label}“") + + # --- Kontobezeichnung --------------------------------------------------- + treffer = _erster_treffer(felder, LABELS_KONTO) + if treffer: + wert, _, label = treffer + e.konto_text = Feld(wert[:255], f"nach „{label}“") + + # --- Status: nur verbuchte Zahlungen zählen ----------------------------- + treffer = _erster_treffer(felder, LABELS_STATUS) + if treffer: + wert, _, label = treffer + if not any(g in wert.lower() for g in STATUS_GEBUCHT): + e.unklar.append("status") + + if e.betrag is None: + e.unklar.append("betrag") if "betrag" not in e.unklar else None + return e diff --git a/backend/tests/fixtures/mail/raiffeisen_zahlungsstatus.eml b/backend/tests/fixtures/mail/raiffeisen_zahlungsstatus.eml new file mode 100644 index 0000000..82b3802 --- /dev/null +++ b/backend/tests/fixtures/mail/raiffeisen_zahlungsstatus.eml @@ -0,0 +1,48 @@ +From: noreply@ebanking.raiffeisen.ch +To: kundin@example.com +Date: Fri, 31 Jul 2026 00:59:29 +0200 +Message-Id: +Subject: =?utf-8?Q?Raiffeisen_E-Banking_-_=C3=84nderung_Zahlungsstatus?= +Mime-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=utf-8 + +Sehr geehrte Kundin, sehr geehrter Kunde + +Sie haben im Raiffeisen E-Banking die E-Mail-Benachrichtigung bei =C3=84nd= +erungen von Zahlungsstatus abonniert. Gerne informieren wir Sie =C3=BCber = +folgende Ver=C3=A4nderung: + +Auf Ihrem YoungMember Privatkonto "YoungMember Privatkonto" hat der Status= + eines Zahlungsauftrages ge=C3=A4ndert. +Details : + Konto: YoungMember Privatkonto "YoungMember Privatkonto" + Beg=C3=BCnstigter: Sunrise GmbH / Yallo + Betrag: 14.90 CHF + Ausf=C3=BChrungsdatum: 31.07.2026 + Neuer Status: Verbucht + +Freundliche Gr=C3=BCsse +Ihr Raiffeisen E-Banking Team + + +Hinweis: Auftr=C3=A4ge via E-Mail werden aus Sicherheitsgr=C3=BCnden nicht= + entgegengenommen. Bitte benutzen Sie die entsprechende Funktion im Raiffe= +isen E-Banking. + +***************************************************** + +This e-mail may contain confidential material. It is intended only for the= + person or entity which it is addressed to. In case you should not be supp= +osed to get this e-mail we ask you to delete it without taking notice of i= +ts content. Any views or opinions expressed in this e-mail are those of th= +e sender and do not necessarily coincide with those of The Swiss Raiffeise= +n Group. Therefore this e-mail does not represent a binding agreement nor = +an offer to deal. E-Mail transmission can be insecure and can contain erro= +rs. Information could be intercepted, corrupted, lost, destroyed, incomple= +te or may contain viruses. Neither The Swiss Raiffeisen Group nor the send= +er can accept any liability for any kind of damage as the result of viruse= +s or transmission errors. + +***************************************************** + diff --git a/backend/tests/fixtures/mail/revolut_ueberweisung.eml b/backend/tests/fixtures/mail/revolut_ueberweisung.eml new file mode 100644 index 0000000..3383f42 --- /dev/null +++ b/backend/tests/fixtures/mail/revolut_ueberweisung.eml @@ -0,0 +1,161 @@ +From: Revolut +To: kundin@example.com +Date: Sat, 01 Aug 2026 10:41:51 +0000 +Message-Id: +Subject: =?utf-8?Q?Du_hast_324_=E2=82=AC_an_Alex_Muster_gesendet?= +Mime-Version: 1.0 +Content-Type: text/html; charset=utf-8 + + + + + + + +
+ + + + + +
+ + + + +
+ + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + +
+ +

Deine Überweisung war erfolgreich

+ +
+ +
+ +
+ + +
+ +
+ + + + + + +
+ + + + +
+ + +
+ + + + +
+ + + + + +
+ +

Hallo Alex,

Du hast 324 € an Alex Muster gesendet. Die Details findest du unten:

An
Alex Muster

Gesendet
324 €

Verwendungszweck
Gesendet von Revolut

Am
Samstag, 1 August, 12:41

Erwartete Ankunft
Innerhalb weniger Sekunden


\ No newline at end of file diff --git a/backend/tests/test_mail_parse.py b/backend/tests/test_mail_parse.py new file mode 100644 index 0000000..b4c7d6b --- /dev/null +++ b/backend/tests/test_mail_parse.py @@ -0,0 +1,105 @@ +"""Erkennung von Zahlungsdaten aus echten Benachrichtigungs-Mails. + +Die Muster unter ``fixtures/mail`` sind echte Mails von Raiffeisen und Revolut, +anonymisiert. Aufbau, Zeilenumbrüche und Formulierungen sind unverändert – nur +daran lässt sich prüfen, ob die Erkennung wirklich trägt. +""" + +import email +import email.policy +import pathlib +from datetime import date + +from app.services import mail_parse + +MUSTER = pathlib.Path(__file__).parent / "fixtures" / "mail" + + +def _lies(name: str): + with open(MUSTER / name, "rb") as f: + msg = email.message_from_binary_file(f, policy=email.policy.default) + teil = msg.get_body(preferencelist=("plain",)) + if teil is not None: + text = teil.get_content() + else: + text = mail_parse.html_zu_text(msg.get_body(preferencelist=("html",)).get_content()) + return msg["Subject"], text, msg + + +def test_raiffeisen_zahlungsstatus(): + """Beschriftete Felder in einer Klartext-Mail: ``Betrag: 14.90 CHF``.""" + betreff, text, msg = _lies("raiffeisen_zahlungsstatus.eml") + e = mail_parse.parse(betreff, text, email.utils.parsedate_to_datetime(msg["Date"])) + + assert e.betrag.wert == 1490 + assert e.waehrung.wert == "CHF" + assert e.datum.wert == date(2026, 7, 31) + assert e.empfaenger.wert == "Sunrise GmbH / Yallo" + assert e.richtung.wert == "expense" + assert e.unklar == [] + # Jeder Wert muss erklären können, woher er stammt. + assert "Betrag" in e.betrag.quelle + assert "Ausführungsdatum" in e.datum.quelle + + +def test_revolut_ueberweisung(): + """Nur HTML, Wert steht UNTER der Beschriftung, Datum ohne Jahr.""" + betreff, text, msg = _lies("revolut_ueberweisung.eml") + e = mail_parse.parse(betreff, text, email.utils.parsedate_to_datetime(msg["Date"])) + + assert e.betrag.wert == 32400 + assert e.waehrung.wert == "EUR" + assert e.empfaenger.wert == "Alex Muster" + assert e.richtung.wert == "expense" # „gesendet“ + # „Samstag, 1 August, 12:41“ trägt kein Jahr – der Zeitstempel der Mail + # ist hier die verlässlichere Quelle. + assert e.datum.wert == date(2026, 8, 1) + assert "E-Mail" in e.datum.quelle + + +def test_stilbloecke_liefern_keine_betraege(): + """CSS enthält Zahlen wie ``width:100%`` und ``480px`` – kein Geld.""" + roh = "

Betrag: 12,50 EUR

" + text = mail_parse.html_zu_text(roh) + assert "480" not in text + e = mail_parse.parse("", text) + assert e.betrag.wert == 1250 + + +def test_nicht_verbuchter_auftrag_gilt_als_unklar(): + """Ein erfasster, aber nicht verbuchter Auftrag ist noch nicht geflossen.""" + text = ("Auf Ihrem Privatkonto hat der Status eines Zahlungsauftrages geändert.\n" + " Begünstigter: Muster AG\n" + " Betrag: 40.00 CHF\n" + " Ausführungsdatum: 05.03.2026\n" + " Neuer Status: Zur Visierung\n") + e = mail_parse.parse("Änderung Zahlungsstatus", text) + assert "status" in e.unklar + + e2 = mail_parse.parse("Änderung Zahlungsstatus", text.replace("Zur Visierung", "Verbucht")) + assert e2.unklar == [] + + +def test_mehrere_betraege_ohne_beschriftung_bleiben_unklar(): + """Lieber nachfragen als still den falschen Betrag buchen.""" + e = mail_parse.parse("Kontoinformation", "Umsatz 25,00 EUR\nNeuer Saldo 813,55 EUR\n") + assert "betrag" in e.unklar + assert e.betrag is None + + +def test_beschriftung_schlaegt_den_saldo(): + """Steht der Betrag beschriftet da, gewinnt er gegen den Kontostand.""" + e = mail_parse.parse("Kontoinformation", "Betrag: 25,00 EUR\nNeuer Saldo: 813,55 EUR\n") + assert e.betrag.wert == 2500 + + +def test_gutschrift_wird_als_einnahme_erkannt(): + e = mail_parse.parse("Du hast 50 € erhalten", "Empfangen\n50 €\n") + assert e.richtung.wert == "income" + assert e.betrag.wert == 5000 + + +def test_blosse_zahlen_sind_keine_betraege(): + e = mail_parse.parse("Auftrag 12345", "Referenz 998877\nBestellnummer 4711\n") + assert e.betrag is None + assert "betrag" in e.unklar