Files
aza/AzA march 2026 - Kopie (6)/aza_email.py
2026-04-16 13:32:32 +02:00

4369 lines
203 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 (520pt), 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("<Button-1>", lambda e: _apply(_size[0] + 1))
btn_down.bind("<Button-1>", lambda e: _apply(_size[0] - 1))
for w in (btn_up, btn_down):
w.bind("<Enter>", lambda e, ww=w: ww.configure(fg=_fg_hover))
w.bind("<Leave>", 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("<Return>", lambda e: self._on_list_font_change())
list_spinbox.bind("<FocusOut>", 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("<Return>", lambda e: self._on_compose_font_change())
compose_spinbox.bind("<FocusOut>", 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("<Enter>", lambda e: btn.configure(bg=self.accent_hover))
btn.bind("<Leave>", 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("<Enter>", lambda e, b=btn: b.configure(bg=self.bg_medium))
btn.bind("<Leave>", 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(
"<Configure>",
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("<MouseWheel>", _on_mousewheel)
self.email_canvas.bind("<Button-4>", _on_mousewheel_linux_up)
self.email_canvas.bind("<Button-5>", _on_mousewheel_linux_down)
self.email_scrollable_frame.bind("<MouseWheel>", _on_mousewheel)
self.email_scrollable_frame.bind("<Button-4>", _on_mousewheel_linux_up)
self.email_scrollable_frame.bind("<Button-5>", _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("<Configure>", 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("<MouseWheel>", self._mousewheel_callback)
widget.bind("<Button-4>", self._mousewheel_linux_up_callback)
widget.bind("<Button-5>", 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("<Enter>", on_enter)
item_frame.bind("<Leave>", on_leave)
item_frame.bind("<Button-1>", 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("<Button-1>", on_click)
initial_label.bind("<Button-1>", 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("<Button-1>", 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("<Button-1>", 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("<Button-1>", 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("<Button-1>", 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("<Button-1>", 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("<Button-1>", 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("<Button-1>", lambda e: "break") # Verhindert Cursor-Setzen
self.preview_text.bind("<KeyPress>", 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("<Button-3>", 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("<Button-1>", on_listbox_click)
autocomplete_listbox.bind("<ButtonRelease-1>", lambda e: select_contact())
autocomplete_listbox.bind("<Return>", 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("<KeyRelease>", on_key_release)
self.compose_to_entry.bind("<FocusOut>", 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 <email@domain.com>" 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("<KeyRelease>", 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>", "<email@domain.com>", "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("<KeyRelease>", validate_email_field)
to_entry.bind("<FocusOut>", 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("<Control-c>", lambda e: copy_text())
message_text.bind("<Control-v>", lambda e: paste_text())
message_text.bind("<Control-x>", 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("<Button-3>", 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>", "<email@domain.com>", "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("<Configure>", on_frame_configure)
canvas.bind("<Configure>", 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]
<Antworttext>
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'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
# Ersetze <br>, <p>, <div> mit Zeilenumbrüchen
text = re.sub(r'<br\s*/?>|<p>|<div>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'</p>|</div>', '\n', text, flags=re.IGNORECASE)
# Ersetze Listen
text = re.sub(r'<li>', '\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("<KeyRelease>", 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()