""" 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) }