docker.dateiverwaltung/backend/app/modules/mail_fetcher.py

392 lines
15 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
import logging
from ..config import INBOX_DIR
logger = logging.getLogger(__name__)
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) -> 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
))
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) -> List[Dict]:
"""Holt Attachments aus einem einzelnen Ordner"""
ergebnisse = []
try:
# Ordner auswählen
status, _ = self.connection.select(ordner)
# Suche nach Mails
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):
"""
Generator-Version für Streaming - yielded Events während des Abrufs
Yields:
Dict mit type: "ordner", "mails", "datei", "skip", "fehler"
"""
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:
yield {"type": "ordner", "name": ordner}
try:
status, _ = self.connection.select(ordner)
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:
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)
}