Neue Features: - 3-Panel Dateimanager (Ordnerbaum, Dateiliste, Vorschau) - Separates Vorschau-Fenster für zweiten Monitor - Resize-Handles für flexible Panel-Größen (horizontal & vertikal) - Vorschau-Panel ausblendbar wenn externes Fenster aktiv - Natürliche Sortierung (Sonderzeichen → Zahlen → Buchstaben) - PDF-Vorschau mit Fit-to-Page - Email-Attachment Abruf erweitert Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
487 lines
18 KiB
Python
487 lines
18 KiB
Python
"""
|
|
Mail-Fetcher Modul
|
|
Holt Attachments aus IMAP-Postfächern
|
|
"""
|
|
import imaplib
|
|
import email
|
|
from email.header import decode_header
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import List, Dict, Optional, Callable
|
|
import logging
|
|
import threading
|
|
|
|
from ..config import INBOX_DIR
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Globaler Manager für laufende Abrufe
|
|
class AbrufManager:
|
|
"""Verwaltet laufende Mail-Abrufe und ermöglicht Abbruch"""
|
|
|
|
def __init__(self):
|
|
self._aktive_abrufe: Dict[int, dict] = {} # postfach_id -> status
|
|
self._lock = threading.Lock()
|
|
|
|
def starten(self, postfach_id: int) -> bool:
|
|
"""Startet einen Abruf, gibt False zurück wenn bereits einer läuft"""
|
|
with self._lock:
|
|
if postfach_id in self._aktive_abrufe:
|
|
return False
|
|
self._aktive_abrufe[postfach_id] = {
|
|
"status": "running",
|
|
"gestartet": datetime.now(),
|
|
"abbrechen": False
|
|
}
|
|
return True
|
|
|
|
def stoppen(self, postfach_id: int) -> bool:
|
|
"""Markiert einen Abruf zum Abbruch"""
|
|
with self._lock:
|
|
if postfach_id in self._aktive_abrufe:
|
|
self._aktive_abrufe[postfach_id]["abbrechen"] = True
|
|
return True
|
|
return False
|
|
|
|
def beenden(self, postfach_id: int):
|
|
"""Entfernt einen Abruf aus der Liste"""
|
|
with self._lock:
|
|
if postfach_id in self._aktive_abrufe:
|
|
del self._aktive_abrufe[postfach_id]
|
|
|
|
def soll_abbrechen(self, postfach_id: int) -> bool:
|
|
"""Prüft ob ein Abruf abgebrochen werden soll"""
|
|
with self._lock:
|
|
if postfach_id in self._aktive_abrufe:
|
|
return self._aktive_abrufe[postfach_id].get("abbrechen", False)
|
|
return True # Nicht registriert = abbrechen
|
|
|
|
def ist_aktiv(self, postfach_id: int) -> bool:
|
|
"""Prüft ob ein Abruf läuft"""
|
|
with self._lock:
|
|
return postfach_id in self._aktive_abrufe
|
|
|
|
def alle_aktiven(self) -> Dict[int, dict]:
|
|
"""Gibt alle aktiven Abrufe zurück"""
|
|
with self._lock:
|
|
return dict(self._aktive_abrufe)
|
|
|
|
def stoppe_alle(self):
|
|
"""Stoppt alle laufenden Abrufe"""
|
|
with self._lock:
|
|
for postfach_id in self._aktive_abrufe:
|
|
self._aktive_abrufe[postfach_id]["abbrechen"] = True
|
|
|
|
|
|
# Globale Instanz
|
|
abruf_manager = AbrufManager()
|
|
|
|
|
|
class MailFetcher:
|
|
"""Holt Attachments aus einem IMAP-Postfach"""
|
|
|
|
def __init__(self, config: Dict):
|
|
"""
|
|
Args:
|
|
config: Dict mit imap_server, imap_port, email, passwort, ordner,
|
|
erlaubte_typen, max_groesse_mb
|
|
"""
|
|
self.config = config
|
|
self.connection = None
|
|
|
|
def connect(self) -> bool:
|
|
"""Verbindung zum IMAP-Server herstellen"""
|
|
try:
|
|
self.connection = imaplib.IMAP4_SSL(
|
|
self.config["imap_server"],
|
|
self.config.get("imap_port", 993)
|
|
)
|
|
self.connection.login(
|
|
self.config["email"],
|
|
self.config["passwort"]
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"IMAP Verbindungsfehler: {e}")
|
|
return False
|
|
|
|
def disconnect(self):
|
|
"""Verbindung trennen"""
|
|
if self.connection:
|
|
try:
|
|
self.connection.logout()
|
|
except:
|
|
pass
|
|
self.connection = None
|
|
|
|
def liste_ordner(self) -> List[str]:
|
|
"""Listet alle verfügbaren IMAP-Ordner"""
|
|
if not self.connection:
|
|
if not self.connect():
|
|
return []
|
|
|
|
try:
|
|
status, folders = self.connection.list()
|
|
ordner_liste = []
|
|
if status == "OK":
|
|
for folder in folders:
|
|
if isinstance(folder, bytes):
|
|
# Format: (flags) "delimiter" "name"
|
|
parts = folder.decode().split(' "')
|
|
if len(parts) >= 3:
|
|
name = parts[-1].strip('"')
|
|
ordner_liste.append(name)
|
|
else:
|
|
# Fallback
|
|
ordner_liste.append(folder.decode().split()[-1].strip('"'))
|
|
return ordner_liste
|
|
except Exception as e:
|
|
logger.error(f"Fehler beim Auflisten der Ordner: {e}")
|
|
return []
|
|
|
|
def fetch_attachments(self, ziel_ordner: Optional[Path] = None,
|
|
nur_ungelesen: bool = False,
|
|
markiere_gelesen: bool = False,
|
|
alle_ordner: bool = False,
|
|
bereits_verarbeitet: set = None,
|
|
ab_datum: datetime = None) -> List[Dict]:
|
|
"""
|
|
Holt alle Attachments die den Filtern entsprechen
|
|
|
|
Args:
|
|
alle_ordner: Wenn True, werden ALLE IMAP-Ordner durchsucht
|
|
bereits_verarbeitet: Set von Message-IDs die übersprungen werden
|
|
|
|
Returns:
|
|
Liste von Dicts mit: pfad, original_name, absender, betreff, datum, groesse, message_id
|
|
"""
|
|
if not self.connection:
|
|
if not self.connect():
|
|
return []
|
|
|
|
ziel = ziel_ordner or INBOX_DIR
|
|
ziel.mkdir(parents=True, exist_ok=True)
|
|
|
|
ergebnisse = []
|
|
erlaubte_typen = self.config.get("erlaubte_typen", [".pdf"])
|
|
max_groesse = self.config.get("max_groesse_mb", 25) * 1024 * 1024
|
|
bereits_verarbeitet = bereits_verarbeitet or set()
|
|
|
|
# Ordner bestimmen
|
|
if alle_ordner:
|
|
ordner_liste = self.liste_ordner()
|
|
logger.info(f"Durchsuche {len(ordner_liste)} Ordner")
|
|
else:
|
|
ordner_liste = [self.config.get("ordner", "INBOX")]
|
|
|
|
for ordner in ordner_liste:
|
|
ergebnisse.extend(self._fetch_from_folder(
|
|
ordner, ziel, erlaubte_typen, max_groesse,
|
|
nur_ungelesen, markiere_gelesen, bereits_verarbeitet, ab_datum
|
|
))
|
|
|
|
return ergebnisse
|
|
|
|
def _fetch_from_folder(self, ordner: str, ziel: Path,
|
|
erlaubte_typen: List[str], max_groesse: int,
|
|
nur_ungelesen: bool, markiere_gelesen: bool,
|
|
bereits_verarbeitet: set, ab_datum: datetime = None) -> List[Dict]:
|
|
"""Holt Attachments aus einem einzelnen Ordner"""
|
|
ergebnisse = []
|
|
|
|
try:
|
|
# Ordner auswählen
|
|
status, _ = self.connection.select(ordner)
|
|
|
|
# Suche nach Mails - mit optionalem Datum-Filter
|
|
if ab_datum:
|
|
# IMAP Datum-Format: DD-Mon-YYYY (z.B. 01-Jan-2024)
|
|
datum_str = ab_datum.strftime("%d-%b-%Y")
|
|
if nur_ungelesen:
|
|
search_criteria = f'(UNSEEN SINCE {datum_str})'
|
|
else:
|
|
search_criteria = f'(SINCE {datum_str})'
|
|
else:
|
|
search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL"
|
|
status, messages = self.connection.search(None, search_criteria)
|
|
|
|
if status != "OK":
|
|
logger.warning(f"Keine Mails gefunden in {ordner}")
|
|
return []
|
|
|
|
mail_ids = messages[0].split()
|
|
logger.info(f"Gefunden: {len(mail_ids)} Mails in {ordner}")
|
|
|
|
for mail_id in mail_ids:
|
|
try:
|
|
# Mail abrufen
|
|
status, msg_data = self.connection.fetch(mail_id, "(RFC822)")
|
|
if status != "OK":
|
|
continue
|
|
|
|
msg = email.message_from_bytes(msg_data[0][1])
|
|
|
|
# Message-ID extrahieren und prüfen ob bereits verarbeitet
|
|
message_id = msg.get("Message-ID", "")
|
|
if message_id and message_id in bereits_verarbeitet:
|
|
continue # Bereits verarbeitet, überspringen
|
|
|
|
# Metadaten extrahieren
|
|
absender = self._decode_header(msg.get("From", ""))
|
|
betreff = self._decode_header(msg.get("Subject", ""))
|
|
datum = msg.get("Date", "")
|
|
|
|
# Attachments durchgehen
|
|
for part in msg.walk():
|
|
if part.get_content_maintype() == "multipart":
|
|
continue
|
|
|
|
filename = part.get_filename()
|
|
if not filename:
|
|
continue
|
|
|
|
filename = self._decode_header(filename)
|
|
datei_endung = Path(filename).suffix.lower()
|
|
|
|
# Filter prüfen
|
|
if datei_endung not in erlaubte_typen:
|
|
logger.debug(f"Überspringe {filename}: Typ {datei_endung} nicht erlaubt")
|
|
continue
|
|
|
|
payload = part.get_payload(decode=True)
|
|
if not payload:
|
|
continue
|
|
|
|
if len(payload) > max_groesse:
|
|
logger.warning(f"Überspringe {filename}: Zu groß ({len(payload)} bytes)")
|
|
continue
|
|
|
|
# Speichern
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
safe_filename = self._safe_filename(filename)
|
|
ziel_pfad = ziel / f"{timestamp}_{safe_filename}"
|
|
|
|
# Eindeutigen Namen sicherstellen
|
|
counter = 1
|
|
while ziel_pfad.exists():
|
|
ziel_pfad = ziel / f"{timestamp}_{counter}_{safe_filename}"
|
|
counter += 1
|
|
|
|
ziel_pfad.write_bytes(payload)
|
|
|
|
ergebnisse.append({
|
|
"pfad": str(ziel_pfad),
|
|
"original_name": filename,
|
|
"absender": absender,
|
|
"betreff": betreff,
|
|
"datum": datum,
|
|
"groesse": len(payload),
|
|
"message_id": message_id,
|
|
"ordner": ordner
|
|
})
|
|
|
|
logger.info(f"Gespeichert: {ziel_pfad.name}")
|
|
|
|
# Als gelesen markieren
|
|
if markiere_gelesen and ergebnisse:
|
|
self.connection.store(mail_id, "+FLAGS", "\\Seen")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Fehler bei Mail {mail_id}: {e}")
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.error(f"Fehler beim Abrufen: {e}")
|
|
|
|
return ergebnisse
|
|
|
|
def _decode_header(self, value: str) -> str:
|
|
"""Dekodiert Email-Header (kann encoded sein)"""
|
|
if not value:
|
|
return ""
|
|
try:
|
|
decoded_parts = decode_header(value)
|
|
result = []
|
|
for part, charset in decoded_parts:
|
|
if isinstance(part, bytes):
|
|
result.append(part.decode(charset or "utf-8", errors="replace"))
|
|
else:
|
|
result.append(part)
|
|
return " ".join(result)
|
|
except:
|
|
return str(value)
|
|
|
|
def _safe_filename(self, filename: str) -> str:
|
|
"""Macht Dateinamen sicher für Dateisystem"""
|
|
# Ungültige Zeichen ersetzen
|
|
invalid_chars = '<>:"/\\|?*'
|
|
for char in invalid_chars:
|
|
filename = filename.replace(char, "_")
|
|
return filename.strip()
|
|
|
|
def fetch_attachments_generator(self, ziel_ordner: Optional[Path] = None,
|
|
nur_ungelesen: bool = False,
|
|
markiere_gelesen: bool = False,
|
|
alle_ordner: bool = False,
|
|
bereits_verarbeitet: set = None,
|
|
abbruch_callback: Callable[[], bool] = None,
|
|
ab_datum: datetime = None):
|
|
"""
|
|
Generator-Version für Streaming - yielded Events während des Abrufs
|
|
|
|
Args:
|
|
abbruch_callback: Funktion die True zurückgibt wenn abgebrochen werden soll
|
|
|
|
Yields:
|
|
Dict mit type: "ordner", "mails", "datei", "skip", "fehler", "abgebrochen"
|
|
"""
|
|
if not self.connection:
|
|
if not self.connect():
|
|
yield {"type": "fehler", "nachricht": "Verbindung fehlgeschlagen"}
|
|
return
|
|
|
|
ziel = ziel_ordner or INBOX_DIR
|
|
ziel.mkdir(parents=True, exist_ok=True)
|
|
|
|
erlaubte_typen = self.config.get("erlaubte_typen", [".pdf"])
|
|
max_groesse = self.config.get("max_groesse_mb", 25) * 1024 * 1024
|
|
bereits_verarbeitet = bereits_verarbeitet or set()
|
|
|
|
# Ordner bestimmen
|
|
if alle_ordner:
|
|
ordner_liste = self.liste_ordner()
|
|
yield {"type": "info", "nachricht": f"{len(ordner_liste)} Ordner gefunden"}
|
|
else:
|
|
ordner_liste = [self.config.get("ordner", "INBOX")]
|
|
|
|
for ordner in ordner_liste:
|
|
# Abbruch prüfen
|
|
if abbruch_callback and abbruch_callback():
|
|
yield {"type": "abgebrochen", "nachricht": "Abruf wurde abgebrochen"}
|
|
return
|
|
|
|
yield {"type": "ordner", "name": ordner}
|
|
|
|
try:
|
|
status, _ = self.connection.select(ordner)
|
|
# Suche mit optionalem Datum-Filter
|
|
if ab_datum:
|
|
datum_str = ab_datum.strftime("%d-%b-%Y")
|
|
if nur_ungelesen:
|
|
search_criteria = f'(UNSEEN SINCE {datum_str})'
|
|
else:
|
|
search_criteria = f'(SINCE {datum_str})'
|
|
else:
|
|
search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL"
|
|
status, messages = self.connection.search(None, search_criteria)
|
|
|
|
if status != "OK":
|
|
continue
|
|
|
|
mail_ids = messages[0].split()
|
|
yield {"type": "mails", "ordner": ordner, "anzahl": len(mail_ids)}
|
|
|
|
for mail_id in mail_ids:
|
|
# Abbruch prüfen bei jeder Mail
|
|
if abbruch_callback and abbruch_callback():
|
|
yield {"type": "abgebrochen", "nachricht": "Abruf wurde abgebrochen"}
|
|
return
|
|
|
|
try:
|
|
status, msg_data = self.connection.fetch(mail_id, "(RFC822)")
|
|
if status != "OK":
|
|
continue
|
|
|
|
msg = email.message_from_bytes(msg_data[0][1])
|
|
message_id = msg.get("Message-ID", "")
|
|
|
|
if message_id and message_id in bereits_verarbeitet:
|
|
continue
|
|
|
|
absender = self._decode_header(msg.get("From", ""))
|
|
betreff = self._decode_header(msg.get("Subject", ""))
|
|
datum = msg.get("Date", "")
|
|
|
|
for part in msg.walk():
|
|
if part.get_content_maintype() == "multipart":
|
|
continue
|
|
|
|
filename = part.get_filename()
|
|
if not filename:
|
|
continue
|
|
|
|
filename = self._decode_header(filename)
|
|
datei_endung = Path(filename).suffix.lower()
|
|
|
|
if datei_endung not in erlaubte_typen:
|
|
continue
|
|
|
|
payload = part.get_payload(decode=True)
|
|
if not payload:
|
|
continue
|
|
|
|
if len(payload) > max_groesse:
|
|
yield {"type": "skip", "datei": filename, "grund": "zu groß"}
|
|
continue
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
safe_filename = self._safe_filename(filename)
|
|
ziel_pfad = ziel / f"{timestamp}_{safe_filename}"
|
|
|
|
counter = 1
|
|
while ziel_pfad.exists():
|
|
ziel_pfad = ziel / f"{timestamp}_{counter}_{safe_filename}"
|
|
counter += 1
|
|
|
|
ziel_pfad.write_bytes(payload)
|
|
|
|
yield {
|
|
"type": "datei",
|
|
"pfad": str(ziel_pfad),
|
|
"original_name": filename,
|
|
"absender": absender,
|
|
"betreff": betreff[:100] if betreff else "",
|
|
"datum": datum,
|
|
"groesse": len(payload),
|
|
"message_id": message_id,
|
|
"ordner": ordner
|
|
}
|
|
|
|
if markiere_gelesen:
|
|
self.connection.store(mail_id, "+FLAGS", "\\Seen")
|
|
|
|
except Exception as e:
|
|
yield {"type": "fehler", "nachricht": f"Mail-Fehler: {str(e)[:100]}"}
|
|
continue
|
|
|
|
except Exception as e:
|
|
yield {"type": "fehler", "nachricht": f"Ordner-Fehler {ordner}: {str(e)[:100]}"}
|
|
|
|
def test_connection(self) -> Dict:
|
|
"""Testet die Verbindung und gibt Status zurück"""
|
|
try:
|
|
if self.connect():
|
|
# Ordner auflisten
|
|
status, folders = self.connection.list()
|
|
ordner_liste = []
|
|
if status == "OK":
|
|
for folder in folders:
|
|
if isinstance(folder, bytes):
|
|
ordner_liste.append(folder.decode())
|
|
self.disconnect()
|
|
return {
|
|
"erfolg": True,
|
|
"nachricht": "Verbindung erfolgreich",
|
|
"ordner": ordner_liste
|
|
}
|
|
else:
|
|
return {
|
|
"erfolg": False,
|
|
"nachricht": "Verbindung fehlgeschlagen"
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"erfolg": False,
|
|
"nachricht": str(e)
|
|
}
|