4369 lines
203 KiB
Python
4369 lines
203 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
AzA E-Mail - E-Mail Verwaltung für Arztpraxis
|
||
Mailbird-ähnliche Oberfläche mit IMAP/SMTP Support und KI-Features
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox, scrolledtext, simpledialog, filedialog
|
||
from datetime import datetime
|
||
import threading
|
||
import imaplib
|
||
import smtplib
|
||
import time
|
||
import email
|
||
from email.mime.text import MIMEText
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.header import decode_header
|
||
from openai import OpenAI
|
||
from dotenv import load_dotenv
|
||
from PIL import Image, ImageDraw, ImageTk
|
||
import tempfile
|
||
import wave
|
||
import io
|
||
|
||
|
||
# Konfigurationsdatei
|
||
CONFIG_FILENAME = "aza_email_config.json"
|
||
|
||
def _config_path():
|
||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), CONFIG_FILENAME)
|
||
|
||
|
||
def _get_account_password(index: int) -> str:
|
||
"""Lädt E-Mail-Passwort aus ENV (AZA_EMAIL_PASSWORD_0, _1, ...)."""
|
||
return os.getenv(f"AZA_EMAIL_PASSWORD_{index}", "").strip()
|
||
|
||
|
||
def _strip_passwords(accounts: list) -> list:
|
||
"""Entfernt alle Passwort-Felder aus Account-Daten vor dem Speichern."""
|
||
cleaned = []
|
||
for acc in accounts:
|
||
acc_copy = {k: v for k, v in acc.items() if k not in ("password", "_password")}
|
||
cleaned.append(acc_copy)
|
||
return cleaned
|
||
|
||
|
||
def _check_plaintext_migration(accounts: list) -> list:
|
||
"""Prüft ob Klartext-Passwörter in der Config vorhanden sind.
|
||
Gibt Liste der betroffenen E-Mail-Adressen zurück."""
|
||
affected = []
|
||
for acc in accounts:
|
||
if acc.get("password", "").strip():
|
||
affected.append(acc.get("email", f"Konto {len(affected)}"))
|
||
return affected
|
||
|
||
|
||
def load_email_config():
|
||
"""Lädt E-Mail Konfiguration (Fenstergeometrie, Konten).
|
||
Passwörter werden aus ENV geladen, NICHT aus der JSON-Datei."""
|
||
try:
|
||
path = _config_path()
|
||
if os.path.isfile(path):
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
|
||
plaintext = _check_plaintext_migration(data.get("accounts", []))
|
||
if plaintext:
|
||
print(
|
||
"SICHERHEITSWARNUNG: Klartext-Passwoerter in "
|
||
f"{CONFIG_FILENAME} gefunden!\n"
|
||
"Betroffene Konten: " + ", ".join(plaintext) + "\n"
|
||
"Bitte entfernen und stattdessen ENV-Variablen setzen:\n"
|
||
+ "\n".join(
|
||
f" AZA_EMAIL_PASSWORD_{i}"
|
||
for i in range(len(plaintext))
|
||
) + "\n"
|
||
"Die Passwoerter in der Datei werden IGNORIERT.",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
for i, acc in enumerate(data.get("accounts", [])):
|
||
acc.pop("password", None)
|
||
env_pw = _get_account_password(i)
|
||
if env_pw:
|
||
acc["_password"] = env_pw
|
||
|
||
return data
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
|
||
def save_email_config(data):
|
||
"""Speichert E-Mail Konfiguration. Passwörter werden NIE geschrieben."""
|
||
try:
|
||
path = _config_path()
|
||
safe_data = dict(data)
|
||
if "accounts" in safe_data:
|
||
safe_data["accounts"] = _strip_passwords(safe_data["accounts"])
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(safe_data, f, indent=2, ensure_ascii=False)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ========== Textfeld-Schriftgröße mit ▲▼-Pfeilen ==========
|
||
_FONT_SIZE_SETTINGS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "aza_email_font_sizes.json")
|
||
|
||
def _load_font_sizes():
|
||
try:
|
||
if os.path.isfile(_FONT_SIZE_SETTINGS_FILE):
|
||
with open(_FONT_SIZE_SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
def _save_font_size(key, size):
|
||
data = _load_font_sizes()
|
||
data[key] = size
|
||
try:
|
||
with open(_FONT_SIZE_SETTINGS_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2)
|
||
except Exception:
|
||
pass
|
||
|
||
def add_text_font_size_control(parent_frame, text_widget, initial_size=10, label="Aa", bg_color="#F5FCFF", save_key=None):
|
||
"""▲▼-Pfeile für Textfeld-Schriftgröße (5–20pt), unauffällig im Hintergrund."""
|
||
if save_key:
|
||
saved = _load_font_sizes().get(save_key)
|
||
if saved is not None:
|
||
initial_size = int(saved)
|
||
_size = [max(5, min(20, initial_size))]
|
||
_fg = "#8AAFC0"
|
||
_fg_hover = "#1a4d6d"
|
||
cf = tk.Frame(parent_frame, bg=bg_color, highlightthickness=0, bd=0)
|
||
cf.pack(side="right", padx=4)
|
||
tk.Label(cf, text=label, font=("Segoe UI", 8), bg=bg_color, fg=_fg).pack(side="left", padx=(0, 1))
|
||
size_lbl = tk.Label(cf, text=str(_size[0]), font=("Segoe UI", 8), bg=bg_color, fg=_fg, width=2, anchor="center")
|
||
size_lbl.pack(side="left")
|
||
def _apply(ns):
|
||
ns = max(5, min(20, ns))
|
||
_size[0] = ns
|
||
size_lbl.configure(text=str(ns))
|
||
text_widget.configure(font=("Segoe UI", ns))
|
||
if save_key:
|
||
_save_font_size(save_key, ns)
|
||
text_widget.configure(font=("Segoe UI", _size[0]))
|
||
btn_up = tk.Label(cf, text="\u25B2", font=("Segoe UI", 7), bg=bg_color, fg=_fg, cursor="hand2", bd=0, highlightthickness=0)
|
||
btn_up.pack(side="left", padx=(2, 0))
|
||
btn_down = tk.Label(cf, text="\u25BC", font=("Segoe UI", 7), bg=bg_color, fg=_fg, cursor="hand2", bd=0, highlightthickness=0)
|
||
btn_down.pack(side="left")
|
||
btn_up.bind("<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()
|