# -*- coding: utf-8 -*- """ AzA E-Mail - E-Mail Verwaltung für Arztpraxis Mailbird-ähnliche Oberfläche mit IMAP/SMTP Support und KI-Features """ import os import sys import json import tkinter as tk from tkinter import ttk, messagebox, scrolledtext, simpledialog, filedialog from datetime import datetime import threading import imaplib import smtplib import time import email from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.header import decode_header from openai import OpenAI from dotenv import load_dotenv from PIL import Image, ImageDraw, ImageTk import tempfile import wave import io # Konfigurationsdatei CONFIG_FILENAME = "aza_email_config.json" def _config_path(): return os.path.join(os.path.dirname(os.path.abspath(__file__)), CONFIG_FILENAME) def _get_account_password(index: int) -> str: """Lädt E-Mail-Passwort aus ENV (AZA_EMAIL_PASSWORD_0, _1, ...).""" return os.getenv(f"AZA_EMAIL_PASSWORD_{index}", "").strip() def _strip_passwords(accounts: list) -> list: """Entfernt alle Passwort-Felder aus Account-Daten vor dem Speichern.""" cleaned = [] for acc in accounts: acc_copy = {k: v for k, v in acc.items() if k not in ("password", "_password")} cleaned.append(acc_copy) return cleaned def _check_plaintext_migration(accounts: list) -> list: """Prüft ob Klartext-Passwörter in der Config vorhanden sind. Gibt Liste der betroffenen E-Mail-Adressen zurück.""" affected = [] for acc in accounts: if acc.get("password", "").strip(): affected.append(acc.get("email", f"Konto {len(affected)}")) return affected def load_email_config(): """Lädt E-Mail Konfiguration (Fenstergeometrie, Konten). Passwörter werden aus ENV geladen, NICHT aus der JSON-Datei.""" try: path = _config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) plaintext = _check_plaintext_migration(data.get("accounts", [])) if plaintext: print( "SICHERHEITSWARNUNG: Klartext-Passwoerter in " f"{CONFIG_FILENAME} gefunden!\n" "Betroffene Konten: " + ", ".join(plaintext) + "\n" "Bitte entfernen und stattdessen ENV-Variablen setzen:\n" + "\n".join( f" AZA_EMAIL_PASSWORD_{i}" for i in range(len(plaintext)) ) + "\n" "Die Passwoerter in der Datei werden IGNORIERT.", file=sys.stderr, ) for i, acc in enumerate(data.get("accounts", [])): acc.pop("password", None) env_pw = _get_account_password(i) if env_pw: acc["_password"] = env_pw return data except Exception: pass return {} def save_email_config(data): """Speichert E-Mail Konfiguration. Passwörter werden NIE geschrieben.""" try: path = _config_path() safe_data = dict(data) if "accounts" in safe_data: safe_data["accounts"] = _strip_passwords(safe_data["accounts"]) with open(path, "w", encoding="utf-8") as f: json.dump(safe_data, f, indent=2, ensure_ascii=False) except Exception: pass # ========== Textfeld-Schriftgröße mit ▲▼-Pfeilen ========== _FONT_SIZE_SETTINGS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "aza_email_font_sizes.json") def _load_font_sizes(): try: if os.path.isfile(_FONT_SIZE_SETTINGS_FILE): with open(_FONT_SIZE_SETTINGS_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return {} def _save_font_size(key, size): data = _load_font_sizes() data[key] = size try: with open(_FONT_SIZE_SETTINGS_FILE, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) except Exception: pass def add_text_font_size_control(parent_frame, text_widget, initial_size=10, label="Aa", bg_color="#F5FCFF", save_key=None): """▲▼-Pfeile für Textfeld-Schriftgröße (5–20pt), unauffällig im Hintergrund.""" if save_key: saved = _load_font_sizes().get(save_key) if saved is not None: initial_size = int(saved) _size = [max(5, min(20, initial_size))] _fg = "#8AAFC0" _fg_hover = "#1a4d6d" cf = tk.Frame(parent_frame, bg=bg_color, highlightthickness=0, bd=0) cf.pack(side="right", padx=4) tk.Label(cf, text=label, font=("Segoe UI", 8), bg=bg_color, fg=_fg).pack(side="left", padx=(0, 1)) size_lbl = tk.Label(cf, text=str(_size[0]), font=("Segoe UI", 8), bg=bg_color, fg=_fg, width=2, anchor="center") size_lbl.pack(side="left") def _apply(ns): ns = max(5, min(20, ns)) _size[0] = ns size_lbl.configure(text=str(ns)) text_widget.configure(font=("Segoe UI", ns)) if save_key: _save_font_size(save_key, ns) text_widget.configure(font=("Segoe UI", _size[0])) btn_up = tk.Label(cf, text="\u25B2", font=("Segoe UI", 7), bg=bg_color, fg=_fg, cursor="hand2", bd=0, highlightthickness=0) btn_up.pack(side="left", padx=(2, 0)) btn_down = tk.Label(cf, text="\u25BC", font=("Segoe UI", 7), bg=bg_color, fg=_fg, cursor="hand2", bd=0, highlightthickness=0) btn_down.pack(side="left") btn_up.bind("", lambda e: _apply(_size[0] + 1)) btn_down.bind("", lambda e: _apply(_size[0] - 1)) for w in (btn_up, btn_down): w.bind("", lambda e, ww=w: ww.configure(fg=_fg_hover)) w.bind("", lambda e, ww=w: ww.configure(fg=_fg)) return _size # ========================================================== class AudioRecorder: """ Minimaler Recorder mit sounddevice für E-Mail-Diktat. Speichert als 16kHz mono WAV (16-bit PCM). """ def __init__(self, samplerate=16000, channels=1): self.samplerate = samplerate self.channels = channels self._stream = None self._frames = [] self._recording = False def start(self): try: import sounddevice as sd except Exception as e: raise RuntimeError( "Python-Paket 'sounddevice' fehlt.\n\n" "Installiere es mit:\n" " pip install sounddevice\n\n" f"Details: {e}" ) self._frames = [] self._recording = True def callback(indata, frames, time_info, status): if status: pass if self._recording: self._frames.append(indata.copy()) self._stream = sd.InputStream( samplerate=self.samplerate, channels=self.channels, callback=callback, dtype="float32", blocksize=0, ) self._stream.start() def stop_and_save_wav(self) -> str: if not self._stream: raise RuntimeError("Recorder wurde nicht gestartet.") self._recording = False self._stream.stop() self._stream.close() self._stream = None if not self._frames: raise RuntimeError("Keine Audio-Daten aufgenommen (leer).") import numpy as np audio = np.concatenate(self._frames, axis=0) audio = np.clip(audio, -1.0, 1.0) pcm16 = (audio * 32767.0).astype(np.int16) fd, path = tempfile.mkstemp(suffix=".wav", prefix="email_rec_") os.close(fd) with wave.open(path, "wb") as wf: wf.setnchannels(self.channels) wf.setsampwidth(2) wf.setframerate(self.samplerate) wf.writeframes(pcm16.tobytes()) return path class EmailApp: def __init__(self, root): self.root = root self.root.title("AzA E-Mail") self.root.geometry("1200x700") self.root.minsize(800, 500) # Farben wie Mailbird (dunkles Theme) self.bg_dark = "#2C3E50" self.bg_medium = "#34495E" self.bg_light = "#ECF0F1" self.fg_light = "#FFFFFF" self.fg_dark = "#2C3E50" self.accent_blue = "#3498DB" self.accent_hover = "#2980B9" self.root.configure(bg=self.bg_dark) # E-Mail Konten und Nachrichten self.accounts = [] # [{"name": "", "email": "", "imap_server": "", "imap_port": 993, ...}] self.current_account = None self.emails = [] self.current_folder = "inbox" self.selected_email = None # Aktuell ausgewählte E-Mail # E-Mail Cache für schnelleres Laden self.email_cache = {} # {folder_id: [emails...]} self.cache_timestamp = {} # {folder_id: timestamp} self.cache_max_age = 60 # Cache 60 Sekunden gültig # Kontakte für Auto-Complete self.contacts = set() # Set von E-Mail-Adressen self._load_contacts() # Unterschriften-Einstellungen self.signature = "" # HTML/Text-Unterschrift self.signature_auto_reply = False # Auto bei Antworten self.signature_auto_new = True # Auto bei neuen E-Mails # Schriftgrößen-Einstellungen self.font_size_compose = 10 # Schriftgröße für Compose-Fenster self.font_size_list = 10 # Schriftgröße für E-Mail-Liste # Liste aller offenen Compose-Textwidgets für dynamische Updates self.compose_text_widgets = [] # Liste von Text-Widgets # Audio-Recorder für Diktat self.recorder = AudioRecorder() self.is_recording = False # OpenAI Client initialisieren load_dotenv() api_key = os.getenv("OPENAI_API_KEY", "").strip() self.client = OpenAI(api_key=api_key) if api_key else None # Gespeicherte Geometrie und Konten laden config = load_email_config() saved_geom = config.get("geometry", "") if saved_geom: try: self.root.geometry(saved_geom) except: pass self.accounts = config.get("accounts", []) for i, acc in enumerate(self.accounts): if not acc.get("_password"): env_pw = _get_account_password(i) if env_pw: acc["_password"] = env_pw if self.accounts: self.current_account = self.accounts[0] # Unterschrift laden self.signature = config.get("signature", "") self.signature_auto_reply = config.get("signature_auto_reply", False) self.signature_auto_new = config.get("signature_auto_new", True) # Schriftgrößen laden self.font_size_compose = config.get("font_size_compose", 10) self.font_size_list = config.get("font_size_list", 10) self.root.protocol("WM_DELETE_WINDOW", self._on_close) self._build_ui() # E-Mails asynchron laden (verzögert, damit Fenster sofort erscheint) if self.current_account: # Zeige sofort Lade-Status self.status_var.set("⏳ Verbinde mit E-Mail-Server...") # Lade E-Mails nach 100ms (Fenster ist dann sichtbar) self.root.after(100, self._fetch_emails_with_progress) else: self.status_var.set("Kein Konto konfiguriert. Bitte über ⚙ Konten hinzufügen.") def _on_close(self): """Speichert Geometrie, Konten, Unterschrift und Schriftgrößen beim Schließen.""" config = load_email_config() config["geometry"] = self.root.geometry() config["accounts"] = self.accounts config["signature"] = self.signature config["signature_auto_reply"] = self.signature_auto_reply config["signature_auto_new"] = self.signature_auto_new config["font_size_compose"] = self.font_size_compose config["font_size_list"] = self.font_size_list save_email_config(config) self.root.destroy() def _build_ui(self): """Erstellt die Mailbird-ähnliche Oberfläche.""" # Toolbar oben toolbar = tk.Frame(self.root, bg=self.bg_medium, height=50) toolbar.pack(fill="x", side="top") toolbar.pack_propagate(False) # Toolbar Buttons btn_frame = tk.Frame(toolbar, bg=self.bg_medium) btn_frame.pack(side="left", padx=10, pady=5) self._create_toolbar_button(btn_frame, "✉ Neue E-Mail", self._compose_email).pack(side="left", padx=2) self._create_toolbar_button(btn_frame, "↻ Aktualisieren", self._refresh).pack(side="left", padx=2) self._create_toolbar_button(btn_frame, "🗑 Löschen", self._delete_selected).pack(side="left", padx=2) self._create_toolbar_button(btn_frame, "🚫 Als Spam", self._mark_as_spam).pack(side="left", padx=2) # Rechte Seite der Toolbar: Schriftgrößen + KI-Funktionen & Einstellungen btn_frame_right = tk.Frame(toolbar, bg=self.bg_medium) btn_frame_right.pack(side="right", padx=10, pady=5) # Schriftgrößen-Spinboxes font_frame = tk.Frame(toolbar, bg=self.bg_medium) font_frame.pack(side="right", padx=5) # Spinbox für E-Mail-Liste list_font_frame = tk.Frame(font_frame, bg="#E7F9FD", relief="solid", bd=1) list_font_frame.pack(side="left", padx=5) tk.Label(list_font_frame, text="Aa", bg="#E7F9FD", fg=self.fg_dark, font=("Segoe UI", 8)).pack(side="left", padx=(5, 2)) list_spinbox = tk.Spinbox(list_font_frame, from_=5, to=12, width=3, bg="#E7F9FD", relief="flat", bd=0, font=("Segoe UI", 9), justify="center", textvariable=tk.IntVar(value=self.font_size_list), command=self._on_list_font_change) list_spinbox.pack(side="left", padx=(0, 5)) list_spinbox.delete(0, "end") list_spinbox.insert(0, str(self.font_size_list)) list_spinbox.bind("", lambda e: self._on_list_font_change()) list_spinbox.bind("", lambda e: self._on_list_font_change()) self.list_spinbox = list_spinbox # Spinbox für Compose-Text compose_font_frame = tk.Frame(font_frame, bg="#E7F9FD", relief="solid", bd=1) compose_font_frame.pack(side="left", padx=5) tk.Label(compose_font_frame, text="Aa", bg="#E7F9FD", fg=self.fg_dark, font=("Segoe UI", 8)).pack(side="left", padx=(5, 2)) compose_spinbox = tk.Spinbox(compose_font_frame, from_=5, to=12, width=3, bg="#E7F9FD", relief="flat", bd=0, font=("Segoe UI", 9), justify="center", textvariable=tk.IntVar(value=self.font_size_compose), command=self._on_compose_font_change) compose_spinbox.pack(side="left", padx=(0, 5)) compose_spinbox.delete(0, "end") compose_spinbox.insert(0, str(self.font_size_compose)) compose_spinbox.bind("", lambda e: self._on_compose_font_change()) compose_spinbox.bind("", lambda e: self._on_compose_font_change()) self.compose_spinbox = compose_spinbox self._create_toolbar_button(btn_frame_right, "↩ Antworten", self._reply_email).pack(side="left", padx=2) self._create_toolbar_button(btn_frame_right, "🗑 Löschen", self._delete_selected).pack(side="left", padx=2) self._create_toolbar_button(btn_frame_right, "🚫 Als Spam", self._mark_as_spam).pack(side="left", padx=2) self._create_toolbar_button(btn_frame_right, "✅ Kein Spam", self._mark_as_not_spam).pack(side="left", padx=2) self._create_toolbar_button(btn_frame_right, "📝 KI Zusammenfassung", self._ai_summarize).pack(side="left", padx=2) self._create_toolbar_button(btn_frame_right, "💡 KI Antworten", self._ai_reply_suggestions).pack(side="left", padx=2) self._create_toolbar_button(btn_frame_right, "📋 Vorlagen", self._email_templates).pack(side="left", padx=2) self._create_toolbar_button(btn_frame_right, "✍ Unterschrift", self._manage_signature).pack(side="left", padx=2) self._create_toolbar_button(btn_frame_right, "⚙ Konten", self._manage_accounts).pack(side="left", padx=2) # Hauptbereich: 3-Spalten Layout wie Mailbird main_paned = ttk.PanedWindow(self.root, orient="horizontal") main_paned.pack(fill="both", expand=True) # Linke Sidebar (Ordner) left_frame = tk.Frame(main_paned, bg=self.bg_dark, width=200) self._create_sidebar(left_frame) main_paned.add(left_frame, weight=0) # Mittlerer Bereich (E-Mail Liste) middle_frame = tk.Frame(main_paned, bg=self.bg_light, width=350) self._create_email_list(middle_frame) main_paned.add(middle_frame, weight=1) # Rechter Bereich (E-Mail Vorschau) right_frame = tk.Frame(main_paned, bg="#FFFFFF", width=600) self._create_preview(right_frame) main_paned.add(right_frame, weight=2) # Statusleiste unten statusbar = tk.Frame(self.root, bg=self.bg_medium, height=25) statusbar.pack(fill="x", side="bottom") self.status_var = tk.StringVar(value="Bereit.") tk.Label(statusbar, textvariable=self.status_var, bg=self.bg_medium, fg=self.fg_light, anchor="w", padx=10).pack(fill="x") def _create_toolbar_button(self, parent, text, command): """Erstellt einen Toolbar-Button im Mailbird-Stil.""" btn = tk.Button( parent, text=text, command=command, bg=self.accent_blue, fg=self.fg_light, relief="flat", padx=12, pady=6, font=("Segoe UI", 9), cursor="hand2" ) btn.bind("", lambda e: btn.configure(bg=self.accent_hover)) btn.bind("", lambda e: btn.configure(bg=self.accent_blue)) return btn def _on_list_font_change(self): """Handler für Änderung der Listen-Schriftgröße.""" try: new_size = int(self.list_spinbox.get()) if 5 <= new_size <= 12: self.font_size_list = new_size print(f"DEBUG: Schriftgröße Liste geändert auf {new_size}") # Liste neu laden mit neuer Schriftgröße self._refresh_email_list() # WICHTIG: Auch die E-Mail-Vorschau aktualisieren! if hasattr(self, 'preview_text'): try: widget = self.preview_text # ScrolledText ist ein Frame mit Text-Widget innen # Wir müssen das interne Text-Widget finden text_widget = None for child in widget.winfo_children(): if isinstance(child, tk.Text): text_widget = child break # Wenn kein Child gefunden, ist preview_text selbst das Text-Widget if text_widget is None: text_widget = widget # Font ändern text_widget.configure(font=("Segoe UI", self.font_size_list)) text_widget.update_idletasks() print(f"DEBUG: ✓ Preview-Schriftgröße erfolgreich geändert zu {self.font_size_list}") except Exception as e: print(f"DEBUG: ✗ Fehler beim Ändern der Preview-Schrift: {e}") import traceback traceback.print_exc() # Sofort speichern config = load_email_config() config["font_size_list"] = self.font_size_list save_email_config(config) except ValueError as e: print(f"DEBUG: ValueError bei font change: {e}") except Exception as e: print(f"DEBUG: Unerwarteter Fehler: {e}") import traceback traceback.print_exc() def _on_compose_font_change(self): """Handler für Änderung der Compose-Schriftgröße.""" try: new_size = int(self.compose_spinbox.get()) if 5 <= new_size <= 12: self.font_size_compose = new_size # Sofort speichern config = load_email_config() config["font_size_compose"] = self.font_size_compose save_email_config(config) # WICHTIG: Aktualisiere ALLE offenen Compose-Fenster sofort! for text_widget in self.compose_text_widgets[:]: # Kopie der Liste try: if text_widget.winfo_exists(): # Ändere Schriftgröße text_widget.configure(font=("Segoe UI", self.font_size_compose)) else: # Widget existiert nicht mehr, aus Liste entfernen self.compose_text_widgets.remove(text_widget) except: # Bei Fehler aus Liste entfernen if text_widget in self.compose_text_widgets: self.compose_text_widgets.remove(text_widget) except ValueError: pass def _create_sidebar(self, parent): """Erstellt die linke Sidebar mit Ordnern.""" tk.Label(parent, text="Ordner", bg=self.bg_dark, fg=self.fg_light, font=("Segoe UI", 11, "bold"), anchor="w", padx=10, pady=10).pack(fill="x") # Ordner-Liste folders = [ ("📥 Posteingang", "inbox", "INBOX"), ("📤 Gesendet", "sent", "Sent"), ("🚫 Spam", "spam", "Junk"), ("✏ Entwürfe", "drafts", "Drafts"), ("🗑 Papierkorb", "trash", "Trash"), ] self.folder_buttons = [] self.folder_mapping = {} # folder_id -> IMAP folder name for label, folder_id, imap_folder in folders: self.folder_mapping[folder_id] = imap_folder btn = tk.Button( parent, text=label, anchor="w", bg=self.bg_dark, fg=self.fg_light, relief="flat", padx=15, pady=10, font=("Segoe UI", 10), cursor="hand2", command=lambda f=folder_id: self._select_folder(f) ) btn.pack(fill="x") btn.bind("", lambda e, b=btn: b.configure(bg=self.bg_medium)) btn.bind("", lambda e, b=btn: b.configure(bg=self.bg_dark)) self.folder_buttons.append((btn, folder_id)) def _create_email_list(self, parent): """Erstellt die mittlere E-Mail-Liste im Mailbird-Stil.""" # Header header = tk.Frame(parent, bg="#FFFFFF", height=50) header.pack(fill="x") header.pack_propagate(False) self.email_list_header = tk.Label(header, text="Posteingang", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 14, "bold"), anchor="w", padx=15) self.email_list_header.pack(fill="both", expand=True) # E-Mail Container mit Canvas für Scrolling self.email_list_container = tk.Frame(parent, bg="#F5F5F5") self.email_list_container.pack(fill="both", expand=True) self.email_canvas = tk.Canvas(self.email_list_container, bg="#F5F5F5", highlightthickness=0) scrollbar = tk.Scrollbar(self.email_list_container, orient="vertical", command=self.email_canvas.yview) self.email_scrollable_frame = tk.Frame(self.email_canvas, bg="#F5F5F5") self.email_scrollable_frame.bind( "", lambda e: self.email_canvas.configure(scrollregion=self.email_canvas.bbox("all")) ) self.email_canvas_frame = self.email_canvas.create_window((0, 0), window=self.email_scrollable_frame, anchor="nw") self.email_canvas.configure(yscrollcommand=scrollbar.set) self.email_canvas.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") # Mausrad-Scrolling aktivieren - EINFACHE und ROBUSTE Methode! def _on_mousewheel(event): # Windows self.email_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") def _on_mousewheel_linux_up(event): # Linux scroll up self.email_canvas.yview_scroll(-1, "units") def _on_mousewheel_linux_down(event): # Linux scroll down self.email_canvas.yview_scroll(1, "units") # Bind für Canvas UND scrollable_frame UND root (damit es überall funktioniert!) self.email_canvas.bind("", _on_mousewheel) self.email_canvas.bind("", _on_mousewheel_linux_up) self.email_canvas.bind("", _on_mousewheel_linux_down) self.email_scrollable_frame.bind("", _on_mousewheel) self.email_scrollable_frame.bind("", _on_mousewheel_linux_up) self.email_scrollable_frame.bind("", _on_mousewheel_linux_down) # Speichere Callbacks für späteres Binden neuer Widgets self._mousewheel_callback = _on_mousewheel self._mousewheel_linux_up_callback = _on_mousewheel_linux_up self._mousewheel_linux_down_callback = _on_mousewheel_linux_down # Canvas-Größe anpassen, wenn Parent sich ändert self.email_canvas.bind("", self._on_canvas_configure) self.email_item_frames = [] def _on_canvas_configure(self, event): """Passt die Canvas-Breite an.""" self.email_canvas.itemconfig(self.email_canvas_frame, width=event.width) def _get_initials(self, name_or_email): """Extrahiert Initialen aus Name oder E-Mail.""" # Entferne E-Mail-Adresse in Klammern name = name_or_email.split('<')[0].strip() if not name or '@' in name: # Falls nur E-Mail: erste 2 Buchstaben email_part = name_or_email.split('@')[0] if '@' in name_or_email else name_or_email return email_part[:2].upper() # Extrahiere Initialen aus Namen parts = name.split() if len(parts) >= 2: return (parts[0][0] + parts[-1][0]).upper() elif len(parts) == 1 and len(parts[0]) >= 2: return parts[0][:2].upper() return "??" def _get_color_from_name(self, name_or_email): """Generiert eine konsistente Pastellfarbe basierend auf dem Namen.""" # Pastellfarben (heller und weniger gesättigt) colors = [ "#A8D5BA", # Pastellgrün "#FFB3C6", # Pastellpink "#C5A3D9", # Pastelllila "#FFD4A3", # Pfirsich "#A3C4F3", # Pastellblau "#FFB5B5", # Hellrot "#A3E4F0", # Hellcyan "#E8F48C", # Helllime "#C4A57B", # Hellbraun "#B0BEC5", # Hellblaugrau ] # Hash des Namens für konsistente Farbwahl hash_value = sum(ord(c) for c in name_or_email) return colors[hash_value % len(colors)] def _format_date(self, date_str): """Formatiert Datum im Mailbird-Stil (11:05, Gestern, Montag, etc.).""" try: from email.utils import parsedate_to_datetime dt = parsedate_to_datetime(date_str) now = datetime.now(dt.tzinfo) # Differenz in Tagen diff_days = (now.date() - dt.date()).days if diff_days == 0: # Heute: Uhrzeit anzeigen return dt.strftime("%H:%M") elif diff_days == 1: return "Gestern" elif diff_days <= 6: # Diese Woche: Wochentag weekdays = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] return weekdays[dt.weekday()] else: # Älter: Datum return dt.strftime("%d.%m.%Y") except: # Fallback return date_str[:10] if len(date_str) > 10 else date_str def _refresh_email_list(self): """Aktualisiert die E-Mail-Liste im Mailbird-Stil.""" print(f"DEBUG: _refresh_email_list aufgerufen. self.emails hat {len(self.emails)} Einträge") # Lösche alte E-Mail-Items (ALLE Kinder des Frames!) for frame in self.email_item_frames: frame.destroy() self.email_item_frames = [] # WICHTIG: Lösche auch alle anderen Widgets im scrollable_frame for widget in self.email_scrollable_frame.winfo_children(): widget.destroy() print(f"DEBUG: Alle alten Widgets gelöscht") # Wenn keine E-Mails, zeige Lade-Indikator if not self.emails: print(f"DEBUG: self.emails ist leer, zeige Lade-Indikator") loading_frame = tk.Frame(self.email_scrollable_frame, bg="#F5F5F5") loading_frame.pack(fill="both", expand=True, pady=50) loading_label = tk.Label(loading_frame, text="⏳ Lädt E-Mails...", bg="#F5F5F5", fg="#666666", font=("Segoe UI", 12)) loading_label.pack() self.email_item_frames.append(loading_frame) return print(f"DEBUG: Erstelle {len(self.emails)} E-Mail-Items...") for idx, email in enumerate(self.emails): self._create_email_item(email, idx) # WICHTIG: Mausrad-Scrolling für alle neuen Items aktivieren! def bind_mousewheel_recursive(widget): """Bindet Mausrad-Events rekursiv für alle Kinder.""" if hasattr(self, '_mousewheel_callback'): widget.bind("", self._mousewheel_callback) widget.bind("", self._mousewheel_linux_up_callback) widget.bind("", self._mousewheel_linux_down_callback) for child in widget.winfo_children(): bind_mousewheel_recursive(child) # Binde Mausrad für alle neuen Items bind_mousewheel_recursive(self.email_scrollable_frame) print(f"DEBUG: Mausrad-Scrolling für neue Items aktiviert") # WICHTIG: Canvas-Scrollregion nach Erstellen aller Items aktualisieren! self.email_canvas.update_idletasks() self.email_canvas.configure(scrollregion=self.email_canvas.bbox("all")) print(f"DEBUG: Canvas-Scrollregion aktualisiert") print(f"DEBUG: E-Mail-Items erstellt, zeige erste E-Mail in Vorschau") # WICHTIG: Zeige die erste E-Mail automatisch in der Vorschau if len(self.emails) > 0: self._show_email_preview(0) def _create_email_item(self, email, idx): """Erstellt ein einzelnes E-Mail-Item im Mailbird-Stil.""" print(f"DEBUG: Erstelle E-Mail-Item {idx}: Von='{email.get('from', '')[:30]}', Betreff='{email.get('subject', '')[:30]}'") # Container für E-Mail item_frame = tk.Frame(self.email_scrollable_frame, bg="#FFFFFF", relief="flat", bd=0) item_frame.pack(fill="x", padx=5, pady=2) print(f"DEBUG: item_frame erstellt und gepackt für E-Mail {idx}") # Hover-Effekt def on_enter(e): item_frame.configure(bg="#F0F0F0") def on_leave(e): item_frame.configure(bg="#FFFFFF") def on_click(e): self._show_email_preview(idx) item_frame.bind("", on_enter) item_frame.bind("", on_leave) item_frame.bind("", on_click) # Haupt-Container main_container = tk.Frame(item_frame, bg="#FFFFFF") main_container.pack(fill="x", padx=10, pady=8) # Linker Bereich: Avatar-Kreis left_frame = tk.Frame(main_container, bg="#FFFFFF") left_frame.pack(side="left", padx=(0, 10)) # Erstelle perfekt runden Kreis mit PIL (Anti-Aliasing) initials = self._get_initials(email['from']) base_color = self._get_color_from_name(email['from']) # Konvertiere Hex zu RGB für Opazität def hex_to_rgb(hex_color): hex_color = hex_color.lstrip('#') return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) def rgb_to_hex(rgb): return '#{:02x}{:02x}{:02x}'.format(int(rgb[0]), int(rgb[1]), int(rgb[2])) # 70% Opazität durch Mischung mit Weiß rgb = hex_to_rgb(base_color) white = (255, 255, 255) opacity = 0.7 blended_rgb = tuple(int(rgb[i] * opacity + white[i] * (1 - opacity)) for i in range(3)) # Erstelle hochauflösendes Bild für perfekten Kreis size = 80 # Höhere Auflösung für Anti-Aliasing img = Image.new('RGBA', (size, size), (255, 255, 255, 0)) draw = ImageDraw.Draw(img) # Zeichne perfekt runden Kreis mit Anti-Aliasing draw.ellipse([0, 0, size-1, size-1], fill=blended_rgb + (255,), outline=None) # Skaliere auf finale Größe (macht Kanten glatt) final_size = 40 img = img.resize((final_size, final_size), Image.Resampling.LANCZOS) # Konvertiere zu PhotoImage photo = ImageTk.PhotoImage(img) # Label für Avatar-Bild avatar_label = tk.Label(left_frame, image=photo, bg="#FFFFFF") avatar_label.image = photo # Referenz behalten avatar_label.pack() # Text (Initialen) über dem Bild initial_label = tk.Label(left_frame, text=initials, bg=rgb_to_hex(blended_rgb), fg="#555555", font=("Segoe UI", 11, "bold")) initial_label.place(in_=avatar_label, relx=0.5, rely=0.5, anchor="center") avatar_label.bind("", on_click) initial_label.bind("", on_click) # Mittlerer Bereich: Name, Betreff, Vorschau middle_frame = tk.Frame(main_container, bg="#FFFFFF") middle_frame.pack(side="left", fill="both", expand=True) # Name und Uhrzeit (obere Zeile) top_row = tk.Frame(middle_frame, bg="#FFFFFF") top_row.pack(fill="x") # Name (fett) - verwendet font_size_list name_only = email['from'].split('<')[0].strip() if not name_only: name_only = email['from'].split('@')[0] name_label = tk.Label(top_row, text=name_only, bg="#FFFFFF", fg="#1F1F1F", font=("Segoe UI", self.font_size_list, "bold"), anchor="w") name_label.pack(side="left", fill="x", expand=True) name_label.bind("", on_click) # Anhang-Symbol VOR dem Datum (rechts) - BILD-ICON statt Emoji if email.get('has_attachments', False): try: # Lade das Büroklammer-Bild paperclip_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "paperclip.png") if os.path.exists(paperclip_path): from PIL import Image as PILImage img_clip = PILImage.open(paperclip_path) # Größe basierend auf font_size icon_size = self.font_size_list + 8 img_clip = img_clip.resize((icon_size, icon_size), PILImage.Resampling.LANCZOS) photo_clip = ImageTk.PhotoImage(img_clip) attachment_icon = tk.Label(top_row, image=photo_clip, bg="#FFFFFF", cursor="hand2") attachment_icon.image = photo_clip # Referenz behalten! attachment_icon.pack(side="right", padx=(5, 5)) attachment_icon.bind("", on_click) else: # Fallback auf Emoji attachment_icon = tk.Label(top_row, text="📎", bg="#FFFFFF", fg="#555555", font=("Segoe UI", self.font_size_list + 3, "bold")) attachment_icon.pack(side="right", padx=(5, 5)) attachment_icon.bind("", on_click) except Exception as e: print(f"DEBUG: Büroklammer-Icon Fehler: {e}") # Fallback auf Emoji bei Fehler attachment_icon = tk.Label(top_row, text="📎", bg="#FFFFFF", fg="#555555", font=("Segoe UI", self.font_size_list + 3, "bold")) attachment_icon.pack(side="right", padx=(5, 5)) attachment_icon.bind("", on_click) # Zeit/Datum (rechts) - verwendet font_size_list - 1 date_formatted = self._format_date(email['date']) date_label = tk.Label(top_row, text=date_formatted, bg="#FFFFFF", fg="#666666", font=("Segoe UI", max(self.font_size_list - 1, 5)), anchor="e") date_label.pack(side="right") date_label.bind("", on_click) # Betreff (zweite Zeile) - verwendet font_size_list - 1 subject_text = email['subject'] if len(subject_text) > 50: subject_text = subject_text[:50] + "..." subject_label = tk.Label(middle_frame, text=subject_text, bg="#FFFFFF", fg="#333333", font=("Segoe UI", max(self.font_size_list - 1, 5)), anchor="w") subject_label.pack(fill="x", pady=(2, 0)) subject_label.bind("", on_click) # Dünne Trennlinie separator = tk.Frame(self.email_scrollable_frame, bg="#E0E0E0", height=1) separator.pack(fill="x", padx=15) self.email_item_frames.append(item_frame) def _show_email_preview(self, idx): """Zeigt die E-Mail-Vorschau an.""" if idx >= len(self.emails): return email = self.emails[idx] self.selected_email = email # Speichere aktuell ausgewählte E-Mail # Vorschau aktualisieren self.preview_subject.configure(text=email["subject"]) self.preview_from.configure(text=f"Von: {email['from']}") self.preview_date.configure(text=f"Datum: {email['date']}") # Text ist jetzt immer "normal" (markierbar), nur Inhalt ändern self.preview_text.delete("1.0", "end") # Text formatieren (Visitenkarten-freundlich) formatted_body = self._format_email_body(email["body"]) self.preview_text.insert("1.0", formatted_body) # NICHT mehr auf "disabled" setzen - bleibt "normal" für Markieren & Kopieren! # Anhänge anzeigen (falls vorhanden) for widget in self.preview_attachments_frame.winfo_children(): widget.destroy() attachments = email.get("attachments", []) if attachments: tk.Label(self.preview_attachments_frame, text=f"📎 {len(attachments)} Anhang(e):", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10, "bold"), anchor="w").pack(fill="x", pady=(5, 5)) for att in attachments: att_frame = tk.Frame(self.preview_attachments_frame, bg="#F0F0F0", relief="solid", bd=1) att_frame.pack(fill="x", pady=2) # Icon basierend auf Dateityp if att.get('is_image', False): icon = "🖼️" elif att['content_type'].startswith('application/pdf'): icon = "📄" elif 'word' in att['content_type'] or att['filename'].endswith('.docx'): icon = "📝" elif 'excel' in att['content_type'] or att['filename'].endswith('.xlsx'): icon = "📊" else: icon = "📎" info_label = tk.Label(att_frame, text=f"{icon} {att['filename']} ({att['size']})", bg="#F0F0F0", fg=self.fg_dark, font=("Segoe UI", 9), anchor="w") info_label.pack(side="left", padx=10, pady=5, fill="x", expand=True) # Bild-Vorschau anzeigen (falls Bild) if att.get('is_image', False): try: from PIL import Image import io # Bild aus Payload laden img = Image.open(io.BytesIO(att['payload'])) # Thumbnail erstellen (max 200x150) img.thumbnail((200, 150), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(img) # Bild in neuem Frame anzeigen img_label = tk.Label(att_frame, image=photo, bg="#F0F0F0") img_label.image = photo # Referenz behalten! img_label.pack(side="left", padx=5) except: pass # Bild konnte nicht geladen werden download_btn = tk.Button(att_frame, text="💾 Download", command=lambda a=att: self._download_attachment(a), bg="#3498DB", fg="#FFFFFF", font=("Segoe UI", 8), relief="flat", padx=10, pady=3, cursor="hand2") download_btn.pack(side="right", padx=5, pady=2) def _create_preview(self, parent): """Erstellt die rechte E-Mail-Vorschau.""" # Header header_frame = tk.Frame(parent, bg="#F8F9FA", height=100) header_frame.pack(fill="x") header_frame.pack_propagate(False) info_frame = tk.Frame(header_frame, bg="#F8F9FA") info_frame.pack(fill="both", expand=True, padx=20, pady=15) self.preview_subject = tk.Label( info_frame, text="Betreff", bg="#F8F9FA", fg=self.fg_dark, font=("Segoe UI", 14, "bold"), anchor="w" ) self.preview_subject.pack(fill="x") self.preview_from = tk.Label( info_frame, text="Von:", bg="#F8F9FA", fg="#7F8C8D", font=("Segoe UI", 9), anchor="w" ) self.preview_from.pack(fill="x", pady=(5, 0)) self.preview_date = tk.Label( info_frame, text="Datum:", bg="#F8F9FA", fg="#7F8C8D", font=("Segoe UI", 9), anchor="w" ) self.preview_date.pack(fill="x") # Trennlinie tk.Frame(parent, bg="#BDC3C7", height=1).pack(fill="x") # Anhänge-Bereich (UNTER dem Header, VOR dem Text) self.preview_attachments_frame = tk.Frame(parent, bg="#FFFFFF") self.preview_attachments_frame.pack(fill="x", padx=20, pady=(10, 0)) # E-Mail Inhalt - TEXT MARKIERBAR & KOPIERBAR! content_frame = tk.Frame(parent, bg="#FFFFFF") content_frame.pack(fill="both", expand=True, padx=20, pady=20) preview_header = tk.Frame(content_frame, bg="#FFFFFF") preview_header.pack(fill="x", anchor="w") self.preview_text = scrolledtext.ScrolledText( content_frame, wrap="word", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", self.font_size_list), relief="flat", bd=0, state="normal", cursor="arrow" ) self.preview_text.pack(fill="both", expand=True) add_text_font_size_control(preview_header, self.preview_text, initial_size=self.font_size_list, bg_color="#FFFFFF", save_key="email_preview") # Nur Schreibschutz, aber Markieren & Kopieren erlauben # Bindings für Kopieren (Strg+C funktioniert automatisch!) self.preview_text.bind("", lambda e: "break") # Verhindert Cursor-Setzen self.preview_text.bind("", lambda e: "break" if e.keysym not in ["c", "C", "a", "A"] and not e.state & 0x4 else None) # Kontextmenü für Kopieren preview_menu = tk.Menu(self.preview_text, tearoff=0) preview_menu.add_command(label="Kopieren", command=lambda: self._copy_preview_text(), accelerator="Strg+C") preview_menu.add_command(label="Alles markieren", command=lambda: self.preview_text.tag_add("sel", "1.0", "end"), accelerator="Strg+A") def show_preview_menu(event): try: preview_menu.tk_popup(event.x_root, event.y_root) finally: preview_menu.grab_release() self.preview_text.bind("", show_preview_menu) # Rechtsklick def _copy_preview_text(self): """Kopiert den markierten Text aus der Vorschau.""" try: selected_text = self.preview_text.get("sel.first", "sel.last") self.root.clipboard_clear() self.root.clipboard_append(selected_text) except: pass # Nichts markiert def _load_contacts(self): """Lädt gespeicherte Kontakte aus Datei.""" try: contacts_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "aza_email_contacts.json") if os.path.isfile(contacts_file): with open(contacts_file, "r", encoding="utf-8") as f: data = json.load(f) self.contacts = set(data.get("contacts", [])) except: self.contacts = set() def _save_contacts(self): """Speichert Kontakte in Datei.""" try: contacts_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "aza_email_contacts.json") with open(contacts_file, "w", encoding="utf-8") as f: json.dump({"contacts": list(self.contacts)}, f, indent=2, ensure_ascii=False) except Exception as e: print(f"DEBUG: Fehler beim Speichern von Kontakten: {e}") def _add_contact(self, email): """Fügt E-Mail-Adresse zu Kontakten hinzu.""" if email and "@" in email: email = email.strip().lower() self.contacts.add(email) self._save_contacts() def _compose_email(self): """Öffnet ein Fenster zum Verfassen einer neuen E-Mail.""" compose_win = tk.Toplevel(self.root) compose_win.title("Neue E-Mail") compose_win.geometry("700x500") compose_win.configure(bg="#FFFFFF") # Formular form_frame = tk.Frame(compose_win, bg="#FFFFFF", padx=20, pady=20) form_frame.pack(fill="both", expand=True) # An tk.Label(form_frame, text="An:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=0, column=0, sticky="w", pady=5) self.compose_to_entry = tk.Entry(form_frame, font=("Segoe UI", 10), relief="solid", bd=1) self.compose_to_entry.grid(row=0, column=1, sticky="ew", pady=5) # Auto-Complete Listbox für E-Mail-Adressen autocomplete_listbox = None autocomplete_window = None def show_autocomplete(event=None): """Zeigt Auto-Complete Vorschläge.""" nonlocal autocomplete_listbox, autocomplete_window typed = self.compose_to_entry.get().strip().lower() if len(typed) < 2: hide_autocomplete() return # Finde passende Kontakte matches = [c for c in self.contacts if typed in c] if not matches: hide_autocomplete() return # Erstelle/Update Autocomplete-Fenster if not autocomplete_window: autocomplete_window = tk.Toplevel(compose_win) autocomplete_window.wm_overrideredirect(True) autocomplete_window.attributes('-topmost', True) autocomplete_listbox = tk.Listbox( autocomplete_window, font=("Segoe UI", 10), height=min(8, len(matches)), # Größer: 8 statt 5 width=50, # Breiter! relief="solid", bd=1 ) autocomplete_listbox.pack(fill="both", expand=True) def select_contact(event=None): try: if autocomplete_listbox.curselection(): selected = autocomplete_listbox.get(autocomplete_listbox.curselection()[0]) self.compose_to_entry.delete(0, "end") self.compose_to_entry.insert(0, selected) self.compose_to_entry.icursor("end") hide_autocomplete() self.compose_to_entry.focus_set() except Exception as e: print(f"DEBUG: Fehler bei select_contact: {e}") def on_listbox_click(event): """Bei Klick auf Item in Listbox.""" try: # Finde geklicktes Item index = autocomplete_listbox.nearest(event.y) autocomplete_listbox.selection_clear(0, "end") autocomplete_listbox.selection_set(index) autocomplete_listbox.activate(index) # Warte kurz, dann wähle aus compose_win.after(50, select_contact) except Exception as e: print(f"DEBUG: Fehler bei on_listbox_click: {e}") autocomplete_listbox.bind("", on_listbox_click) autocomplete_listbox.bind("", lambda e: select_contact()) autocomplete_listbox.bind("", select_contact) # Update Position und Größe x = self.compose_to_entry.winfo_rootx() y = self.compose_to_entry.winfo_rooty() + self.compose_to_entry.winfo_height() w = max(self.compose_to_entry.winfo_width(), 400) # Mindestens 400px breit! h = min(200, len(matches)*25 + 10) # Höher: 25px pro Item autocomplete_window.geometry(f"{w}x{h}+{x}+{y}") # Update Liste autocomplete_listbox.delete(0, "end") for match in matches[:10]: # Max 10 Vorschläge autocomplete_listbox.insert("end", match) def hide_autocomplete(event=None): """Versteckt Auto-Complete.""" nonlocal autocomplete_window if autocomplete_window: try: autocomplete_window.destroy() except: pass autocomplete_window = None def on_key_release(event): """Bei Tastendruck Auto-Complete aktualisieren.""" if event.keysym in ["Up", "Down", "Return", "Escape"]: return show_autocomplete() def on_focus_out(event): """Bei Fokus-Verlust Auto-Complete verstecken.""" # Verzögerung, damit Klick auf Listbox noch funktioniert compose_win.after(200, hide_autocomplete) self.compose_to_entry.bind("", on_key_release) self.compose_to_entry.bind("", on_focus_out) # Betreff tk.Label(form_frame, text="Betreff:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=1, column=0, sticky="w", pady=5) self.compose_subject_entry = tk.Entry(form_frame, font=("Segoe UI", 10), relief="solid", bd=1) self.compose_subject_entry.grid(row=1, column=1, sticky="ew", pady=5) # Nachricht tk.Label(form_frame, text="Nachricht:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=2, column=0, sticky="nw", pady=5) text_frame = tk.Frame(form_frame, bg="#FFFFFF") text_frame.grid(row=2, column=1, sticky="nsew", pady=5) compose_header = tk.Frame(text_frame, bg="#FFFFFF") compose_header.pack(fill="x", anchor="w") self.compose_body_text = scrolledtext.ScrolledText( text_frame, wrap="word", font=("Segoe UI", self.font_size_compose), relief="solid", bd=1, height=15 ) self.compose_body_text.pack(fill="both", expand=True) add_text_font_size_control(compose_header, self.compose_body_text, initial_size=self.font_size_compose, bg_color="#FFFFFF", save_key="email_compose") # Unterschrift automatisch einfügen bei neuen E-Mails if self.signature_auto_new and self.signature: self.compose_body_text.insert("end", "\n\n" + self.signature) # WICHTIG: Widget zur Liste hinzufügen für dynamische Updates self.compose_text_widgets.append(self.compose_body_text) # Beim Schließen des Fensters aus Liste entfernen def on_close(): if self.compose_body_text in self.compose_text_widgets: self.compose_text_widgets.remove(self.compose_body_text) compose_win.destroy() form_frame.columnconfigure(1, weight=1) form_frame.rowconfigure(2, weight=1) # Buttons btn_frame = tk.Frame(compose_win, bg="#FFFFFF", pady=10) btn_frame.pack(fill="x", padx=20) def insert_signature(): """Fügt Unterschrift am Ende ein.""" if self.signature: current_text = self.compose_body_text.get("1.0", "end-1c") if not current_text.endswith(self.signature): self.compose_body_text.insert("end", "\n\n" + self.signature) else: messagebox.showinfo("Keine Unterschrift", "Bitte erstellen Sie zuerst eine Unterschrift über den Button '✍ Unterschrift'.", parent=compose_win) def start_dictation(): """Startet Diktat für E-Mail.""" try: import sounddevice as sd except: messagebox.showerror("Fehler", "sounddevice nicht installiert.\n\nBitte installieren:\npip install sounddevice", parent=compose_win) return # Recorder als Instanz-Variable speichern if not hasattr(start_dictation, 'recorder'): start_dictation.recorder = None start_dictation.recording = False if not start_dictation.recording: # Start recording try: print("DEBUG: Starte Aufnahme...") start_dictation.recorder = AudioRecorder() start_dictation.recorder.start() start_dictation.recording = True dict_btn.config(text="⏹ Stopp", bg="#E74C3C") self.status_var.set("🎤 Aufnahme läuft...") print("DEBUG: Aufnahme gestartet") except Exception as e: print(f"DEBUG: Fehler beim Starten: {e}") messagebox.showerror("Fehler", f"Aufnahme fehlgeschlagen:\n{str(e)}", parent=compose_win) else: # Stop recording print("DEBUG: Stoppe Aufnahme...") dict_btn.config(state="disabled", text="⏳ Transkribiere...") self.status_var.set("⏳ Transkribiere Audio...") def transcribe_worker(): try: wav_path = start_dictation.recorder.stop_and_save_wav() start_dictation.recording = False print(f"DEBUG: Audio gespeichert: {wav_path}") print(f"DEBUG: Starte Transkription...") # Transkription load_dotenv() client = OpenAI() with open(wav_path, "rb") as audio_file: transcript = client.audio.transcriptions.create( model="gpt-4o-mini-transcribe", file=audio_file, language="de" ) text = transcript.text print(f"DEBUG: Transkription erfolgreich: {text[:50]}...") # Text einfügen compose_win.after(0, lambda: self.compose_body_text.insert("insert", text + " ")) # Cleanup try: os.remove(wav_path) except: pass compose_win.after(0, lambda: self.status_var.set("✓ Transkription eingefügt")) compose_win.after(0, lambda: dict_btn.config(state="normal", text="🎤 Diktat starten", bg="#3498DB")) except Exception as e: error_msg = str(e) print(f"DEBUG: FEHLER bei Transkription: {error_msg}") import traceback traceback.print_exc() compose_win.after(0, lambda msg=error_msg: messagebox.showerror("Fehler", f"Transkription fehlgeschlagen:\n{msg}", parent=compose_win)) compose_win.after(0, lambda: dict_btn.config(state="normal", text="🎤 Diktat starten", bg="#3498DB")) compose_win.after(0, lambda: self.status_var.set("❌ Transkription fehlgeschlagen")) start_dictation.recording = False threading.Thread(target=transcribe_worker, daemon=True).start() def ai_format(): """KI formatiert den Text.""" text = self.compose_body_text.get("1.0", "end-1c").strip() if not text: messagebox.showinfo("Hinweis", "Bitte geben Sie zuerst Text ein.", parent=compose_win) return ai_btn.config(state="disabled", text="⏳ KI formatiert...") def worker(): try: load_dotenv() client = OpenAI() response = client.chat.completions.create( model="gpt-4o-mini", messages=[{ "role": "system", "content": "Formatiere den folgenden E-Mail-Text professionell: Korrigiere Grammatik, Rechtschreibung und Interpunktion. Behalte den Inhalt bei, mache ihn aber gut lesbar und professionell." }, { "role": "user", "content": text }] ) formatted = response.choices[0].message.content compose_win.after(0, lambda: self.compose_body_text.delete("1.0", "end")) compose_win.after(0, lambda: self.compose_body_text.insert("1.0", formatted)) compose_win.after(0, lambda: self.status_var.set("✓ Text von KI formatiert")) compose_win.after(0, lambda: ai_btn.config(state="normal", text="🤖 KI formatiert")) except Exception as e: error_msg = str(e) compose_win.after(0, lambda msg=error_msg: messagebox.showerror("Fehler", f"KI-Formatierung fehlgeschlagen:\n{msg}", parent=compose_win)) compose_win.after(0, lambda: ai_btn.config(state="normal", text="🤖 KI formatiert")) threading.Thread(target=worker, daemon=True).start() def change_font_size(delta): """Ändert Schriftgröße.""" current_font = self.compose_body_text.cget("font") if isinstance(current_font, str): parts = current_font.split() if len(parts) >= 2: try: size = int(parts[1]) + delta size = max(8, min(24, size)) # 8-24 Bereich self.compose_body_text.configure(font=(parts[0], size)) size_label.config(text=f"{size}") except: pass # Linke Seite: Hauptbuttons left_frame = tk.Frame(btn_frame, bg="#FFFFFF") left_frame.pack(side="left", fill="x", expand=True) send_btn = tk.Button( left_frame, text="📨 Senden", command=lambda: self._send_email(compose_win), bg=self.accent_blue, fg=self.fg_light, font=("Segoe UI", 10), relief="flat", padx=20, pady=8, cursor="hand2" ) send_btn.pack(side="left", padx=5) # Speichere send_btn für später self._current_send_btn = send_btn dict_btn = tk.Button( left_frame, text="🎤 Diktat starten", command=start_dictation, bg="#3498DB", fg=self.fg_light, font=("Segoe UI", 10), relief="flat", padx=15, pady=8, cursor="hand2" ) dict_btn.pack(side="left", padx=5) ai_btn = tk.Button( left_frame, text="🤖 KI formatiert", command=ai_format, bg="#9B59B6", fg=self.fg_light, font=("Segoe UI", 10), relief="flat", padx=15, pady=8, cursor="hand2" ) ai_btn.pack(side="left", padx=5) sig_btn = tk.Button( left_frame, text="✍ Unterschrift", command=insert_signature, bg="#27AE60", fg=self.fg_light, font=("Segoe UI", 10), relief="flat", padx=15, pady=8, cursor="hand2" ) sig_btn.pack(side="left", padx=5) # Rechte Seite: Schriftgröße right_frame = tk.Frame(btn_frame, bg="#FFFFFF") right_frame.pack(side="right") tk.Label(right_frame, text="Größe:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 9)).pack(side="left", padx=5) tk.Button(right_frame, text="−", command=lambda: change_font_size(-1), bg="#ECF0F1", fg=self.fg_dark, font=("Segoe UI", 10, "bold"), relief="flat", width=2, cursor="hand2").pack(side="left", padx=2) size_label = tk.Label(right_frame, text=str(self.font_size_compose), bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10), width=3) size_label.pack(side="left", padx=2) tk.Button(right_frame, text="+", command=lambda: change_font_size(+1), bg="#ECF0F1", fg=self.fg_dark, font=("Segoe UI", 10, "bold"), relief="flat", width=2, cursor="hand2").pack(side="left", padx=2) cancel_btn = tk.Button( btn_frame, text="Abbrechen", command=on_close, bg="#95A5A6", fg=self.fg_light, font=("Segoe UI", 10), relief="flat", padx=20, pady=8, cursor="hand2" ) cancel_btn.pack(side="right", padx=5) # Auch beim X-Button das Widget entfernen compose_win.protocol("WM_DELETE_WINDOW", on_close) def _send_email(self, window): """Sendet E-Mail via SMTP.""" if not self.current_account: messagebox.showerror("Fehler", "Kein Konto konfiguriert.", parent=window) return to_email = self.compose_to_entry.get().strip() subject = self.compose_subject_entry.get().strip() body = self.compose_body_text.get("1.0", "end-1c").strip() if not to_email: messagebox.showerror("Fehler", "Empfänger ist erforderlich.", parent=window) return # Betreff ist optional - wenn leer, verwende "(Kein Betreff)" if not subject: subject = "(Kein Betreff)" print(f"DEBUG: Starte E-Mail-Versand an {to_email}") # Disable Send-Button if hasattr(self, '_current_send_btn'): self._current_send_btn.config(state="disabled", text="📨 Sende...") self.status_var.set("📨 Sende E-Mail...") def worker(): try: acc = self.current_account smtp_server = acc.get("smtp_server", "") smtp_port = acc.get("smtp_port", 587) email_addr = acc.get("email", "") password = acc.get("_password", "") print(f"DEBUG: SMTP Connect zu {smtp_server}:{smtp_port}") # SMTP Verbindung server = smtplib.SMTP(smtp_server, smtp_port, timeout=30) server.set_debuglevel(1) # Debug-Ausgaben server.starttls() print(f"DEBUG: SMTP Login als {email_addr}") server.login(email_addr, password) # E-Mail erstellen msg = MIMEMultipart() msg["From"] = email_addr msg["To"] = to_email msg["Subject"] = subject msg["Date"] = email.utils.formatdate(localtime=True) msg.attach(MIMEText(body, "plain", "utf-8")) print(f"DEBUG: Sende E-Mail...") # Senden server.send_message(msg) server.quit() print(f"DEBUG: ✓ E-Mail erfolgreich per SMTP gesendet!") # SOFORT Erfolgsmeldung und Fenster schließen (nicht auf IMAP warten!) print(f"DEBUG: Zeige Erfolgsmeldung und schließe Fenster...") self.root.after(0, lambda: self.status_var.set("✓ E-Mail gesendet")) self.root.after(0, lambda: messagebox.showinfo("Erfolg", "E-Mail wurde gesendet!", parent=window)) self.root.after(0, window.destroy) # E-Mail-Adresse zu Kontakten hinzufügen self._add_contact(to_email) # Nach erfolgreichem Senden: In Sent-Ordner kopieren (im Hintergrund) try: print(f"DEBUG: Speichere E-Mail im Gesendet-Ordner...") imap_server = acc.get("imap_server", "") imap_port = acc.get("imap_port", 993) mail = imaplib.IMAP4_SSL(imap_server, imap_port, timeout=10) mail.login(email_addr, password) # Finde oder erstelle Sent-Ordner sent_folder = self._find_or_create_sent_folder(mail) if sent_folder: # E-Mail mit aktuellem Datum speichern (timezone-aware!) from datetime import datetime, timezone now = datetime.now(timezone.utc) date_time = imaplib.Time2Internaldate(now) print(f"DEBUG: Speichere in Ordner: '{sent_folder}' mit Datum: {date_time}") # WICHTIG: Ordner mit Leerzeichen/Punkten in Anführungszeichen! if " " in sent_folder or "." in sent_folder: folder_to_use = f'"{sent_folder}"' else: folder_to_use = sent_folder # E-Mail in Sent-Ordner kopieren result = mail.append( folder_to_use, '\\Seen', date_time, msg.as_bytes() ) print(f"DEBUG: IMAP append result: {result}") print(f"DEBUG: ✓ E-Mail erfolgreich in {sent_folder} gespeichert") else: print(f"DEBUG: FEHLER - Kein Sent-Ordner gefunden oder erstellt!") print(f"DEBUG: Schließe IMAP-Verbindung...") try: mail.close() mail.logout() print(f"DEBUG: IMAP-Logout erfolgreich") except: # Logout kann hängen, ignorieren print(f"DEBUG: IMAP-Logout übersprungen") pass except Exception as e: print(f"DEBUG: Fehler beim Speichern in Sent: {e}") # Fehler nicht anzeigen, da E-Mail bereits gesendet wurde print(f"DEBUG: Lösche Cache...") # Cache für Sent-Ordner und Inbox löschen if "sent" in self.email_cache: del self.email_cache["sent"] print(f"DEBUG: Sent-Cache gelöscht") if "sent" in self.cache_timestamp: del self.cache_timestamp["sent"] if "inbox" in self.email_cache: del self.email_cache["inbox"] print(f"DEBUG: Inbox-Cache gelöscht") if "inbox" in self.cache_timestamp: del self.cache_timestamp["inbox"] # WICHTIG: Wenn Gesendet-Ordner aktuell geöffnet ist, neu laden! if self.current_folder == "sent": print(f"DEBUG: Gesendet-Ordner ist aktuell offen, lade neu...") self.root.after(1000, self._fetch_emails) # Nach 1 Sekunde neu laden # Wenn Posteingang aktuell geöffnet ist, neu laden! if self.current_folder == "inbox": print(f"DEBUG: Posteingang ist aktuell offen, lade neu...") self.root.after(1000, self._fetch_emails) # Nach 1 Sekunde neu laden print(f"DEBUG: E-Mail-Versand komplett abgeschlossen!") except Exception as e: error_msg = str(e) print(f"DEBUG: SMTP Fehler: {error_msg}") import traceback traceback.print_exc() self.root.after(0, lambda msg=error_msg: messagebox.showerror("SMTP Fehler", f"Konnte E-Mail nicht senden:\n{msg}", parent=window)) if hasattr(self, '_current_send_btn'): self.root.after(0, lambda: self._current_send_btn.config(state="normal", text="📨 Senden")) threading.Thread(target=worker, daemon=True).start() def _fetch_emails_with_progress(self): """Lädt E-Mails mit Live-Fortschrittsanzeige.""" self._fetch_emails() def _find_or_create_sent_folder(self, mail): """Findet oder erstellt den Sent-Ordner - AKTUELLEN, nicht migrierten!""" try: print(f"DEBUG: Suche Sent-Ordner...") # Liste alle Ordner status, folders_list = mail.list() if status != "OK": return None available_folders = [] for folder_line in folders_list: try: if isinstance(folder_line, bytes): folder_str = folder_line.decode('utf-8', errors='ignore') else: folder_str = str(folder_line) # Parse Ordnername (letzter String in Anführungszeichen) import re matches = re.findall(r'"([^"]*)"', folder_str) if matches and len(matches) >= 2: folder_name = matches[-1] elif matches: folder_name = matches[0] else: parts = folder_str.split() folder_name = parts[-1] if parts else "" if folder_name and folder_name != ".": available_folders.append(folder_name) except: continue print(f"DEBUG: ALLE verfügbaren Ordner:\n{chr(10).join(available_folders)}") # WICHTIG: Priorisiere AKTUELLE Ordner, nicht migrierte! # Migrations-Ordner ZULETZT versuchen! sent_variants = [ "INBOX.Sent", # Standard IMAP "Sent", # Einfach "Sent Items", # Outlook-Style "Sent Messages", # Alternative "[Gmail]/Sent Mail", # Gmail "[Gmail]/Gesendet", # Gmail Deutsch "Gesendet", # Deutsch "INBOX.migriert_von_servertown.Sent Messages", # NUR als Fallback! "INBOX.migriert_von_servertown.Sent", ] # Suche exakten Match for variant in sent_variants: if variant in available_folders: print(f"DEBUG: ✓✓✓ Sent-Ordner gefunden (exakt): {variant}") return variant # Suche mit Teilstring-Match (aber IGNORIERE migrierte Ordner!) for available in available_folders: if ("sent" in available.lower() or "gesendet" in available.lower()) and "migriert" not in available.lower(): print(f"DEBUG: ✓✓✓ Sent-Ordner gefunden (Teilstring, nicht migriert): {available}") return available # Fallback: Auch migrierte Ordner erlauben for available in available_folders: if "sent" in available.lower() or "gesendet" in available.lower(): print(f"DEBUG: ⚠ Sent-Ordner gefunden (migriert als Fallback): {available}") return available # Nicht gefunden - erstelle neuen Sent-Ordner print(f"DEBUG: Kein Sent-Ordner gefunden, erstelle neuen...") try: # Versuche "INBOX.Sent" zu erstellen mail.create("INBOX.Sent") print("DEBUG: ✓✓✓ INBOX.Sent-Ordner neu erstellt") return "INBOX.Sent" except Exception as create_err: print(f"DEBUG: Konnte INBOX.Sent nicht erstellen: {create_err}") try: # Alternative: "Sent" mail.create("Sent") print("DEBUG: ✓✓✓ Sent-Ordner neu erstellt") return "Sent" except: print("DEBUG: FEHLER - Konnte keinen Sent-Ordner erstellen") return None except Exception as e: print(f"DEBUG: FEHLER bei _find_or_create_sent_folder: {e}") import traceback traceback.print_exc() return None def _refresh(self): """Aktualisiert die E-Mail-Liste (forciert Neu-Laden).""" # Cache für aktuellen Ordner löschen if self.current_folder in self.email_cache: del self.email_cache[self.current_folder] if self.current_folder in self.cache_timestamp: del self.cache_timestamp[self.current_folder] self._fetch_emails() def _fetch_emails(self): """Lädt E-Mails vom IMAP-Server mit Caching.""" if not self.current_account: self.status_var.set("Kein Konto konfiguriert. Bitte über ⚙ Konten hinzufügen.") return # IMAP Ordner-Name ermitteln imap_folder = self.folder_mapping.get(self.current_folder, "INBOX") folder_names = { "inbox": "Posteingang", "sent": "Gesendet", "spam": "Spam", "drafts": "Entwürfe", "trash": "Papierkorb" } folder_display = folder_names.get(self.current_folder, "Ordner") # Prüfe Cache import time cache_key = self.current_folder current_time = time.time() if cache_key in self.email_cache and cache_key in self.cache_timestamp: age = current_time - self.cache_timestamp[cache_key] if age < self.cache_max_age: # Cache verwenden self.emails = self.email_cache[cache_key] self._refresh_email_list() self.status_var.set(f"{len(self.emails)} E-Mails in {folder_display} (aus Cache)") return self.status_var.set(f"Lade {folder_display}...") def worker(): try: acc = self.current_account imap_server = acc.get("imap_server", "") imap_port = acc.get("imap_port", 993) email_addr = acc.get("email", "") password = acc.get("_password", "") if not all([imap_server, email_addr, password]): self.root.after(0, lambda: self.status_var.set("Konto-Daten unvollständig. Passwort via AZA_EMAIL_PASSWORD_0 setzen.")) return # IMAP Verbindung mail = imaplib.IMAP4_SSL(imap_server, imap_port) mail.login(email_addr, password) # Verfügbare Ordner auflisten und besten Match finden target_folder = None folder_found = False # Spezialfall: INBOX muss nicht in der Liste sein, versuche direkt if self.current_folder == "inbox": try: status_test = mail.select("INBOX", readonly=True) if status_test[0] == "OK": target_folder = "INBOX" folder_found = True print(f"DEBUG: INBOX direkt gefunden") except Exception as e: print(f"DEBUG: INBOX existiert nicht: {e}") if not folder_found: try: status, folders_list = mail.list() if status == "OK": # Alle verfügbaren Ordner dekodieren - VERBESSERTE LOGIK available_folders = [] for folder_line in folders_list: try: if isinstance(folder_line, bytes): folder_str = folder_line.decode('utf-8', errors='ignore') else: folder_str = str(folder_line) # IMAP LIST Format: (flags) "delimiter" "folder_name" # Beispiel: (\HasNoChildren) "." "INBOX" import re # Suche nach dem letzten String in Anführungszeichen matches = re.findall(r'"([^"]*)"', folder_str) if matches and len(matches) >= 2: # Letzter Match ist der Ordnername folder_name = matches[-1] elif matches and len(matches) == 1: folder_name = matches[0] else: # Fallback: nach Leerzeichen splitten parts = folder_str.split() folder_name = parts[-1] if parts else "" # Nur valide Ordnernamen hinzufügen (nicht "." oder leer) if folder_name and folder_name != ".": available_folders.append(folder_name) except Exception as e: print(f"DEBUG: Fehler beim Parsen von {folder_line}: {e}") continue # DEBUG: Zeige verfügbare Ordner print(f"DEBUG: Verfügbare IMAP-Ordner: {available_folders}") print(f"DEBUG: Suche Ordner für: {self.current_folder}") # Versuche verschiedene Ordner-Namen - SEHR WICHTIG: Reihenfolge beachten! # WICHTIG: Auch migrations-basierte Ordner berücksichtigen! folder_variants = { "inbox": ["INBOX"], # Wird bereits oben direkt versucht "sent": ["INBOX.Sent", "Sent", "Sent Items", "Sent Messages", "Gesendet", "[Gmail]/Sent Mail", "[Gmail]/Gesendet", "INBOX.migriert_von_servertown.Sent Messages"], "spam": ["INBOX.Spam", "INBOX.Junk", "Junk", "Spam", "Junk E-Mail", "[Gmail]/Spam", "INBOX.migriert_von_servertown.Spam", "INBOX.migriert_von_servertown.Junk"], "drafts": ["INBOX.Drafts", "Drafts", "Entwürfe", "[Gmail]/Drafts", "INBOX.migriert_von_servertown.Drafts"], "trash": ["INBOX.Trash", "Trash", "Deleted Items", "Deleted Messages", "Papierkorb", "[Gmail]/Trash", "Gelöscht", "INBOX.migriert_von_servertown.Deleted Messages"], } # Richtigen Ordner finden - SEHR flexibles Matching! if self.current_folder in folder_variants: for variant in folder_variants[self.current_folder]: # Prüfe ob Ordner existiert (auch Teilstring-Match) for available in available_folders: # SEHR flexibles Matching mit mehreren Strategien variant_clean = variant.lower().replace(" ", "").replace("_", "").replace("-", "") available_clean = available.lower().replace(" ", "").replace("_", "").replace("-", "") match_found = False # Strategie 1: Exakte Teilstrings (mit Original-Strings) if variant.lower() in available.lower() or available.lower() in variant.lower(): match_found = True print(f"DEBUG: Match Strategie 1 (Teilstring): '{variant}' <-> '{available}'") # Strategie 2: Ohne Leerzeichen/Unterstriche elif variant_clean in available_clean or available_clean in variant_clean: match_found = True print(f"DEBUG: Match Strategie 2 (bereinigt): '{variant}' <-> '{available}'") # Strategie 3: Schlüsselwörter (für "Sent Messages" etc.) elif self.current_folder == "sent" and ("sent" in available.lower() or "gesendet" in available.lower()): match_found = True print(f"DEBUG: Match Strategie 3 (Schlüsselwort sent): '{available}'") elif self.current_folder == "spam" and ("spam" in available.lower() or "junk" in available.lower()): match_found = True print(f"DEBUG: Match Strategie 3 (Schlüsselwort spam): '{available}'") elif self.current_folder == "trash" and ("deleted" in available.lower() or "trash" in available.lower() or "papierkorb" in available.lower()): match_found = True print(f"DEBUG: Match Strategie 3 (Schlüsselwort trash): '{available}'") if match_found: try: # Versuche readonly select zum Testen # WICHTIG: Ordner mit Leerzeichen/Punkten in Anführungszeichen! if " " in available or "." in available: status_test = mail.select(f'"{available}"', readonly=True) else: status_test = mail.select(available, readonly=True) if status_test[0] == "OK": target_folder = available folder_found = True # WICHTIG: Speichere Mapping für späteren Zugriff! self.folder_mapping[self.current_folder] = available print(f"DEBUG: ✓ Ordner erfolgreich gefunden: {available} für {self.current_folder}") break except Exception as e: print(f"DEBUG: Select fehlgeschlagen für {available}: {e}") continue if folder_found: break except Exception as e: print(f"DEBUG: Fehler beim Ordner-Listing: {e}") pass # FALLBACK für Posteingang: Wenn INBOX nicht existiert, nimm den ersten Ordner if not folder_found and self.current_folder == "inbox" and 'available_folders' in locals() and available_folders: target_folder = available_folders[0] folder_found = True print(f"DEBUG: FALLBACK - Verwende ersten verfügbaren Ordner als Posteingang: {target_folder}") self.root.after(0, lambda tf=target_folder: self.status_var.set(f"⚠ Standard-INBOX nicht gefunden, verwende: {tf}")) # Wenn immer noch nicht gefunden, versuche Ordner zu erstellen (für Spam/Trash) if not folder_found or target_folder is None: # Versuche Spam/Trash-Ordner zu erstellen (mit INBOX. Präfix!) if self.current_folder in ["spam", "trash", "drafts"]: folder_names = {"spam": "INBOX.Spam", "trash": "INBOX.Trash", "drafts": "INBOX.Drafts"} new_folder_name = folder_names[self.current_folder] try: print(f"DEBUG: Versuche Ordner '{new_folder_name}' zu erstellen...") mail.create(new_folder_name) target_folder = new_folder_name folder_found = True self.folder_mapping[self.current_folder] = new_folder_name self.root.after(0, lambda: self.status_var.set(f"✓ Ordner '{new_folder_name}' wurde erstellt")) print(f"DEBUG: ✓ Ordner '{new_folder_name}' erfolgreich erstellt!") except Exception as create_error: print(f"DEBUG: Konnte Ordner nicht erstellen: {create_error}") # Wenn immer noch nicht gefunden/erstellt, zeige Fehler if not folder_found or target_folder is None: error_msg = f"Ordner '{folder_display}' nicht gefunden auf Server. Verfügbar: {', '.join(available_folders[:5]) if 'available_folders' in locals() else 'unbekannt'}" self.root.after(0, lambda msg=error_msg: self.status_var.set(msg)) self.root.after(0, lambda msg=error_msg: messagebox.showwarning("Ordner nicht gefunden", msg)) mail.logout() return # Ordner auswählen (jetzt mit Sicherheit der richtige!) try: print(f"DEBUG: Wähle Ordner aus: {target_folder}") # WICHTIG: Ordner mit Punkten/Leerzeichen in Anführungszeichen setzen! if " " in target_folder or "." in target_folder: status_select = mail.select(f'"{target_folder}"') else: status_select = mail.select(target_folder) if status_select[0] != "OK": error_msg = f"Konnte Ordner '{target_folder}' nicht öffnen (Status: {status_select})" self.root.after(0, lambda msg=error_msg: self.status_var.set(msg)) self.root.after(0, lambda msg=error_msg: messagebox.showerror("Fehler", msg)) mail.logout() return else: # Erfolg - zeige welcher Ordner geladen wird self.root.after(0, lambda tf=target_folder, fd=folder_display: self.status_var.set(f"✓ Lade {fd} (IMAP: {tf})")) except Exception as e: error_msg = f"Fehler beim Öffnen von '{target_folder}': {str(e)}" self.root.after(0, lambda msg=error_msg: self.status_var.set(msg)) self.root.after(0, lambda msg=error_msg: messagebox.showerror("Fehler", msg)) mail.logout() return # Nur die letzten 20 E-Mails abrufen (für schnelleres Laden) print(f"DEBUG: Suche E-Mails...") self.root.after(0, lambda: self.status_var.set("🔍 Suche E-Mails...")) status, messages = mail.search(None, "ALL") if status != "OK": self.root.after(0, lambda: self.status_var.set(f"Keine E-Mails in {folder_display}.")) self.emails = [] self.root.after(0, self._refresh_email_list) mail.close() mail.logout() return email_ids = messages[0].split() total_count = len(email_ids) # WICHTIG: Zeige auch wenn 0 E-Mails gefunden wurden! if total_count == 0: print(f"DEBUG: Keine E-Mails in Ordner '{target_folder}' gefunden!") self.root.after(0, lambda fd=folder_display: self.status_var.set(f"✓ {fd} ist leer (keine E-Mails)")) self.emails = [] self.root.after(0, self._refresh_email_list) mail.close() mail.logout() return # WICHTIG: Die neuesten 50 E-Mails (nicht umkehren!) # E-Mail-IDs sind aufsteigend (1, 2, 3, ...), die höchsten sind die neuesten email_ids = email_ids[-50:] # Letzte 50 = neueste 50 print(f"DEBUG: Gefunden {total_count} E-Mails, lade die letzten {len(email_ids)} (IDs: {email_ids[0]}...{email_ids[-1]})...") self.root.after(0, lambda tc=total_count, lc=len(email_ids): self.status_var.set(f"📥 Lade {lc} von {tc} E-Mails...")) fetched_emails = [] print(f"DEBUG: Beginne E-Mail-Parsing für {len(email_ids)} E-Mails...") # NICHT reversed! Die Liste ist schon richtig sortiert (älteste zuerst) # Wir laden von alt nach neu, aber zeigen dann reversed in der Liste for idx, email_id in enumerate(email_ids, 1): try: print(f"DEBUG: Lade E-Mail {idx}/{len(email_ids)} (ID: {email_id})...") # Live-Status-Update JEDE E-Mail (damit User sieht, dass es lädt) self.root.after(0, lambda i=idx, total=len(email_ids): self.status_var.set(f"📥 Lade E-Mail {i}/{total}...")) # Volle E-Mail laden (für korrekte Vorschau) status, msg_data = mail.fetch(email_id, "(RFC822)") if status != "OK": print(f"DEBUG: FEHLER - Fetch fehlgeschlagen für E-Mail {email_id}: Status={status}") continue for response_part in msg_data: if isinstance(response_part, tuple): msg = email.message_from_bytes(response_part[1]) # Betreff dekodieren subject = "" if msg["Subject"]: decoded = decode_header(msg["Subject"]) subject = "" for part, encoding in decoded: if isinstance(part, bytes): subject += part.decode(encoding or "utf-8", errors="ignore") else: subject += str(part) # Absender dekodieren from_addr = msg.get("From", "Unbekannt") if from_addr: decoded = decode_header(from_addr) from_addr = "" for part, encoding in decoded: if isinstance(part, bytes): from_addr += part.decode(encoding or "utf-8", errors="ignore") else: from_addr += str(part) # Datum date_str = msg.get("Date", "") # Body extrahieren (RICHTIG decoded mit HTML-Unterstützung!) body = "" html_body = "" attachments = [] # Liste der Anhänge if msg.is_multipart(): for part in msg.walk(): content_type = part.get_content_type() content_disposition = part.get("Content-Disposition", "") # Text-Teil extrahieren (Plain Text) if content_type == "text/plain" and "attachment" not in content_disposition: try: payload = part.get_payload(decode=True) if payload: # Encoding erkennen charset = part.get_content_charset() or 'utf-8' body = payload.decode(charset, errors="ignore") except: pass # HTML-Teil extrahieren elif content_type == "text/html" and "attachment" not in content_disposition: try: payload = part.get_payload(decode=True) if payload: # Encoding erkennen charset = part.get_content_charset() or 'utf-8' html_body = payload.decode(charset, errors="ignore") except: pass # Anhänge sammeln (inkl. inline images) elif "attachment" in content_disposition or part.get_filename(): filename = part.get_filename() if filename: # Dateinamen dekodieren decoded_filename = decode_header(filename) filename_str = "" for part_text, encoding in decoded_filename: if isinstance(part_text, bytes): filename_str += part_text.decode(encoding or "utf-8", errors="ignore") else: filename_str += str(part_text) # Größe ermitteln (ungefähr) payload = part.get_payload(decode=True) size = len(payload) if payload else 0 size_str = f"{size / 1024:.1f} KB" if size < 1024 * 1024 else f"{size / (1024 * 1024):.1f} MB" # Prüfe ob es ein Bild ist is_image = content_type.startswith("image/") attachments.append({ "filename": filename_str, "size": size_str, "content_type": content_type, "payload": payload, "is_image": is_image }) else: # Nicht-multipart E-Mail content_type = msg.get_content_type() try: payload = msg.get_payload(decode=True) if payload: # Encoding erkennen charset = msg.get_content_charset() or 'utf-8' decoded_text = payload.decode(charset, errors="ignore") if content_type == "text/html": html_body = decoded_text else: body = decoded_text except: body = msg.get_payload() # HTML zu Text konvertieren (falls kein Plain Text vorhanden) if not body and html_body: body = self._html_to_text(html_body) # Vorschau erstellen (erste 150 Zeichen des RICHTIGEN Textes) preview = body[:150].replace("\n", " ").replace("\r", " ").strip() if len(body) > 150: preview += "..." if not preview: preview = "(Keine Vorschau verfügbar)" fetched_emails.append({ "from": from_addr, "subject": subject or "(Kein Betreff)", "date": date_str, "preview": preview, "body": body, "html_body": html_body, # HTML-Version speichern "has_attachments": len(attachments) > 0, "attachments": attachments, "email_id": email_id.decode() if isinstance(email_id, bytes) else str(email_id) }) # E-Mail-Adresse zu Kontakten hinzufügen if "@" in from_addr: # Extrahiere E-Mail aus "Name " Format if "<" in from_addr and ">" in from_addr: email_only = from_addr.split("<")[1].split(">")[0].strip() else: email_only = from_addr.strip() self._add_contact(email_only) print(f"DEBUG: ✓ E-Mail erfolgreich geparst: ID={email_id}, Datum='{date_str[:20]}', Von='{from_addr[:30]}', Betreff='{subject[:30]}'") except Exception as e: print(f"DEBUG: FEHLER beim Laden der E-Mail {email_id}: {e}") import traceback traceback.print_exc() continue print(f"DEBUG: E-Mail-Parsing abgeschlossen. {len(fetched_emails)} E-Mails erfolgreich geladen.") # Zeige erste und letzte E-Mail VOR der Sortierung if fetched_emails: print(f"DEBUG: VOR Sortierung - Erste E-Mail: Datum={fetched_emails[0]['date'][:20]}, ID={fetched_emails[0]['email_id']}") print(f"DEBUG: VOR Sortierung - Letzte E-Mail: Datum={fetched_emails[-1]['date'][:20]}, ID={fetched_emails[-1]['email_id']}") mail.close() mail.logout() # WICHTIG: E-Mails nach Datum sortieren (neueste zuerst)! # Verwende email.utils.parsedate_to_datetime für richtige Datums-Sortierung from email.utils import parsedate_to_datetime from datetime import datetime, timezone def get_email_date(email_dict): """Konvertiert E-Mail-Datum zu datetime für Sortierung.""" try: date_str = email_dict.get('date', '') if date_str: dt = parsedate_to_datetime(date_str) # WICHTIG: Konvertiere zu naive datetime (ohne Timezone), um Vergleichsfehler zu vermeiden if dt and dt.tzinfo is not None: # Konvertiere zu UTC und entferne Timezone-Info dt = dt.replace(tzinfo=None) return dt if dt else datetime(1970, 1, 1) except Exception as e: print(f"DEBUG: Fehler beim Parsen von Datum '{date_str[:30] if date_str else 'leer'}': {e}") # Fallback: sehr altes Datum return datetime(1970, 1, 1) # Sortiere nach Datum, neueste zuerst (reverse=True) try: fetched_emails.sort(key=get_email_date, reverse=True) print(f"DEBUG: E-Mails nach Datum sortiert - neueste zuerst") except Exception as sort_error: print(f"DEBUG: FEHLER beim Sortieren: {sort_error}") import traceback traceback.print_exc() # Fallback: Keine Sortierung, verwende wie geladen pass # Zeige erste und letzte E-Mail NACH der Sortierung if fetched_emails: print(f"DEBUG: NACH Sortierung - Erste E-Mail: Datum={fetched_emails[0]['date'][:20]}, ID={fetched_emails[0]['email_id']}") print(f"DEBUG: NACH Sortierung - Letzte E-Mail: Datum={fetched_emails[-1]['date'][:20]}, ID={fetched_emails[-1]['email_id']}") self.emails = fetched_emails print(f"DEBUG: self.emails gesetzt mit {len(fetched_emails)} E-Mails") # Cache speichern import time cache_key = self.current_folder self.email_cache[cache_key] = fetched_emails self.cache_timestamp[cache_key] = time.time() print(f"DEBUG: Rufe _refresh_email_list auf...") self.root.after(0, self._refresh_email_list) count_msg = f"✓ {len(fetched_emails)} E-Mails geladen (von {total_count} im Ordner '{folder_display}')" self.root.after(0, lambda: self.status_var.set(count_msg)) print(f"DEBUG: {count_msg}") except Exception as e: error_msg = str(e) self.root.after(0, lambda: self.status_var.set(f"Fehler beim Laden: {error_msg}")) self.root.after(0, lambda: messagebox.showerror("IMAP Fehler", f"Konnte E-Mails nicht laden:\n{error_msg}")) threading.Thread(target=worker, daemon=True).start() def _manage_accounts(self): """Öffnet Konto-Verwaltung.""" acc_win = tk.Toplevel(self.root) acc_win.title("E-Mail-Konten verwalten") acc_win.geometry("600x500") acc_win.configure(bg="#FFFFFF") acc_win.transient(self.root) main_f = tk.Frame(acc_win, bg="#FFFFFF", padx=20, pady=20) main_f.pack(fill="both", expand=True) tk.Label(main_f, text="E-Mail-Konten", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 14, "bold")).pack(anchor="w", pady=(0, 10)) # Liste der Konten list_frame = tk.Frame(main_f, bg="#FFFFFF") list_frame.pack(fill="both", expand=True) scrollbar = tk.Scrollbar(list_frame) scrollbar.pack(side="right", fill="y") acc_listbox = tk.Listbox( list_frame, font=("Segoe UI", 10), relief="solid", bd=1, yscrollcommand=scrollbar.set ) acc_listbox.pack(side="left", fill="both", expand=True) scrollbar.config(command=acc_listbox.yview) def refresh_acc_list(): acc_listbox.delete(0, "end") for acc in self.accounts: acc_listbox.insert("end", f"{acc.get('name', 'Unbenannt')} ({acc.get('email', '')})") refresh_acc_list() # Buttons btn_f = tk.Frame(main_f, bg="#FFFFFF") btn_f.pack(fill="x", pady=(10, 0)) def add_account(): self._add_account_dialog(acc_win, refresh_acc_list) def edit_account(): sel = acc_listbox.curselection() if not sel: messagebox.showinfo("Hinweis", "Bitte Konto auswählen.", parent=acc_win) return idx = sel[0] if idx < len(self.accounts): self._edit_account_dialog(acc_win, idx, refresh_acc_list) def remove_account(): sel = acc_listbox.curselection() if not sel: messagebox.showinfo("Hinweis", "Bitte Konto auswählen.", parent=acc_win) return idx = sel[0] if idx < len(self.accounts): if messagebox.askyesno("Löschen", f"Konto wirklich löschen?", parent=acc_win): del self.accounts[idx] if self.current_account == self.accounts[idx] if idx < len(self.accounts) else None: self.current_account = self.accounts[0] if self.accounts else None refresh_acc_list() self._save_config() tk.Button(btn_f, text="➕ Neues Konto", command=add_account, bg=self.accent_blue, fg=self.fg_light, relief="flat", padx=15, pady=8, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_f, text="✏ Bearbeiten", command=edit_account, bg="#27AE60", fg=self.fg_light, relief="flat", padx=15, pady=8, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_f, text="🗑 Konto löschen", command=remove_account, bg="#E74C3C", fg=self.fg_light, relief="flat", padx=15, pady=8, cursor="hand2").pack(side="left", padx=5) def _add_account_dialog(self, parent, callback, edit_mode=False, account_data=None, account_idx=None): """Dialog zum Hinzufügen oder Bearbeiten eines E-Mail-Kontos.""" dialog = tk.Toplevel(parent) dialog.title("E-Mail-Konto bearbeiten" if edit_mode else "Neues E-Mail-Konto") dialog.geometry("550x680") dialog.configure(bg="#FFFFFF") dialog.transient(parent) form = tk.Frame(dialog, bg="#FFFFFF", padx=20, pady=20) form.pack(fill="both", expand=True) title_text = "E-Mail-Konto bearbeiten" if edit_mode else "E-Mail-Konto einrichten" tk.Label(form, text=title_text, bg="#FFFFFF", font=("Segoe UI", 12, "bold")).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 15)) # Formular-Felder fields = [ ("Kontoname:", "name", "z.B. Praxis Lindengut"), ("E-Mail-Adresse:", "email", "praxis@lindengut.ch"), ("Passwort:", "password", ""), ("IMAP-Server:", "imap_server", "imap.gmail.com"), ("IMAP-Port:", "imap_port", "993"), ("SMTP-Server:", "smtp_server", "smtp.gmail.com"), ("SMTP-Port:", "smtp_port", "587"), ] entries = {} row = 1 for label_text, key, placeholder in fields: tk.Label(form, text=label_text, bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=row, column=0, sticky="w", pady=5) entry = tk.Entry(form, font=("Segoe UI", 10), relief="solid", bd=1) if key == "password": entry.configure(show="*") # Bei Bearbeitung: Werte aus bestehendem Konto laden if edit_mode and account_data: value = account_data.get(key, "") if value: entry.insert(0, str(value)) else: entry.insert(0, placeholder) entry.grid(row=row, column=1, sticky="ew", pady=5) entries[key] = entry row += 1 form.columnconfigure(1, weight=1) # Info-Text info_text = ( "Häufige Server:\n\n" "Gmail: imap.gmail.com (993), smtp.gmail.com (587)\n" "Outlook: outlook.office365.com (993/587)\n" "Yahoo: imap.mail.yahoo.com (993/587)\n\n" "Hinweis: Gmail benötigt ein App-Passwort!" ) tk.Label(form, text=info_text, bg="#F8F9FA", fg="#7F8C8D", font=("Segoe UI", 9), justify="left", relief="solid", bd=1, padx=10, pady=10).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(10, 15)) row += 1 # Test-Button und Status-Label test_frame = tk.Frame(form, bg="#FFFFFF") test_frame.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 15)) test_status_var = tk.StringVar(value="") test_status_label = tk.Label(test_frame, textvariable=test_status_var, bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 9), wraplength=450, justify="left") test_status_label.pack(fill="x", pady=(0, 10)) def test_connection(): """Testet IMAP und SMTP Verbindung.""" test_status_var.set("⏳ Teste Verbindung...") dialog.update() test_pw = entries["password"].get().strip() account = { "name": entries["name"].get().strip(), "email": entries["email"].get().strip(), "imap_server": entries["imap_server"].get().strip(), "imap_port": int(entries["imap_port"].get().strip() or "993"), "smtp_server": entries["smtp_server"].get().strip(), "smtp_port": int(entries["smtp_port"].get().strip() or "587"), } if not account["email"] or not test_pw: test_status_var.set("Fehler: E-Mail und Passwort sind erforderlich.") return def test_worker(): results = {"imap": False, "smtp": False, "imap_error": "", "smtp_error": ""} # Test IMAP try: mail = imaplib.IMAP4_SSL(account["imap_server"], account["imap_port"]) mail.login(account["email"], test_pw) mail.select("INBOX") mail.close() mail.logout() results["imap"] = True except Exception as e: results["imap_error"] = str(e) # Test SMTP try: server = smtplib.SMTP(account["smtp_server"], account["smtp_port"]) server.starttls() server.login(account["email"], test_pw) server.quit() results["smtp"] = True except Exception as e: results["smtp_error"] = str(e) # Ergebnis anzeigen def show_result(): if results["imap"] and results["smtp"]: test_status_var.set("✅ Test erfolgreich!\n✓ IMAP (Posteingang): Funktioniert\n✓ SMTP (Postausgang): Funktioniert") messagebox.showinfo("Test erfolgreich", "Die Verbindung wurde erfolgreich getestet!\n\n" "✓ Posteingang (IMAP): OK\n" "✓ Postausgang (SMTP): OK\n\n" "Sie können das Konto jetzt speichern.", parent=dialog) else: error_msg = "❌ Test fehlgeschlagen:\n\n" if not results["imap"]: error_msg += f"✗ IMAP (Posteingang): {results['imap_error'][:100]}\n\n" else: error_msg += "✓ IMAP (Posteingang): OK\n\n" if not results["smtp"]: error_msg += f"✗ SMTP (Postausgang): {results['smtp_error'][:100]}\n\n" else: error_msg += "✓ SMTP (Postausgang): OK\n\n" test_status_var.set(error_msg) # Detaillierte Fehlermeldung full_error = "Verbindungstest fehlgeschlagen:\n\n" if not results["imap"]: full_error += f"IMAP-Fehler:\n{results['imap_error']}\n\n" if not results["smtp"]: full_error += f"SMTP-Fehler:\n{results['smtp_error']}\n\n" full_error += "Bitte überprüfen Sie:\n" full_error += "• Server-Adressen und Ports\n" full_error += "• E-Mail-Adresse und Passwort\n" full_error += "• Bei Gmail: App-Passwort verwenden!" messagebox.showerror("Verbindungstest fehlgeschlagen", full_error, parent=dialog) dialog.after(0, show_result) threading.Thread(target=test_worker, daemon=True).start() tk.Button(test_frame, text="🔍 Verbindung testen", command=test_connection, bg="#F39C12", fg=self.fg_light, relief="flat", padx=20, pady=8, cursor="hand2").pack() def save_account(): pw_input = entries["password"].get().strip() account = { "name": entries["name"].get().strip(), "email": entries["email"].get().strip(), "_password": pw_input, "imap_server": entries["imap_server"].get().strip(), "imap_port": int(entries["imap_port"].get().strip() or "993"), "smtp_server": entries["smtp_server"].get().strip(), "smtp_port": int(entries["smtp_port"].get().strip() or "587"), } if not account["email"] or not pw_input: messagebox.showerror("Fehler", "E-Mail und Passwort sind erforderlich.", parent=dialog) return if edit_mode and account_idx is not None: # Bestehendes Konto aktualisieren self.accounts[account_idx] = account if self.current_account == account_data: self.current_account = account messagebox.showinfo("Erfolg", "Konto wurde aktualisiert!", parent=parent) else: # Neues Konto hinzufügen self.accounts.append(account) if not self.current_account: self.current_account = account messagebox.showinfo("Erfolg", "Konto wurde hinzugefügt!", parent=parent) self._save_config() callback() dialog.destroy() if not edit_mode: self._fetch_emails() btn_f = tk.Frame(form, bg="#FFFFFF") btn_f.grid(row=row+1, column=0, columnspan=2, sticky="ew", pady=(0, 0)) save_text = "💾 Änderungen speichern" if edit_mode else "💾 Speichern" tk.Button(btn_f, text=save_text, command=save_account, bg=self.accent_blue, fg=self.fg_light, relief="flat", padx=20, pady=8, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_f, text="Abbrechen", command=dialog.destroy, bg="#95A5A6", fg=self.fg_light, relief="flat", padx=20, pady=8, cursor="hand2").pack(side="left", padx=5) def _edit_account_dialog(self, parent, account_idx, callback): """Dialog zum Bearbeiten eines bestehenden Kontos.""" if account_idx >= len(self.accounts): return account_data = self.accounts[account_idx] self._add_account_dialog(parent, callback, edit_mode=True, account_data=account_data, account_idx=account_idx) def _save_config(self): """Speichert Konfiguration.""" config = load_email_config() config["accounts"] = self.accounts save_email_config(config) def _reply_email(self): """Antwortet auf die ausgewählte E-Mail.""" if not self.selected_email: messagebox.showinfo("Hinweis", "Bitte wählen Sie zuerst eine E-Mail aus.") return # Öffne Compose-Fenster mit vorausgefüllten Daten compose_win = tk.Toplevel(self.root) compose_win.title("Antworten") compose_win.geometry("700x550") compose_win.configure(bg="#FFFFFF") # STATUS-ZEILE ZUOBERST (wie im Hauptprogramm) status_frame = tk.Frame(compose_win, bg="#E7F9FD", height=40) status_frame.pack(fill="x") status_frame.pack_propagate(False) status_var = tk.StringVar(value="Bereit zum Diktieren") status_label = tk.Label(status_frame, textvariable=status_var, bg="#E7F9FD", fg=self.fg_dark, font=("Segoe UI", 10)) status_label.pack(side="left", pady=10, padx=20) # SCHRIFTGRÖSSEN-SPINBOX (rechts in der Statusleiste) font_control_frame = tk.Frame(status_frame, bg="#E7F9FD") font_control_frame.pack(side="right", padx=20, pady=5) tk.Label(font_control_frame, text="Aa", bg="#E7F9FD", fg=self.fg_dark, font=("Segoe UI", 9)).pack(side="left", padx=(0, 3)) reply_font_size = tk.IntVar(value=self.font_size_compose) def on_font_change(val): """Ändert die Schriftgröße im Antworten-Fenster.""" try: size = int(float(val)) message_text.configure(font=("Segoe UI", size)) except: pass font_spinbox = tk.Spinbox(font_control_frame, from_=5, to=12, textvariable=reply_font_size, width=3, bg="#E7F9FD", relief="solid", bd=1, command=lambda: on_font_change(reply_font_size.get()), font=("Segoe UI", 9)) font_spinbox.pack(side="left") font_spinbox.bind("", lambda e: on_font_change(reply_font_size.get())) form_frame = tk.Frame(compose_win, bg="#FFFFFF", padx=20, pady=20) form_frame.pack(fill="both", expand=True) # An (vorausgefüllt) tk.Label(form_frame, text="An:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=0, column=0, sticky="w", pady=5) # Frame für Entry + Info to_frame = tk.Frame(form_frame, bg="#FFFFFF") to_frame.grid(row=0, column=1, sticky="ew", pady=5) to_entry = tk.Entry(to_frame, font=("Segoe UI", 10), relief="solid", bd=1) to_entry.insert(0, self.selected_email['from']) to_entry.pack(fill="x") # Info-Label für mehrere E-Mails info_label = tk.Label(to_frame, text="💡 Mehrere E-Mails mit Komma trennen: max@test.de, anna@test.de", bg="#FFFFFF", fg="#7F8C8D", font=("Segoe UI", 8), anchor="w") info_label.pack(fill="x", pady=(2, 0)) # E-Mail-Validierung mit blauer Anzeige def extract_email_address(text): """Extrahiert die reine E-Mail-Adresse aus verschiedenen Formaten.""" import re # Formate: "Name ", "", "email@domain.com" match = re.search(r'<([^>]+)>', text) if match: return match.group(1).strip() return text.strip() def validate_email_field(event=None): """Validiert E-Mail-Adressen und zeigt sie blau an, wenn korrekt.""" import re email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' current_text = to_entry.get().strip() # Multiple E-Mails durch Komma/Semikolon getrennt recipients = [addr.strip() for addr in re.split('[,;]', current_text)] if current_text else [] # Extrahiere reine E-Mail-Adressen und validiere clean_emails = [] all_valid = True for addr in recipients: clean_addr = extract_email_address(addr) clean_emails.append(clean_addr) if not re.match(email_pattern, clean_addr): all_valid = False break if all_valid and recipients: # BLAU wenn alle E-Mails korrekt to_entry.configure(fg="#2E86C1", bg="#EBF5FB") elif recipients: # ROT wenn mindestens eine ungültig to_entry.configure(fg="#E74C3C", bg="#FADBD8") else: # Normal wenn leer to_entry.configure(fg=self.fg_dark, bg="#FFFFFF") # Bei jeder Änderung validieren to_entry.bind("", validate_email_field) to_entry.bind("", validate_email_field) # Initial validieren validate_email_field() # Betreff (Re: ...) tk.Label(form_frame, text="Betreff:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=1, column=0, sticky="w", pady=5) subject_entry = tk.Entry(form_frame, font=("Segoe UI", 10), relief="solid", bd=1) original_subject = self.selected_email['subject'] if not original_subject.startswith("Re:"): original_subject = f"Re: {original_subject}" subject_entry.insert(0, original_subject) subject_entry.grid(row=1, column=1, sticky="ew", pady=5) # Nachricht tk.Label(form_frame, text="Nachricht:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=2, column=0, sticky="nw", pady=5) text_frame = tk.Frame(form_frame, bg="#FFFFFF") text_frame.grid(row=2, column=1, sticky="nsew", pady=5) message_text = scrolledtext.ScrolledText( text_frame, wrap="word", font=("Segoe UI", self.font_size_compose), relief="solid", bd=1, height=15 ) message_text.pack(fill="both", expand=True) # Unterschrift automatisch einfügen bei Antworten reply_text = "" if self.signature_auto_reply and self.signature: reply_text = "\n\n" + self.signature + "\n\n" # Zitat der Original-Nachricht quote = f"---\nAm {self.selected_email['date']} schrieb {self.selected_email['from']}:\n> " + \ self.selected_email['body'].replace("\n", "\n> ") message_text.insert("1.0", reply_text + quote) message_text.mark_set("insert", "1.0") form_frame.columnconfigure(1, weight=1) form_frame.rowconfigure(2, weight=1) self.compose_to_entry = to_entry self.compose_subject_entry = subject_entry self.compose_body_text = message_text # WICHTIG: Widget zur Liste hinzufügen für dynamische Updates self.compose_text_widgets.append(message_text) # Beim Schließen des Fensters aus Liste entfernen def on_close(): if message_text in self.compose_text_widgets: self.compose_text_widgets.remove(message_text) compose_win.destroy() # Buttons btn_frame = tk.Frame(compose_win, bg="#FFFFFF", pady=10) btn_frame.pack(fill="x", padx=20) def insert_signature(): """Fügt Unterschrift am Cursor ein.""" if self.signature: current_pos = message_text.index("insert") message_text.insert(current_pos, "\n\n" + self.signature + "\n\n") else: messagebox.showinfo("Keine Unterschrift", "Bitte erstellen Sie zuerst eine Unterschrift über den Button '✍ Unterschrift'.", parent=compose_win) # DIKTAT DIREKT IM FENSTER (wie im Hauptprogramm) is_recording = [False] timer_sec = [0] timer_running = [False] dictation_btn = [None] def update_timer(): if timer_running[0]: timer_sec[0] += 1 mins = timer_sec[0] // 60 secs = timer_sec[0] % 60 status_var.set(f"🔴 Aufnahme läuft... {mins}:{secs:02d}") compose_win.after(1000, update_timer) def start_dictation(): """Startet Diktat DIREKT im Fenster.""" if is_recording[0]: # Stoppen try: timer_running[0] = False is_recording[0] = False status_var.set("⏳ Verarbeite Audio...") dictation_btn[0].configure(text="🎤 Diktat starten", bg="#27AE60") def worker(): try: wav_path = self.recorder.stop_and_save_wav() # OpenAI Whisper Transkription with open(wav_path, "rb") as f: transcript_obj = self.client.audio.transcriptions.create( model="whisper-1", file=f, language="de" ) transcribed_text = transcript_obj.text.strip() # Temp-Datei löschen try: os.remove(wav_path) except: pass # Text einfügen def insert_text(): if transcribed_text: current_pos = message_text.index("insert") message_text.insert(current_pos, transcribed_text + " ") status_var.set("✓ Transkription abgeschlossen") else: status_var.set("Kein Audio gehört") compose_win.after(0, insert_text) except Exception as e: def show_error(): status_var.set(f"❌ Fehler: {str(e)}") compose_win.after(0, show_error) threading.Thread(target=worker, daemon=True).start() except Exception as e: status_var.set(f"❌ Fehler: {str(e)}") else: # Starten if not self.client: messagebox.showerror("Fehler", "OpenAI API-Key fehlt!\n\nBitte .env Datei mit OPENAI_API_KEY erstellen.", parent=compose_win) return try: self.recorder.start() is_recording[0] = True timer_running[0] = True timer_sec[0] = 0 status_var.set("🔴 Aufnahme läuft... 0:00") dictation_btn[0].configure(text="⏹ Aufnahme stoppen", bg="#E74C3C") update_timer() except Exception as e: status_var.set(f"❌ Fehler: {str(e)}") def format_with_ai(): """Formatiert NUR den neuen Text mit KI, behält Zitat unten bei.""" if not self.client: messagebox.showerror("Fehler", "OpenAI API-Key fehlt!\n\nBitte .env Datei mit OPENAI_API_KEY erstellen.", parent=compose_win) return current_text = message_text.get("1.0", "end-1c") if not current_text.strip(): messagebox.showinfo("Kein Text", "Bitte geben Sie zuerst Text ein.", parent=compose_win) return # Text am Zitat-Trennzeichen splitten # Trennzeichen: "---" oder "Am ... schrieb" separator_found = False new_text_part = current_text quoted_part = "" # Suche nach "---" Trennzeichen if "\n---\n" in current_text: parts = current_text.split("\n---\n", 1) new_text_part = parts[0] quoted_part = "\n---\n" + parts[1] separator_found = True # Alternative: Suche nach "Am ... schrieb" elif "\nAm " in current_text and " schrieb " in current_text: import re match = re.search(r'\n(Am .+ schrieb .+:\n>)', current_text) if match: split_pos = match.start() new_text_part = current_text[:split_pos] quoted_part = current_text[split_pos:] separator_found = True if not new_text_part.strip(): messagebox.showinfo("Kein neuer Text", "Bitte schreiben Sie zuerst Ihre Antwort.", parent=compose_win) return status_var.set("⏳ KI formatiert Ihren Text...") def worker(): try: prompt = """Du bist ein Assistent, der diktierten E-Mail-Text professionell formatiert. WICHTIGE REGELN: 1. Erkenne gesprochene Befehle und setze sie um (schreibe sie NICHT aus): - "neue Zeile" oder "Neue Zeile" → füge einen Zeilenumbruch ein - "neuer Absatz" oder "Neuer Absatz" → füge zwei Zeilenumbrüche ein (Leerzeile) - "erstens", "zweitens", "drittens" → ersetze durch "1.", "2.", "3." - "Punkt eins", "Punkt zwei" → ersetze durch "1.", "2." 2. Formatiere den Text wie einen professionellen E-Mail-Brief: - Korrekte Absätze - Sinnvolle Zeilenumbrüche - Aufzählungen mit Nummern (1., 2., 3.) - Korrekte Groß-/Kleinschreibung - Entferne übermäßige Leerzeichen 3. Ändere NICHT den Inhalt oder die Bedeutung! 4. Behalte den Ton und Stil bei (formell/informell) 5. Gib NUR den formatierten Text zurück, KEINE Erklärungen! JETZT FORMATIERE NUR DIESEN TEXT (NICHT das Zitat darunter!): """ response = self.client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": "Du bist ein Experte für E-Mail-Formatierung. Formatiere NUR den neuen Text, nicht das Zitat."}, {"role": "user", "content": prompt + new_text_part} ], temperature=0.3, max_tokens=1500 ) formatted_text = response.choices[0].message.content.strip() # Füge Zitat wieder hinzu (falls vorhanden) final_text = formatted_text + quoted_part def update_text(): message_text.delete("1.0", "end") message_text.insert("1.0", final_text) status_var.set("✓ Ihr Text wurde formatiert (Zitat erhalten)") compose_win.after(0, update_text) except Exception as e: def show_error(): status_var.set(f"❌ Fehler: {str(e)}") messagebox.showerror("Fehler", f"Formatierung fehlgeschlagen:\n{str(e)}", parent=compose_win) compose_win.after(0, show_error) threading.Thread(target=worker, daemon=True).start() def insert_image(): """Fügt ein Bild als Platzhalter in den Text ein.""" from tkinter import filedialog # Datei-Dialog für Bildauswahl file_path = filedialog.askopenfilename( parent=compose_win, title="Bild auswählen", filetypes=[ ("Bilddateien", "*.png *.jpg *.jpeg *.gif *.bmp"), ("Alle Dateien", "*.*") ] ) if file_path: # Füge Platzhalter für Bild ein (E-Mail wird später mit Attachment gesendet) import os filename = os.path.basename(file_path) current_pos = message_text.index("insert") message_text.insert(current_pos, f"\n[📷 BILD: {filename}]\n") status_var.set(f"✓ Bild eingefügt: {filename}") def copy_text(): """Kopiert markierten Text in die Zwischenablage.""" try: selected_text = message_text.get("sel.first", "sel.last") compose_win.clipboard_clear() compose_win.clipboard_append(selected_text) status_var.set("✓ Text kopiert") except tk.TclError: # Nichts markiert status_var.set("Kein Text markiert") def paste_text(): """Fügt Text aus der Zwischenablage ein.""" try: clipboard_text = compose_win.clipboard_get() current_pos = message_text.index("insert") message_text.insert(current_pos, clipboard_text) status_var.set("✓ Text eingefügt") except tk.TclError: # Zwischenablage leer status_var.set("Zwischenablage leer") # Tastenkürzel für Copy/Paste (falls nicht automatisch vorhanden) message_text.bind("", lambda e: copy_text()) message_text.bind("", lambda e: paste_text()) message_text.bind("", lambda e: (copy_text(), message_text.delete("sel.first", "sel.last") if message_text.tag_ranges("sel") else None)) # Rechtsklick-Kontextmenü context_menu = tk.Menu(message_text, tearoff=0) context_menu.add_command(label="✂ Ausschneiden (Strg+X)", command=lambda: (copy_text(), message_text.delete("sel.first", "sel.last") if message_text.tag_ranges("sel") else None)) context_menu.add_command(label="📋 Kopieren (Strg+C)", command=copy_text) context_menu.add_command(label="📄 Einfügen (Strg+V)", command=paste_text) context_menu.add_separator() context_menu.add_command(label="🗑 Alles löschen", command=lambda: message_text.delete("1.0", "end")) def show_context_menu(event): try: context_menu.tk_popup(event.x_root, event.y_root) finally: context_menu.grab_release() message_text.bind("", show_context_menu) # Rechtsklick # Buttons (mit Diktat-Button) dictation_btn[0] = tk.Button(btn_frame, text="🎤 Diktat starten", command=start_dictation, bg="#27AE60", fg="#FFFFFF", font=("Segoe UI", 10, "bold"), relief="flat", padx=15, pady=5, cursor="hand2") dictation_btn[0].pack(side="left", padx=(0, 5)) tk.Button(btn_frame, text="✨ KI formatiert", command=format_with_ai, bg="#9B59B6", fg="#FFFFFF", font=("Segoe UI", 10, "bold"), relief="flat", padx=15, pady=5, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_frame, text="📷 Bild", command=insert_image, bg="#E67E22", fg="#FFFFFF", font=("Segoe UI", 10), relief="flat", padx=15, pady=5, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_frame, text="✍ Unterschrift einfügen", command=insert_signature, bg="#3498DB", fg="#FFFFFF", font=("Segoe UI", 10), relief="flat", padx=15, pady=5, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_frame, text="📨 Senden", command=lambda: self._send_reply(compose_win, to_entry, subject_entry, message_text), bg="#2ECC71", fg="#FFFFFF", font=("Segoe UI", 10, "bold"), relief="flat", padx=20, pady=5, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_frame, text="Abbrechen", command=on_close, bg="#95A5A6", fg="#FFFFFF", font=("Segoe UI", 10), relief="flat", padx=20, pady=5, cursor="hand2").pack(side="left", padx=5) # Auch beim X-Button das Widget entfernen compose_win.protocol("WM_DELETE_WINDOW", on_close) def _send_reply(self, window, to_entry_widget, subject_entry_widget, message_text_widget): """Sendet die Antwort-E-Mail (korrigierte Version mit echten Widgets).""" to = to_entry_widget.get().strip() subject = subject_entry_widget.get().strip() body = message_text_widget.get("1.0", "end-1c").strip() # Validierung if not to: messagebox.showwarning("Fehlende Daten", "Bitte geben Sie mindestens einen Empfänger ein.", parent=window) return if not subject: messagebox.showwarning("Fehlende Daten", "Bitte geben Sie einen Betreff ein.", parent=window) return if not body: messagebox.showwarning("Fehlende Daten", "Bitte geben Sie eine Nachricht ein.", parent=window) return # E-Mail-Adresse(n) validieren UND extrahieren import re email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' def extract_email_address(text): """Extrahiert die reine E-Mail-Adresse aus verschiedenen Formaten.""" # Formate: "Name ", "", "email@domain.com" match = re.search(r'<([^>]+)>', text) if match: return match.group(1).strip() return text.strip() # Multiple E-Mails durch Komma/Semikolon getrennt raw_recipients = [addr.strip() for addr in re.split('[,;]', to)] recipients = [extract_email_address(addr) for addr in raw_recipients] invalid_emails = [addr for addr in recipients if not re.match(email_pattern, addr)] if invalid_emails: messagebox.showerror("Ungültige E-Mail-Adresse(n)", f"Folgende E-Mail-Adressen sind ungültig:\n\n" + "\n".join(invalid_emails) + f"\n\nBitte korrigieren Sie diese und versuchen Sie es erneut.", parent=window) return # Senden im Thread def worker(): try: if not self.current_account: self.root.after(0, lambda: messagebox.showerror("Fehler", "Kein E-Mail-Konto konfiguriert!", parent=window)) return # SMTP-Verbindung smtp_server = self.current_account.get("smtp_server", "") smtp_port = int(self.current_account.get("smtp_port", 587)) email_user = self.current_account.get("email", "") email_password = self.current_account.get("_password", "") if not all([smtp_server, smtp_port, email_user, email_password]): self.root.after(0, lambda: messagebox.showerror("Fehler", "E-Mail-Konto unvollständig konfiguriert!\n\nBitte setzen Sie:\n• AZA_EMAIL_PASSWORD_0 (ENV-Variable)\n• SMTP-Server, SMTP-Port, E-Mail-Adresse", parent=window)) return # E-Mail erstellen from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart msg = MIMEMultipart() msg["From"] = email_user msg["To"] = ", ".join(recipients) # Mehrere Empfänger msg["Subject"] = subject msg.attach(MIMEText(body, "plain", "utf-8")) # SMTP senden server = smtplib.SMTP(smtp_server, smtp_port, timeout=10) server.starttls() server.login(email_user, email_password) server.send_message(msg) server.quit() # In Sent-Ordner speichern try: mail = imaplib.IMAP4_SSL( self.current_account.get("imap_server", ""), int(self.current_account.get("imap_port", 993)), timeout=10 ) mail.login(email_user, email_password) sent_folder = self._find_or_create_sent_folder(mail) # E-Mail als RFC822 speichern import email mail.append(sent_folder, '', imaplib.Time2Internaldate(time.time()), msg.as_bytes()) mail.logout() except Exception as e: print(f"DEBUG: Fehler beim Speichern in Sent: {e}") # Erfolg self.root.after(0, lambda: messagebox.showinfo("Erfolg", "E-Mail wurde erfolgreich gesendet!", parent=window)) self.root.after(0, window.destroy) # Cache löschen if "sent" in self.email_cache: del self.email_cache["sent"] if "sent" in self.cache_timestamp: del self.cache_timestamp["sent"] except smtplib.SMTPAuthenticationError: self.root.after(0, lambda: messagebox.showerror("Authentifizierung fehlgeschlagen", "E-Mail-Adresse oder Passwort falsch!\n\nTipps:\n• Bei Gmail: App-Passwort verwenden\n• Passwort überprüfen\n• SMTP-Einstellungen prüfen", parent=window)) except smtplib.SMTPException as e: self.root.after(0, lambda: messagebox.showerror("SMTP-Fehler", f"E-Mail konnte nicht gesendet werden:\n\n{str(e)}\n\nBitte überprüfen Sie:\n• SMTP-Server\n• SMTP-Port\n• Internetverbindung", parent=window)) except Exception as e: self.root.after(0, lambda: messagebox.showerror("Fehler", f"Unerwarteter Fehler:\n\n{str(e)}\n\nBitte kontaktieren Sie den Support.", parent=window)) threading.Thread(target=worker, daemon=True).start() def _dictate_reply(self): """Diktiert eine Antwort-E-Mail.""" if not self.selected_email: messagebox.showinfo("Hinweis", "Bitte wählen Sie zuerst eine E-Mail aus.") return messagebox.showinfo("Diktieren", "Diktat-Funktion:\n\n" "Diese Funktion ermöglicht es Ihnen, eine Antwort zu diktieren.\n\n" "Hinweis: Diese Funktion benötigt Integration mit dem\n" "Hauptprogramm (basis14.py) für Spracherkennung.\n\n" "Verwenden Sie vorerst '↩ Antworten' und dann das\n" "Diktat-Fenster aus dem Hauptprogramm.") def _ai_reply_yes(self): """Generiert eine kurze bejahende KI-Antwort.""" if not self.selected_email: messagebox.showinfo("Hinweis", "Bitte wählen Sie zuerst eine E-Mail aus.") return messagebox.showinfo("KI-Antwort", "KI Ja-Antwort:\n\n" "Diese Funktion wird eine kurze, höfliche und\n" "bejahende Antwort generieren.\n\n" "Beispiel: 'Vielen Dank für Ihre Nachricht.\n" "Ja, das passt sehr gut. Ich bestätige den Termin.\n\n" "Mit freundlichen Grüßen'") def _ai_reply_no(self): """Generiert eine kurze verneinende KI-Antwort.""" if not self.selected_email: messagebox.showinfo("Hinweis", "Bitte wählen Sie zuerst eine E-Mail aus.") return messagebox.showinfo("KI-Antwort", "KI Nein-Antwort:\n\n" "Diese Funktion wird eine kurze, höfliche und\n" "verneinende Antwort generieren.\n\n" "Beispiel: 'Vielen Dank für Ihre Anfrage.\n" "Leider ist dies zu diesem Zeitpunkt nicht möglich.\n\n" "Mit freundlichen Grüßen'") def _ai_summarize(self): """KI-Zusammenfassung der ausgewählten E-Mail.""" if not self.selected_email: messagebox.showinfo("Hinweis", "Bitte wählen Sie zuerst eine E-Mail aus.") return if not self.client: messagebox.showerror("Fehler", "OpenAI API-Schlüssel nicht gefunden.\nBitte .env Datei mit OPENAI_API_KEY erstellen.") return email_body = self.selected_email.get('body', '') if not email_body or len(email_body.strip()) < 50: messagebox.showinfo("Hinweis", "E-Mail ist zu kurz für eine Zusammenfassung.") return # Dialog für Zusammenfassung summary_win = tk.Toplevel(self.root) summary_win.title("📝 KI Zusammenfassung") summary_win.geometry("600x400") summary_win.configure(bg="#FFFFFF") summary_win.transient(self.root) tk.Label(summary_win, text="KI Zusammenfassung", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 14, "bold")).pack(pady=(20, 10), padx=20, anchor="w") status_label = tk.Label(summary_win, text="⏳ Zusammenfassung wird generiert...", bg="#FFFFFF", fg="#666666", font=("Segoe UI", 10)) status_label.pack(pady=10) text_frame = tk.Frame(summary_win, bg="#FFFFFF", padx=20, pady=10) text_frame.pack(fill="both", expand=True) summary_text = scrolledtext.ScrolledText( text_frame, wrap="word", font=("Segoe UI", 10), relief="solid", bd=1, height=12, state="disabled" ) summary_text.pack(fill="both", expand=True) def generate(): try: prompt = f"""Fasse diese E-Mail kurz und prägnant zusammen. Extrahiere die wichtigsten Punkte: E-Mail: Von: {self.selected_email['from']} Betreff: {self.selected_email['subject']} Inhalt: {email_body} Erstelle eine kompakte Zusammenfassung mit: 1. Hauptthema (1 Satz) 2. Wichtigste Punkte (2-3 Stichpunkte) 3. Erforderliche Aktion (falls vorhanden) Antwort auf Deutsch und medizinisch-professionell.""" response = self.client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0.3, max_tokens=300 ) summary = response.choices[0].message.content.strip() def update_ui(): status_label.config(text="✅ Zusammenfassung erstellt") summary_text.configure(state="normal") summary_text.delete("1.0", "end") summary_text.insert("1.0", summary) summary_text.configure(state="disabled") summary_win.after(0, update_ui) except Exception as e: def show_error(): status_label.config(text="❌ Fehler bei der Generierung") messagebox.showerror("Fehler", f"KI-Zusammenfassung fehlgeschlagen:\n{str(e)}", parent=summary_win) summary_win.after(0, show_error) threading.Thread(target=generate, daemon=True).start() tk.Button(summary_win, text="Schließen", command=summary_win.destroy, bg="#95A5A6", fg=self.fg_light, relief="flat", padx=20, pady=8, cursor="hand2").pack(pady=(10, 20)) def _ai_reply_suggestions(self): """Zeigt KI-generierte Antwort-Vorschläge.""" if not self.selected_email: messagebox.showinfo("Hinweis", "Bitte wählen Sie zuerst eine E-Mail aus.") return if not self.client: messagebox.showerror("Fehler", "OpenAI API-Schlüssel nicht gefunden.\nBitte .env Datei mit OPENAI_API_KEY erstellen.") return # Dialog für Antwort-Vorschläge suggestions_win = tk.Toplevel(self.root) suggestions_win.title("💡 KI Antwort-Vorschläge") suggestions_win.geometry("800x900") suggestions_win.configure(bg="#FFFFFF") suggestions_win.transient(self.root) tk.Label(suggestions_win, text="KI Antwort-Vorschläge", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 14, "bold")).pack(pady=(20, 10), padx=20, anchor="w") tk.Label(suggestions_win, text="Wählen Sie einen Vorschlag oder bearbeiten Sie ihn:", bg="#FFFFFF", fg="#666666", font=("Segoe UI", 10)).pack(pady=(0, 10), padx=20, anchor="w") status_label = tk.Label(suggestions_win, text="⏳ KI generiert Antwort-Vorschläge...", bg="#FFFFFF", fg="#666666", font=("Segoe UI", 10)) status_label.pack(pady=10) # Scrollbarer Container für Vorschläge canvas = tk.Canvas(suggestions_win, bg="#FFFFFF", highlightthickness=0) scrollbar = tk.Scrollbar(suggestions_win, orient="vertical", command=canvas.yview) suggestions_frame = tk.Frame(canvas, bg="#FFFFFF") canvas.configure(yscrollcommand=scrollbar.set) scrollbar.pack(side="right", fill="y") canvas.pack(side="left", fill="both", expand=True, padx=(20, 0), pady=10) canvas_frame = canvas.create_window((0, 0), window=suggestions_frame, anchor="nw") def on_frame_configure(event): canvas.configure(scrollregion=canvas.bbox("all")) def on_canvas_configure(event): canvas.itemconfig(canvas_frame, width=event.width) suggestions_frame.bind("", on_frame_configure) canvas.bind("", on_canvas_configure) def generate(): try: email_body = self.selected_email.get('body', '') prompt = f"""Du bist Arzt in einer Praxis. Generiere 6 verschiedene Antwort-Vorschläge auf diese E-Mail: Von: {self.selected_email['from']} Betreff: {self.selected_email['subject']} Inhalt: {email_body} Erstelle 6 Antworten: 1. KURZ & POSITIV: Sehr kurze, freundliche Zusage (1-2 Sätze) 2. AUSFÜHRLICH & POSITIV: Detaillierte, positive Antwort mit allen Details (4-6 Sätze) 3. NEUTRAL: Sachliche, neutrale Antwort (3-4 Sätze) 4. RÜCKFRAGE: Höfliche Nachfrage zu Details (2-3 Sätze) 5. HÖFLICHE ABSAGE: Freundliche, aber bestimmte Ablehnung mit Grund (3-4 Sätze) 6. KURZ & ABLEHNEND: Sehr kurze, höfliche Absage (1-2 Sätze) Format für jede Antwort: [OPTION X] Alle auf Deutsch, höflich und professionell. Keine Grußformel am Ende.""" response = self.client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0.7, max_tokens=1200 ) suggestions_text = response.choices[0].message.content.strip() # Parse die Antworten suggestions = [] parts = suggestions_text.split("[OPTION") for part in parts[1:]: # Überspringe ersten leeren Teil lines = part.strip().split("\n", 1) if len(lines) == 2: suggestions.append(lines[1].strip()) # Kategorien für Farben (Pastellfarben mit Opazität) suggestion_types = [ {"label": "✅ Kurz & Positiv", "bg": "#C8E6C9", "text_bg": "#E8F5E9"}, # Helles Pastellgrün {"label": "✅ Ausführlich & Positiv", "bg": "#B2DFDB", "text_bg": "#E0F2F1"}, # Helles Pastelltürkis {"label": "⚪ Neutral", "bg": "#E0E0E0", "text_bg": "#F5F5F5"}, # Hellgrau {"label": "❓ Rückfrage", "bg": "#BBDEFB", "text_bg": "#E3F2FD"}, # Helles Pastellblau {"label": "❌ Höfliche Absage", "bg": "#FFCCBC", "text_bg": "#FBE9E7"}, # Helles Pastellorange {"label": "❌ Kurz & Ablehnend", "bg": "#FFCDD2", "text_bg": "#FFEBEE"} # Helles Pastellrot ] def update_ui(): status_label.config(text="✅ Vorschläge generiert - Klicken Sie auf einen, um ihn zu verwenden:") for i, suggestion in enumerate(suggestions): if i >= len(suggestion_types): break suggestion_type = suggestion_types[i] # Frame für jeden Vorschlag mit farbigem Hintergrund frame = tk.Frame(suggestions_frame, bg=suggestion_type['bg'], relief="solid", bd=1) frame.pack(fill="x", pady=5) # Header mit Kategorie header_frame = tk.Frame(frame, bg=suggestion_type['bg']) header_frame.pack(fill="x") tk.Label(header_frame, text=suggestion_type['label'], bg=suggestion_type['bg'], fg="#424242", font=("Segoe UI", 10, "bold"), anchor="w", padx=10, pady=8).pack(fill="x") # Text-Widget mit hellerem Hintergrund text_widget = tk.Text(frame, wrap="word", font=("Segoe UI", 10), relief="flat", bd=0, bg=suggestion_type['text_bg'], height=4) text_widget.pack(fill="both", padx=10, pady=5) text_widget.insert("1.0", suggestion) # Button zum Verwenden def use_suggestion(s=suggestion): self._use_reply_suggestion(s) suggestions_win.destroy() btn_frame = tk.Frame(frame, bg=suggestion_type['bg']) btn_frame.pack(fill="x", pady=8, padx=10) tk.Button(btn_frame, text="📧 Verwenden", command=use_suggestion, bg=self.accent_blue, fg=self.fg_light, relief="flat", font=("Segoe UI", 9), padx=15, pady=6, cursor="hand2").pack(side="right") suggestions_win.after(0, update_ui) except Exception as e: def show_error(): status_label.config(text="❌ Fehler bei der Generierung") messagebox.showerror("Fehler", f"KI-Vorschläge fehlgeschlagen:\n{str(e)}", parent=suggestions_win) suggestions_win.after(0, show_error) threading.Thread(target=generate, daemon=True).start() def _use_reply_suggestion(self, suggestion_text): """Verwendet einen KI-Vorschlag für die Antwort.""" if not self.selected_email: return # Öffne Compose-Fenster mit KI-Vorschlag compose_win = tk.Toplevel(self.root) compose_win.title("Antworten mit KI-Vorschlag") compose_win.geometry("700x500") compose_win.configure(bg="#FFFFFF") form_frame = tk.Frame(compose_win, bg="#FFFFFF", padx=20, pady=20) form_frame.pack(fill="both", expand=True) # An tk.Label(form_frame, text="An:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=0, column=0, sticky="w", pady=5) to_entry = tk.Entry(form_frame, font=("Segoe UI", 10), relief="solid", bd=1) to_entry.insert(0, self.selected_email['from']) to_entry.grid(row=0, column=1, sticky="ew", pady=5) # Betreff tk.Label(form_frame, text="Betreff:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=1, column=0, sticky="w", pady=5) subject_entry = tk.Entry(form_frame, font=("Segoe UI", 10), relief="solid", bd=1) original_subject = self.selected_email['subject'] if not original_subject.startswith("Re:"): original_subject = f"Re: {original_subject}" subject_entry.insert(0, original_subject) subject_entry.grid(row=1, column=1, sticky="ew", pady=5) # Nachricht mit KI-Vorschlag tk.Label(form_frame, text="Nachricht:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=2, column=0, sticky="nw", pady=5) text_frame = tk.Frame(form_frame, bg="#FFFFFF") text_frame.grid(row=2, column=1, sticky="nsew", pady=5) message_text = scrolledtext.ScrolledText( text_frame, wrap="word", font=("Segoe UI", self.font_size_compose), relief="solid", bd=1, height=15 ) message_text.pack(fill="both", expand=True) # KI-Vorschlag + automatische Unterschrift bei Antworten full_text = suggestion_text if self.signature_auto_reply and self.signature: full_text += "\n\n" + self.signature message_text.insert("1.0", full_text) form_frame.columnconfigure(1, weight=1) form_frame.rowconfigure(2, weight=1) self.compose_to_entry = to_entry self.compose_subject_entry = subject_entry self.compose_body_text = message_text # WICHTIG: Widget zur Liste hinzufügen für dynamische Updates self.compose_text_widgets.append(message_text) # Beim Schließen des Fensters aus Liste entfernen def on_close(): if message_text in self.compose_text_widgets: self.compose_text_widgets.remove(message_text) compose_win.destroy() # Buttons btn_frame = tk.Frame(compose_win, bg="#FFFFFF", pady=10) btn_frame.pack(fill="x", padx=20) def insert_signature(): """Fügt Unterschrift am Ende ein.""" if self.signature: current_text = message_text.get("1.0", "end-1c") if not current_text.endswith(self.signature): message_text.insert("end", "\n\n" + self.signature) else: messagebox.showinfo("Keine Unterschrift", "Bitte erstellen Sie zuerst eine Unterschrift über den Button '✍ Unterschrift'.", parent=compose_win) tk.Button(btn_frame, text="📨 Senden", command=lambda: self._send_email(compose_win), bg=self.accent_blue, fg=self.fg_light, font=("Segoe UI", 10), relief="flat", padx=20, pady=8, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_frame, text="✍ Unterschrift einfügen", command=insert_signature, bg="#27AE60", fg=self.fg_light, font=("Segoe UI", 10), relief="flat", padx=20, pady=8, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_frame, text="Abbrechen", command=on_close, bg="#95A5A6", fg=self.fg_light, font=("Segoe UI", 10), relief="flat", padx=20, pady=8, cursor="hand2").pack(side="left", padx=5) # Auch beim X-Button das Widget entfernen compose_win.protocol("WM_DELETE_WINDOW", on_close) def _email_templates(self): """Zeigt E-Mail-Vorlagen mit KI-Anpassung.""" templates_win = tk.Toplevel(self.root) templates_win.title("📋 E-Mail-Vorlagen") templates_win.geometry("700x600") templates_win.configure(bg="#FFFFFF") templates_win.transient(self.root) tk.Label(templates_win, text="E-Mail-Vorlagen", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 14, "bold")).pack(pady=(20, 10), padx=20, anchor="w") tk.Label(templates_win, text="Wählen Sie eine Vorlage - KI passt sie automatisch an:", bg="#FFFFFF", fg="#666666", font=("Segoe UI", 10)).pack(pady=(0, 20), padx=20, anchor="w") # Vorlagen-Container templates_frame = tk.Frame(templates_win, bg="#FFFFFF") templates_frame.pack(fill="both", expand=True, padx=20, pady=10) templates = [ { "name": "✅ Terminbestätigung", "type": "termin_bestaetigung", "description": "Bestätigt einen Termin mit Datum, Uhrzeit und Vorbereitung" }, { "name": "📋 Befund mitteilen", "type": "befund", "description": "Teilt Befundergebnisse höflich mit und bietet Besprechung an" }, { "name": "💊 Rezept-Information", "type": "rezept", "description": "Informiert über ausgestelltes Rezept und Abholung" }, { "name": "❌ Terminabsage", "type": "absage", "description": "Sagt Termin höflich ab und bietet Alternativen" }, { "name": "📞 Rückruf-Bitte", "type": "rueckruf", "description": "Bittet um Rückruf zu bestimmten Zeiten" }, { "name": "ℹ Allgemeine Info", "type": "info", "description": "Allgemeine Information über Praxisabläufe" } ] for template in templates: frame = tk.Frame(templates_frame, bg="#F8F9FA", relief="solid", bd=1) frame.pack(fill="x", pady=5) tk.Label(frame, text=template['name'], bg="#F8F9FA", fg=self.fg_dark, font=("Segoe UI", 11, "bold"), anchor="w", padx=15, pady=8).pack(fill="x") tk.Label(frame, text=template['description'], bg="#F8F9FA", fg="#666666", font=("Segoe UI", 9), anchor="w", padx=15, wraplength=600).pack(fill="x", pady=(0, 8)) def use_template(t=template): templates_win.destroy() self._create_email_from_template(t) tk.Button(frame, text="📧 Verwenden", command=use_template, bg=self.accent_blue, fg=self.fg_light, relief="flat", padx=15, pady=6, cursor="hand2").pack(anchor="e", padx=15, pady=(0, 10)) tk.Button(templates_win, text="Abbrechen", command=templates_win.destroy, bg="#95A5A6", fg=self.fg_light, relief="flat", padx=20, pady=8, cursor="hand2").pack(pady=(10, 20)) def _create_email_from_template(self, template): """Erstellt eine E-Mail aus Vorlage mit KI-Anpassung.""" if not self.client: messagebox.showerror("Fehler", "OpenAI API-Schlüssel nicht gefunden.") return # Dialog mit Parametern params_win = tk.Toplevel(self.root) params_win.title(f"📋 {template['name']}") params_win.geometry("500x400") params_win.configure(bg="#FFFFFF") params_win.transient(self.root) tk.Label(params_win, text=template['name'], bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 12, "bold")).pack(pady=(20, 10), padx=20, anchor="w") # Empfänger form_frame = tk.Frame(params_win, bg="#FFFFFF", padx=20, pady=10) form_frame.pack(fill="both", expand=True) tk.Label(form_frame, text="Empfänger:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=0, column=0, sticky="w", pady=5) recipient_entry = tk.Entry(form_frame, font=("Segoe UI", 10), relief="solid", bd=1) if self.selected_email: recipient_entry.insert(0, self.selected_email['from']) recipient_entry.grid(row=0, column=1, sticky="ew", pady=5) # Details (z.B. Datum, Uhrzeit, etc.) tk.Label(form_frame, text="Details/Kontext:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=1, column=0, sticky="nw", pady=5) details_text = scrolledtext.ScrolledText( form_frame, wrap="word", font=("Segoe UI", 10), relief="solid", bd=1, height=8 ) details_text.grid(row=1, column=1, sticky="nsew", pady=5) # Platzhalter-Text je nach Vorlagentyp placeholder = { "termin_bestaetigung": "z.B.: Montag, 17.02.2026, 14:00 Uhr\nBitte nüchtern erscheinen", "befund": "z.B.: Blutwerte sind gut, Cholesterin leicht erhöht", "rezept": "z.B.: Antibiotikum, 7 Tage Einnahme", "absage": "z.B.: Notfall in der Praxis, Alternative: nächste Woche", "rueckruf": "z.B.: Befundbesprechung, vormittags erreichbar", "info": "z.B.: Praxis geschlossen am 20.02, Vertretung: Dr. Müller" }.get(template['type'], "Zusätzliche Informationen...") details_text.insert("1.0", placeholder) form_frame.columnconfigure(1, weight=1) form_frame.rowconfigure(1, weight=1) status_label = tk.Label(params_win, text="", bg="#FFFFFF", fg="#666666", font=("Segoe UI", 9)) status_label.pack(pady=5) def generate_from_template(): recipient = recipient_entry.get().strip() details = details_text.get("1.0", "end-1c").strip() if not recipient: messagebox.showerror("Fehler", "Bitte Empfänger angeben.", parent=params_win) return status_label.config(text="⏳ KI erstellt E-Mail...") params_win.update() def worker(): try: context = "" if self.selected_email: context = f"\n\nKontext (vorherige E-Mail):\n{self.selected_email.get('body', '')[:500]}" template_prompts = { "termin_bestaetigung": f"Erstelle eine professionelle Terminbestätigungs-E-Mail mit: {details}{context}", "befund": f"Erstelle eine einfühlsame E-Mail, die Befundergebnisse mitteilt: {details}{context}", "rezept": f"Erstelle eine E-Mail über ein ausgestelltes Rezept: {details}{context}", "absage": f"Erstelle eine höfliche Terminabsage mit Erklärung und Alternative: {details}{context}", "rueckruf": f"Erstelle eine höfliche Rückruf-Bitte: {details}{context}", "info": f"Erstelle eine informative E-Mail: {details}{context}" } prompt = f"""Du bist Arzt in der Praxis Lindengut AG. {template_prompts.get(template['type'], '')} Erstelle eine professionelle, höfliche E-Mail auf Deutsch. - Direkte Anrede (Sehr geehrte/r ...) - Klare, verständliche Sprache - Freundlicher Ton - Schließe mit: "Mit freundlichen Grüßen\nPraxis Lindengut AG" Nur den E-Mail-Text, keine zusätzlichen Erklärungen.""" response = self.client.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": prompt}], temperature=0.7, max_tokens=500 ) email_content = response.choices[0].message.content.strip() def open_compose(): params_win.destroy() # Compose-Fenster öffnen compose_win = tk.Toplevel(self.root) compose_win.title("Neue E-Mail aus Vorlage") compose_win.geometry("700x500") compose_win.configure(bg="#FFFFFF") form = tk.Frame(compose_win, bg="#FFFFFF", padx=20, pady=20) form.pack(fill="both", expand=True) tk.Label(form, text="An:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=0, column=0, sticky="w", pady=5) to_e = tk.Entry(form, font=("Segoe UI", 10), relief="solid", bd=1) to_e.insert(0, recipient) to_e.grid(row=0, column=1, sticky="ew", pady=5) tk.Label(form, text="Betreff:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=1, column=0, sticky="w", pady=5) subj_e = tk.Entry(form, font=("Segoe UI", 10), relief="solid", bd=1) subj_e.insert(0, template['name'].replace("✅", "").replace("📋", "").replace("💊", "").replace("❌", "").replace("📞", "").replace("ℹ", "").strip()) subj_e.grid(row=1, column=1, sticky="ew", pady=5) tk.Label(form, text="Nachricht:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 10)).grid(row=2, column=0, sticky="nw", pady=5) text_f = tk.Frame(form, bg="#FFFFFF") text_f.grid(row=2, column=1, sticky="nsew", pady=5) msg_text = scrolledtext.ScrolledText( text_f, wrap="word", font=("Segoe UI", 10), relief="solid", bd=1, height=15 ) msg_text.pack(fill="both", expand=True) msg_text.insert("1.0", email_content) form.columnconfigure(1, weight=1) form.rowconfigure(2, weight=1) self.compose_to_entry = to_e self.compose_subject_entry = subj_e self.compose_body_text = msg_text btn_f = tk.Frame(compose_win, bg="#FFFFFF", pady=10) btn_f.pack(fill="x", padx=20) tk.Button(btn_f, text="📨 Senden", command=lambda: self._send_email(compose_win), bg=self.accent_blue, fg=self.fg_light, font=("Segoe UI", 10), relief="flat", padx=20, pady=8, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_f, text="Abbrechen", command=compose_win.destroy, bg="#95A5A6", fg=self.fg_light, font=("Segoe UI", 10), relief="flat", padx=20, pady=8, cursor="hand2").pack(side="left", padx=5) params_win.after(0, open_compose) except Exception as e: def show_error(): status_label.config(text="❌ Fehler") messagebox.showerror("Fehler", f"KI-Vorlage fehlgeschlagen:\n{str(e)}", parent=params_win) params_win.after(0, show_error) threading.Thread(target=worker, daemon=True).start() btn_frame = tk.Frame(params_win, bg="#FFFFFF", pady=10) btn_frame.pack(fill="x", padx=20) tk.Button(btn_frame, text="🤖 KI-E-Mail generieren", command=generate_from_template, bg=self.accent_blue, fg=self.fg_light, relief="flat", padx=20, pady=10, cursor="hand2", font=("Segoe UI", 10, "bold")).pack(pady=10) tk.Button(btn_frame, text="Abbrechen", command=params_win.destroy, bg="#95A5A6", fg=self.fg_light, relief="flat", padx=20, pady=8, cursor="hand2").pack() def _delete_selected(self): """Verschiebt die ausgewählte E-Mail in den Papierkorb (Trash).""" if not self.selected_email: messagebox.showinfo("Hinweis", "Bitte wählen Sie zuerst eine E-Mail aus.") return if not self.current_account: messagebox.showerror("Fehler", "Kein Konto konfiguriert.") return email_id = self.selected_email.get("email_id") if not email_id: messagebox.showerror("Fehler", "E-Mail-ID nicht gefunden.") return if not messagebox.askyesno("Löschen", f"Diese E-Mail in den Papierkorb verschieben?\n\nVon: {self.selected_email['from']}\nBetreff: {self.selected_email['subject']}"): return self.status_var.set("⏳ Verschiebe in Papierkorb...") def worker(): try: acc = self.current_account imap_server = acc.get("imap_server", "") imap_port = acc.get("imap_port", 993) email_addr = acc.get("email", "") password = acc.get("_password", "") # IMAP Verbindung mail = imaplib.IMAP4_SSL(imap_server, imap_port, timeout=10) mail.login(email_addr, password) # Aktuellen Ordner auswählen current_imap = self.folder_mapping.get(self.current_folder, "INBOX") mail.select(f'"{current_imap}"' if " " in current_imap else current_imap) # Papierkorb-Ordner finden trash_folder = self._find_trash_folder(mail) if trash_folder: # E-Mail in Papierkorb kopieren mail.copy(email_id, f'"{trash_folder}"' if " " in trash_folder else trash_folder) # Original E-Mail löschen mail.store(email_id, '+FLAGS', '\\Deleted') mail.expunge() mail.close() mail.logout() # Cache löschen und neu laden cache_key = self.current_folder if cache_key in self.email_cache: del self.email_cache[cache_key] if cache_key in self.cache_timestamp: del self.cache_timestamp[cache_key] self.root.after(0, lambda: self.status_var.set("✓ E-Mail gelöscht")) self.root.after(0, self._fetch_emails) except Exception as e: self.root.after(0, lambda: messagebox.showerror("Fehler", f"Konnte E-Mail nicht löschen:\n{str(e)}")) self.root.after(0, lambda: self.status_var.set("❌ Fehler beim Löschen")) threading.Thread(target=worker, daemon=True).start() def _find_trash_folder(self, mail): """Findet den Papierkorb-Ordner auf dem Server.""" trash_variants = [ "INBOX.Trash", "INBOX.Deleted", "[Gmail]/Trash", "Trash", "Deleted", "Papierkorb", "INBOX.migriert_von_servertown.Deleted Messages" ] for variant in trash_variants: try: if "." in variant or " " in variant: status = mail.select(f'"{variant}"', readonly=True) else: status = mail.select(variant, readonly=True) if status[0] == "OK": return variant except: continue return None def _remove_email_from_list(self, idx): """Entfernt E-Mail aus der lokalen Liste.""" if idx < len(self.emails): del self.emails[idx] self._refresh_email_list() def _html_to_text(self, html): """Konvertiert HTML zu Plain Text.""" if not html: return "" import re # Entferne Script und Style Tags text = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r']*>.*?', '', text, flags=re.DOTALL | re.IGNORECASE) # Ersetze
,

,

mit Zeilenumbrüchen text = re.sub(r'|

|

', '\n', text, flags=re.IGNORECASE) text = re.sub(r'

|
', '\n', text, flags=re.IGNORECASE) # Ersetze Listen text = re.sub(r'
  • ', '\n• ', text, flags=re.IGNORECASE) # Entferne alle HTML-Tags text = re.sub(r'<[^>]+>', '', text) # HTML-Entities dekodieren import html text = html.unescape(text) # Mehrfache Leerzeilen entfernen text = re.sub(r'\n{3,}', '\n\n', text) # Leerzeichen normalisieren text = re.sub(r'[ \t]+', ' ', text) return text.strip() def _format_email_body(self, body): """Formatiert E-Mail-Text für bessere Lesbarkeit (z.B. Visitenkarten).""" if not body: return body import re # 1. URLs erkennen und mit Zeilenumbruch trennen # Pattern für URLs url_pattern = r'(https?://[^\s]+)' # 2. E-Mail-Adressen erkennen email_pattern = r'([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})' # 3. Telefonnummern erkennen (z.B. 052 566 11 01 oder +41 52 566 11 01) phone_pattern = r'(Tel\.?\s*:?\s*[\d\s\+\-\(\)]+)' formatted = body # Ersetze mehrere Spaces durch einen formatted = re.sub(r' {2,}', ' ', formatted) # Füge Zeilenumbrüche vor wichtigen Keywords keywords = [ r'(Tel\.)', r'(Fax\.)', r'(www\.)', r'(http)', r'(Visitenkarte:)', r'(\d{4}\s+[A-ZÄÖÜ])', # PLZ + Ort (z.B. "8400 Winterthur") ] for keyword in keywords: formatted = re.sub(keyword, r'\n\1', formatted) # Füge Zeilenumbruch nach E-Mail-Adressen formatted = re.sub(email_pattern, r'\1\n', formatted) # Entferne doppelte Zeilenumbrüche (max 2) formatted = re.sub(r'\n{3,}', '\n\n', formatted) # Entferne Leerzeichen am Zeilenanfang lines = formatted.split('\n') lines = [line.strip() for line in lines] formatted = '\n'.join(lines) return formatted def _download_attachment(self, attachment): """Lädt einen E-Mail-Anhang direkt in den Download-Ordner herunter.""" # Windows Download-Ordner ermitteln import os downloads_folder = os.path.join(os.path.expanduser("~"), "Downloads") # Sicherstellen, dass der Ordner existiert if not os.path.exists(downloads_folder): os.makedirs(downloads_folder) filename = attachment['filename'] save_path = os.path.join(downloads_folder, filename) # Falls Datei bereits existiert, einen eindeutigen Namen generieren if os.path.exists(save_path): base, ext = os.path.splitext(filename) counter = 1 while os.path.exists(save_path): save_path = os.path.join(downloads_folder, f"{base} ({counter}){ext}") counter += 1 try: # Payload in Datei schreiben with open(save_path, "wb") as f: f.write(attachment['payload']) self.status_var.set(f"✓ Anhang gespeichert: {os.path.basename(save_path)}") messagebox.showinfo("Erfolg", f"Anhang wurde im Download-Ordner gespeichert:\n\n{os.path.basename(save_path)}\n\n({downloads_folder})") except Exception as e: messagebox.showerror("Fehler", f"Anhang konnte nicht gespeichert werden:\n\n{str(e)}") def _mark_as_spam(self): """Markiert die ausgewählte E-Mail als Spam.""" if not self.selected_email: messagebox.showinfo("Hinweis", "Bitte wählen Sie zuerst eine E-Mail aus.") return if not self.current_account: messagebox.showerror("Fehler", "Kein Konto konfiguriert.") return email_id = self.selected_email.get("email_id") if not email_id: messagebox.showerror("Fehler", "E-Mail-ID nicht gefunden.") return if not messagebox.askyesno("Spam", f"Diese E-Mail als Spam markieren?\n\nVon: {self.selected_email['from']}\nBetreff: {self.selected_email['subject']}"): return self.status_var.set("⏳ Verschiebe zu Spam...") def worker(): try: acc = self.current_account imap_server = acc.get("imap_server", "") imap_port = acc.get("imap_port", 993) email_addr = acc.get("email", "") password = acc.get("_password", "") # IMAP Verbindung mail = imaplib.IMAP4_SSL(imap_server, imap_port, timeout=10) mail.login(email_addr, password) # Aktuellen Ordner auswählen current_imap = self.folder_mapping.get(self.current_folder, "INBOX") mail.select(f'"{current_imap}"' if " " in current_imap else current_imap) # Spam-Ordner finden spam_folder = self._find_spam_folder(mail) if not spam_folder: self.root.after(0, lambda: messagebox.showerror("Fehler", "Spam-Ordner nicht gefunden auf dem Server.")) mail.close() mail.logout() return # Zurück zum ursprünglichen Ordner mail.select(f'"{current_imap}"' if " " in current_imap else current_imap) # E-Mail kopieren zum Spam-Ordner mail.copy(email_id, f'"{spam_folder}"' if " " in spam_folder else spam_folder) # Original E-Mail löschen mail.store(email_id, '+FLAGS', '\\Deleted') mail.expunge() mail.close() mail.logout() print(f"DEBUG: E-Mail erfolgreich zu Spam verschoben: {spam_folder}") # Cache löschen - WICHTIG: Beide Ordner (aktuell + spam)! cache_key = self.current_folder if cache_key in self.email_cache: del self.email_cache[cache_key] print(f"DEBUG: Cache gelöscht für: {cache_key}") if cache_key in self.cache_timestamp: del self.cache_timestamp[cache_key] # AUCH Spam-Cache löschen, damit beim Öffnen neu geladen wird! if "spam" in self.email_cache: del self.email_cache["spam"] print(f"DEBUG: Spam-Cache gelöscht") if "spam" in self.cache_timestamp: del self.cache_timestamp["spam"] self.root.after(0, lambda: self.status_var.set("✓ E-Mail als Spam markiert und verschoben")) self.root.after(0, self._fetch_emails) except Exception as e: self.root.after(0, lambda: messagebox.showerror("Fehler", f"Konnte E-Mail nicht als Spam markieren:\n{str(e)}")) self.root.after(0, lambda: self.status_var.set("❌ Fehler beim Spam-Markieren")) threading.Thread(target=worker, daemon=True).start() def _mark_as_not_spam(self): """Markiert eine Spam-E-Mail als kein Spam (verschiebt zu Posteingang).""" if not self.selected_email: messagebox.showinfo("Hinweis", "Bitte wählen Sie zuerst eine E-Mail aus.") return if not self.current_account: messagebox.showerror("Fehler", "Kein Konto konfiguriert.") return # Nur sinnvoll im Spam-Ordner if self.current_folder != "spam": messagebox.showinfo("Hinweis", "Diese Funktion ist nur im Spam-Ordner verfügbar.") return email_id = self.selected_email.get("email_id") if not email_id: messagebox.showerror("Fehler", "E-Mail-ID nicht gefunden.") return if not messagebox.askyesno("Kein Spam", f"Diese E-Mail ist kein Spam?\n\n(Wird zum Posteingang verschoben)\n\nVon: {self.selected_email['from']}\nBetreff: {self.selected_email['subject']}"): return self.status_var.set("⏳ Verschiebe zum Posteingang...") def worker(): try: acc = self.current_account imap_server = acc.get("imap_server", "") imap_port = acc.get("imap_port", 993) email_addr = acc.get("email", "") password = acc.get("_password", "") # IMAP Verbindung mail = imaplib.IMAP4_SSL(imap_server, imap_port, timeout=10) mail.login(email_addr, password) # Spam-Ordner auswählen spam_imap = self.folder_mapping.get("spam", "Junk") mail.select(f'"{spam_imap}"' if " " in spam_imap else spam_imap) # E-Mail zu INBOX kopieren mail.copy(email_id, "INBOX") # Original aus Spam löschen mail.store(email_id, '+FLAGS', '\\Deleted') mail.expunge() mail.close() mail.logout() # Cache löschen und neu laden if "spam" in self.email_cache: del self.email_cache["spam"] if "spam" in self.cache_timestamp: del self.cache_timestamp["spam"] self.root.after(0, lambda: self.status_var.set("✓ E-Mail zum Posteingang verschoben")) self.root.after(0, self._fetch_emails) except Exception as e: self.root.after(0, lambda: messagebox.showerror("Fehler", f"Konnte E-Mail nicht verschieben:\n{str(e)}")) self.root.after(0, lambda: self.status_var.set("❌ Fehler beim Verschieben")) threading.Thread(target=worker, daemon=True).start() def _find_spam_folder(self, mail): """Findet den Spam-Ordner auf dem Server.""" print("DEBUG: Suche Spam-Ordner...") # Priorisiere INBOX.Spam und INBOX.Junk (wie vom Server verlangt) spam_variants = [ "INBOX.Spam", "INBOX.Junk", "[Gmail]/Spam", "Junk", "Spam", "Junk E-Mail", "INBOX.migriert_von_servertown.Spam", "INBOX.migriert_von_servertown.Junk" ] # Liste alle verfügbaren Ordner (für besseres Debugging) try: status, folder_list = mail.list() if status == "OK": available = [f.decode() if isinstance(f, bytes) else f for f in folder_list] print(f"DEBUG: Verfügbare Ordner auf Server:\n{chr(10).join(available[:10])}") # Erste 10 Ordner except Exception as e: print(f"DEBUG: Konnte Ordnerliste nicht abrufen: {e}") for variant in spam_variants: try: # Ordner mit Punkt in Anführungszeichen if "." in variant or " " in variant: status = mail.select(f'"{variant}"', readonly=True) else: status = mail.select(variant, readonly=True) if status[0] == "OK": print(f"DEBUG: ✓ Spam-Ordner gefunden: {variant}") return variant except Exception as e: print(f"DEBUG: ✗ Spam-Ordner '{variant}' nicht verfügbar: {e}") continue # Falls nicht gefunden, versuche zu erstellen print("DEBUG: Kein Spam-Ordner gefunden, versuche zu erstellen...") try: print("DEBUG: Erstelle INBOX.Spam Ordner...") mail.create("INBOX.Spam") # Prüfe ob erfolgreich status = mail.select("INBOX.Spam", readonly=True) if status[0] == "OK": print("DEBUG: ✓ INBOX.Spam erfolgreich erstellt!") return "INBOX.Spam" except Exception as e: print(f"DEBUG: ✗ Konnte Spam-Ordner nicht erstellen: {e}") print("DEBUG: FEHLER - Kein Spam-Ordner verfügbar!") return None def _select_folder(self, folder_id): """Wechselt den Ordner und lädt E-Mails.""" folder_names = { "inbox": "Posteingang", "sent": "Gesendet", "spam": "Spam", "drafts": "Entwürfe", "trash": "Papierkorb" } self.current_folder = folder_id # Header aktualisieren self.email_list_header.configure(text=folder_names.get(folder_id, "Ordner")) # Aktiven Button hervorheben for btn, btn_folder in self.folder_buttons: if btn_folder == folder_id: btn.configure(bg=self.bg_medium) else: btn.configure(bg=self.bg_dark) self.status_var.set(f"{folder_names.get(folder_id, 'Ordner')} wird geladen...") self._fetch_emails() def _manage_signature(self): """Öffnet Unterschriften-Verwaltung.""" sig_win = tk.Toplevel(self.root) sig_win.title("✍ E-Mail-Unterschrift verwalten") sig_win.geometry("900x750") sig_win.configure(bg="#FFFFFF") sig_win.transient(self.root) # Header header_frame = tk.Frame(sig_win, bg="#3498DB", height=60) header_frame.pack(fill="x") header_frame.pack_propagate(False) tk.Label(header_frame, text="✍ E-Mail-Unterschrift", bg="#3498DB", fg="#FFFFFF", font=("Segoe UI", 16, "bold")).pack(pady=15, padx=20, anchor="w") # Haupt-Container main_frame = tk.Frame(sig_win, bg="#FFFFFF", padx=30, pady=20) main_frame.pack(fill="both", expand=True) # Anleitung info_frame = tk.Frame(main_frame, bg="#E8F4F8", relief="solid", bd=1) info_frame.pack(fill="x", pady=(0, 20)) tk.Label(info_frame, text="💡 Tipp: Erstellen Sie eine professionelle E-Mail-Signatur", bg="#E8F4F8", fg="#2C3E50", font=("Segoe UI", 11, "bold"), anchor="w").pack(fill="x", padx=15, pady=(10, 5)) tips_text = """Sie können folgende Elemente einfügen: • Name, Titel und Position • Praxisname und Adresse • Telefon, E-Mail und Website • Öffnungszeiten oder wichtige Hinweise • Bild oder Logo (über Button "📷 Bild hinzufügen") Formatierung: Verwenden Sie Leerzeilen für Abstände, | für Trennzeichen.""" tk.Label(info_frame, text=tips_text, bg="#E8F4F8", fg="#555555", font=("Segoe UI", 9), justify="left", anchor="w").pack(fill="x", padx=15, pady=(0, 10)) # Unterschriften-Editor tk.Label(main_frame, text="Unterschrift bearbeiten:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 11, "bold"), anchor="w").pack(fill="x", pady=(0, 5)) editor_frame = tk.Frame(main_frame, bg="#FFFFFF") editor_frame.pack(fill="both", expand=True, pady=(0, 15)) # Text-Editor mit Scrollbar text_scroll = tk.Scrollbar(editor_frame) text_scroll.pack(side="right", fill="y") sig_text = tk.Text(editor_frame, wrap="word", font=("Segoe UI", 10), relief="solid", bd=1, yscrollcommand=text_scroll.set, height=12) sig_text.pack(side="left", fill="both", expand=True) text_scroll.config(command=sig_text.yview) # Aktuelle Unterschrift laden sig_text.insert("1.0", self.signature) # Button-Leiste für Editor btn_editor_frame = tk.Frame(main_frame, bg="#FFFFFF") btn_editor_frame.pack(fill="x", pady=(0, 15)) def add_template(): """Fügt eine Beispiel-Vorlage ein.""" template = f"""────────────────────────────── Mit freundlichen Grüßen Dr. med. [Ihr Name] [Facharzt für ...] Praxis [Praxisname] [Straße und Hausnummer] [PLZ Stadt] Tel: [Telefonnummer] E-Mail: [E-Mail] Web: [Website] Sprechzeiten: Mo-Fr 8:00-18:00 Uhr ──────────────────────────────""" sig_text.delete("1.0", "end") sig_text.insert("1.0", template) def add_image(): """Fügt ein Bild/Logo zur Unterschrift hinzu.""" file_path = filedialog.askopenfilename( title="Bild/Logo auswählen", filetypes=[ ("Bild-Dateien", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*") ] ) if file_path: # Speichere Bildpfad in Unterschrift current_text = sig_text.get("1.0", "end-1c") sig_text.insert("end", f"\n\n[BILD: {file_path}]") messagebox.showinfo("Bild hinzugefügt", f"Bild wurde zur Unterschrift hinzugefügt:\n{os.path.basename(file_path)}", parent=sig_win) tk.Button(btn_editor_frame, text="📋 Vorlage einfügen", command=add_template, bg="#95A5A6", fg=self.fg_light, relief="flat", font=("Segoe UI", 9), padx=12, pady=6, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_editor_frame, text="📷 Bild hinzufügen", command=add_image, bg="#95A5A6", fg=self.fg_light, relief="flat", font=("Segoe UI", 9), padx=12, pady=6, cursor="hand2").pack(side="left", padx=5) # Automatische Einfüge-Optionen tk.Label(main_frame, text="Automatisches Einfügen:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 11, "bold"), anchor="w").pack(fill="x", pady=(5, 5)) auto_frame = tk.Frame(main_frame, bg="#F8F9FA", relief="solid", bd=1) auto_frame.pack(fill="x", pady=(0, 15)) var_auto_new = tk.BooleanVar(value=self.signature_auto_new) var_auto_reply = tk.BooleanVar(value=self.signature_auto_reply) tk.Checkbutton(auto_frame, text="✅ Unterschrift automatisch bei neuen E-Mails einfügen", variable=var_auto_new, bg="#F8F9FA", fg=self.fg_dark, font=("Segoe UI", 10), anchor="w", selectcolor="#FFFFFF").pack(fill="x", padx=15, pady=8) tk.Checkbutton(auto_frame, text="✅ Unterschrift automatisch bei Antworten einfügen", variable=var_auto_reply, bg="#F8F9FA", fg=self.fg_dark, font=("Segoe UI", 10), anchor="w", selectcolor="#FFFFFF").pack(fill="x", padx=15, pady=(0, 8)) # Vorschau tk.Label(main_frame, text="Vorschau:", bg="#FFFFFF", fg=self.fg_dark, font=("Segoe UI", 11, "bold"), anchor="w").pack(fill="x", pady=(5, 5)) preview_frame = tk.Frame(main_frame, bg="#F0F0F0", relief="solid", bd=1) preview_frame.pack(fill="both", expand=True) preview_text = tk.Text(preview_frame, wrap="word", font=("Segoe UI", 9), relief="flat", bd=0, bg="#F0F0F0", fg="#333333", state="disabled", height=6) preview_text.pack(fill="both", expand=True, padx=10, pady=10) def update_preview(): """Aktualisiert die Vorschau.""" preview_text.config(state="normal") preview_text.delete("1.0", "end") current_sig = sig_text.get("1.0", "end-1c") preview_text.insert("1.0", current_sig) preview_text.config(state="disabled") sig_text.bind("", lambda e: update_preview()) update_preview() # Speichern/Abbrechen btn_frame = tk.Frame(main_frame, bg="#FFFFFF") btn_frame.pack(fill="x", pady=(15, 0)) def save_signature(): """Speichert die Unterschrift.""" self.signature = sig_text.get("1.0", "end-1c") self.signature_auto_new = var_auto_new.get() self.signature_auto_reply = var_auto_reply.get() # Sofort speichern config = load_email_config() config["signature"] = self.signature config["signature_auto_new"] = self.signature_auto_new config["signature_auto_reply"] = self.signature_auto_reply save_email_config(config) messagebox.showinfo("Gespeichert", "Unterschrift wurde erfolgreich gespeichert!", parent=sig_win) sig_win.destroy() tk.Button(btn_frame, text="💾 Speichern", command=save_signature, bg=self.accent_blue, fg=self.fg_light, relief="flat", font=("Segoe UI", 10, "bold"), padx=25, pady=10, cursor="hand2").pack(side="left", padx=5) tk.Button(btn_frame, text="Abbrechen", command=sig_win.destroy, bg="#95A5A6", fg=self.fg_light, relief="flat", font=("Segoe UI", 10), padx=25, pady=10, cursor="hand2").pack(side="left", padx=5) def main(): root = tk.Tk() app = EmailApp(root) root.mainloop() if __name__ == "__main__": main()