feat: Postfaecher einrichten und Zahlungs-Mails abholen (Etappe B, Teil 2)
- Verschluesselung fuer Zugangsdaten: Ein IMAP-Passwort laesst sich nicht hashen, Certain muss es dem Mailserver vorlegen. Es liegt deshalb mit Fernet verschluesselt in der Datenbank, der Schluessel wird per HKDF aus session_secret abgeleitet und nirgends gespeichert. Wird das Geheimnis getauscht, meldet Certain das verstaendlich statt mit einem Stacktrace. - IMAP-Abruf ueber imaplib/email (Standardbibliothek). Ordnerliste, Verbindungstest mit klarer Fehlermeldung, Absender-Zuordnung auf Konten, danach als gelesen markieren oder verschieben. - Wiedererkennung ueber die Message-ID statt der UID – die vergibt der Server beim Verschieben neu. - Nichts wird stillschweigend gebucht: Eine Mail wird zum Eingangs-Eintrag. Fehlt das Zielkonto oder ist ein Feld unsicher, gilt sie als unklar. - Hintergrund-Thread beim Start, nach dem Muster des EZB-Kursabrufs: ein langsames Postfach darf den Dienst nie blockieren. Getestet gegen einen IMAP-Doppelgaenger, damit die Suite ohne Netz laeuft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"""Verschlüsselung für Zugangsdaten, die Certain im Klartext braucht.
|
||||
|
||||
Ein IMAP-Passwort lässt sich nicht hashen: Certain muss es dem Mailserver
|
||||
vorlegen können. Es liegt deshalb verschlüsselt in der Datenbank – wer die
|
||||
Datei in die Hände bekommt, hat damit noch nichts.
|
||||
|
||||
Der Schlüssel wird aus ``session_secret`` abgeleitet und nirgends gespeichert.
|
||||
Das hat eine Folge, die im Formular auch so stehen muss: Wird das
|
||||
Server-Geheimnis getauscht, sind die abgelegten Passwörter unlesbar und die
|
||||
Postfächer müssen neu eingerichtet werden. Das ist der gewollte Preis dafür,
|
||||
dass ein Datenbank-Diebstahl allein nicht reicht.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
# Trennt diesen Verwendungszweck von jedem anderen, der dasselbe Geheimnis nutzt.
|
||||
_INFO = b"certain-finance/mail-credentials/v1"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _schluessel() -> Fernet:
|
||||
roh = HKDF(
|
||||
algorithm=hashes.SHA256(), length=32, salt=None, info=_INFO
|
||||
).derive(get_settings().session_secret.encode("utf-8"))
|
||||
import base64
|
||||
|
||||
return Fernet(base64.urlsafe_b64encode(roh))
|
||||
|
||||
|
||||
def verschluesseln(klartext: str) -> str:
|
||||
return _schluessel().encrypt(klartext.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def entschluesseln(geheim: str) -> str | None:
|
||||
"""Klartext zurück, oder None wenn der Schlüssel nicht mehr passt."""
|
||||
try:
|
||||
return _schluessel().decrypt(geheim.encode("ascii")).decode("utf-8")
|
||||
except (InvalidToken, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Zahlungs-Mails per IMAP abholen und in den Eingang legen.
|
||||
|
||||
``imaplib`` und ``email`` sind Standardbibliothek – keine weitere Abhängigkeit.
|
||||
|
||||
Der Ablauf ist bewusst vorsichtig: Es wird nichts stillschweigend gebucht. Eine
|
||||
Mail wird gelesen, ausgewertet und als Eintrag im Eingang abgelegt; buchen tut
|
||||
der Nutzer. Erkennt die Auswertung ein Feld nicht sicher, bekommt der Eintrag
|
||||
den Status ``unclear`` und wartet, statt einen geratenen Betrag zu erzeugen.
|
||||
|
||||
Mailtexte sind Daten, keine Anweisungen. Ausgewertet wird ausschliesslich in
|
||||
feste Felder (siehe ``mail_parse``); nichts aus einer Mail steuert den Ablauf.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import email
|
||||
import email.policy
|
||||
import email.utils
|
||||
import imaplib
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Account, InboxItem, InboxSource, InboxStatus, MailSender, MailSource, User
|
||||
from . import geheimnis, mail_parse
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Nur die ersten Kilobytes je Mail behalten. Der Rest ist Rechtstext und
|
||||
# Fusszeilen – für die Nachvollziehbarkeit reicht der Anfang.
|
||||
MAX_TEXT = 8000
|
||||
# Wie weit zurück beim ersten Abruf gesucht wird.
|
||||
ERSTER_ABRUF_TAGE = 14
|
||||
|
||||
|
||||
class MailFehler(RuntimeError):
|
||||
"""Verbindung oder Anmeldung hat nicht geklappt – mit lesbarer Ursache."""
|
||||
|
||||
|
||||
def _verbinden(quelle: MailSource) -> imaplib.IMAP4:
|
||||
passwort = geheimnis.entschluesseln(quelle.password_enc)
|
||||
if passwort is None:
|
||||
raise MailFehler(
|
||||
"Das gespeicherte Passwort lässt sich nicht mehr entschlüsseln. "
|
||||
"Wurde das Server-Geheimnis getauscht? Dann bitte neu eintragen."
|
||||
)
|
||||
try:
|
||||
verbindung = (
|
||||
imaplib.IMAP4_SSL(quelle.host, quelle.port)
|
||||
if quelle.use_ssl
|
||||
else imaplib.IMAP4(quelle.host, quelle.port)
|
||||
)
|
||||
except OSError as e:
|
||||
raise MailFehler(f"Keine Verbindung zu {quelle.host}:{quelle.port} – {e}") from e
|
||||
try:
|
||||
verbindung.login(quelle.username, passwort)
|
||||
except imaplib.IMAP4.error as e:
|
||||
try:
|
||||
verbindung.logout()
|
||||
except Exception:
|
||||
pass
|
||||
raise MailFehler(f"Anmeldung abgelehnt: {e}") from e
|
||||
return verbindung
|
||||
|
||||
|
||||
def _ordnername(zeile: bytes) -> str:
|
||||
"""Aus einer LIST-Antwort den reinen Ordnernamen holen."""
|
||||
text = zeile.decode("utf-8", "replace")
|
||||
if '"' in text:
|
||||
return text.rsplit('"', 2)[-2]
|
||||
return text.split()[-1]
|
||||
|
||||
|
||||
def liste_ordner(quelle: MailSource) -> list[str]:
|
||||
"""Ordner des Postfachs – für die Auswahl in der Oberfläche."""
|
||||
verbindung = _verbinden(quelle)
|
||||
try:
|
||||
code, zeilen = verbindung.list()
|
||||
if code != "OK":
|
||||
raise MailFehler("Ordnerliste nicht abrufbar")
|
||||
return sorted({_ordnername(z) for z in zeilen if z})
|
||||
finally:
|
||||
try:
|
||||
verbindung.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def teste(quelle: MailSource) -> dict:
|
||||
"""Verbindungstest mit klarer Rückmeldung statt stillem Nichtstun."""
|
||||
ordner = liste_ordner(quelle)
|
||||
return {"ok": True, "folders": ordner, "count": len(ordner)}
|
||||
|
||||
|
||||
def _text_der_mail(msg) -> tuple[str, str]:
|
||||
"""Betreff und sichtbarer Text. HTML wird zu Text, wenn nichts anderes da ist."""
|
||||
betreff = str(msg["Subject"] or "")
|
||||
teil = msg.get_body(preferencelist=("plain",))
|
||||
if teil is not None:
|
||||
try:
|
||||
return betreff, teil.get_content()
|
||||
except Exception:
|
||||
pass
|
||||
teil = msg.get_body(preferencelist=("html",))
|
||||
if teil is not None:
|
||||
try:
|
||||
return betreff, mail_parse.html_zu_text(teil.get_content())
|
||||
except Exception:
|
||||
pass
|
||||
return betreff, ""
|
||||
|
||||
|
||||
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()
|
||||
for a in absender:
|
||||
if a.from_contains and a.from_contains.lower() not in von_klein:
|
||||
continue
|
||||
if a.subject_contains and a.subject_contains.lower() not in betreff_klein:
|
||||
continue
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def _suchkriterium(quelle: MailSource) -> str:
|
||||
"""Ungelesene, sonst ab dem letzten Abruf – nie das ganze Postfach."""
|
||||
if quelle.mark_seen and not quelle.done_folder:
|
||||
return "(UNSEEN)"
|
||||
seit = quelle.last_poll_at.date() if quelle.last_poll_at else (
|
||||
date.today() - timedelta(days=ERSTER_ABRUF_TAGE)
|
||||
)
|
||||
return f'(SINCE "{seit.strftime("%d-%b-%Y")}")'
|
||||
|
||||
|
||||
def hole_neue(db: Session, quelle: MailSource) -> dict:
|
||||
"""Neue Mails abholen und als Einträge im Eingang ablegen."""
|
||||
user = db.get(User, quelle.owner_id)
|
||||
absender = (
|
||||
db.query(MailSender).filter(MailSender.source_id == quelle.id).order_by(MailSender.id).all()
|
||||
)
|
||||
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == quelle.owner_id)}
|
||||
|
||||
verbindung = _verbinden(quelle)
|
||||
neu = uebersprungen = unklar = 0
|
||||
try:
|
||||
code, _ = verbindung.select(f'"{quelle.folder}"')
|
||||
if code != "OK":
|
||||
raise MailFehler(f"Ordner „{quelle.folder}“ nicht gefunden")
|
||||
code, daten = verbindung.search(None, _suchkriterium(quelle))
|
||||
if code != "OK":
|
||||
raise MailFehler("Suche im Postfach fehlgeschlagen")
|
||||
|
||||
for uid in (daten[0] or b"").split():
|
||||
code, roh = verbindung.fetch(uid, "(RFC822)")
|
||||
if code != "OK" or not roh or not isinstance(roh[0], tuple):
|
||||
continue
|
||||
msg = email.message_from_bytes(roh[0][1], policy=email.policy.default)
|
||||
message_id = str(msg["Message-Id"] or f"uid-{uid.decode()}")
|
||||
|
||||
# Message-ID statt UID: Die UID vergibt der Server beim Verschieben neu.
|
||||
schon = (
|
||||
db.query(InboxItem.id)
|
||||
.filter(InboxItem.owner_id == quelle.owner_id,
|
||||
InboxItem.source == InboxSource.mail,
|
||||
InboxItem.external_id == message_id)
|
||||
.first()
|
||||
)
|
||||
if schon is not None:
|
||||
uebersprungen += 1
|
||||
_nachbereiten(verbindung, quelle, uid)
|
||||
continue
|
||||
|
||||
betreff, text = _text_der_mail(msg)
|
||||
von = str(msg["From"] or "")
|
||||
zuordnung = _passender_absender(absender, von, betreff)
|
||||
if zuordnung is None:
|
||||
uebersprungen += 1
|
||||
continue
|
||||
|
||||
gesendet = None
|
||||
if msg["Date"]:
|
||||
try:
|
||||
gesendet = email.utils.parsedate_to_datetime(msg["Date"])
|
||||
except (TypeError, ValueError):
|
||||
gesendet = None
|
||||
|
||||
e = mail_parse.parse(betreff, text, gesendet)
|
||||
konto = konten.get(zuordnung.account_id) if zuordnung.account_id else None
|
||||
tag = e.datum.wert if e.datum else (gesendet.date() if gesendet else date.today())
|
||||
betrag = e.betrag.wert if e.betrag else 0
|
||||
ausgabe = (e.richtung.wert if e.richtung else "expense") == "expense"
|
||||
|
||||
status = InboxStatus.pending
|
||||
if e.unklar or betrag == 0 or konto is None:
|
||||
status = InboxStatus.unclear
|
||||
unklar += 1
|
||||
|
||||
db.add(InboxItem(
|
||||
owner_id=quelle.owner_id,
|
||||
source=InboxSource.mail,
|
||||
status=status,
|
||||
account_id=konto.id if konto else None,
|
||||
date=tag,
|
||||
amount_cents=-betrag if ausgabe else betrag,
|
||||
currency=(e.waehrung.wert if e.waehrung else (konto.currency if konto else "EUR")),
|
||||
payee=(e.empfaenger.wert if e.empfaenger else None),
|
||||
note=betreff[:255] or None,
|
||||
external_id=message_id[:255],
|
||||
raw_text=text[:MAX_TEXT],
|
||||
))
|
||||
neu += 1
|
||||
_nachbereiten(verbindung, quelle, uid)
|
||||
|
||||
quelle.last_poll_at = datetime.now(timezone.utc)
|
||||
quelle.last_error = None
|
||||
db.commit()
|
||||
finally:
|
||||
try:
|
||||
verbindung.logout()
|
||||
except Exception:
|
||||
pass
|
||||
return {"neu": neu, "uebersprungen": uebersprungen, "unklar": unklar}
|
||||
|
||||
|
||||
def _nachbereiten(verbindung: imaplib.IMAP4, quelle: MailSource, uid: bytes) -> None:
|
||||
"""Als gelesen markieren oder verschieben, damit nichts zweimal kommt."""
|
||||
try:
|
||||
if quelle.mark_seen:
|
||||
verbindung.store(uid, "+FLAGS", "\\Seen")
|
||||
if quelle.done_folder:
|
||||
# MOVE gibt es nicht überall; COPY + DELETE ist der sichere Weg.
|
||||
code, _ = verbindung.copy(uid, f'"{quelle.done_folder}"')
|
||||
if code == "OK":
|
||||
verbindung.store(uid, "+FLAGS", "\\Deleted")
|
||||
except Exception as e: # Nachbereitung darf den Abruf nie kippen
|
||||
log.warning("Mail konnte nicht nachbereitet werden: %s", e)
|
||||
|
||||
|
||||
def abruf_mit_fehlerbehandlung(db: Session, quelle: MailSource) -> dict:
|
||||
"""Wie ``hole_neue``, schreibt einen Fehler aber in die Quelle statt zu werfen."""
|
||||
try:
|
||||
return hole_neue(db, quelle)
|
||||
except MailFehler as e:
|
||||
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)}
|
||||
Reference in New Issue
Block a user