diff --git a/mail_checker.py b/mail_checker.py new file mode 100644 index 0000000..eaa064e --- /dev/null +++ b/mail_checker.py @@ -0,0 +1,1048 @@ +import imaplib +import time +import threading +import queue +import json +import os +import logging +import subprocess +import email +import email.header +import email.utils +import tkinter as tk +from tkinter import messagebox, filedialog, ttk +from PIL import Image, ImageDraw +import pystray + +# --- Logging postavljanje --- +LOG_FILE = "mail_checker.log" +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler(LOG_FILE, encoding="utf-8"), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +# --- Konfiguracija --- +CONFIG_FILE = "config.json" +MAX_RETRIES = 3 +RETRY_DELAY = 5 # sekundi između ponovnih pokušaja + +# --- Globalno stanje zaštićeno lockom --- +_state_lock = threading.Lock() +last_unread_counts = {} # {mail_user: count | None} None = još nije provjeravano +is_running = True +tray_icon = None +config_accounts = [] + +# --- Red čekanja za TTS — govori se serijski, ne paralelno --- +_tts_queue: queue.Queue = queue.Queue() + +def _tts_worker(): + """Pozadinska nit koja izvršava TTS poruke jednu po jednu.""" + while True: + item = _tts_queue.get() + if item is None: + break + text, gender = item + voice_variant = "hr+f3" if gender == "Female" else "hr" + try: + subprocess.run( + ["espeak-ng", "-v", voice_variant, "-s", "150", text], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except FileNotFoundError: + logger.warning("espeak-ng nije pronađen.") + finally: + _tts_queue.task_done() + +_tts_thread = threading.Thread(target=_tts_worker, daemon=True) +_tts_thread.start() + + +# --------------------------------------------------------------------------- +# Keyring helper — pada gracefully na keyring bez podrške (npr. headless) +# --------------------------------------------------------------------------- +try: + import keyring + _USE_KEYRING = True +except ImportError: + _USE_KEYRING = False + logger.warning("Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno.") + +KEYRING_SERVICE = "MailChecker" + +def _save_password(user: str, password: str) -> str: + """Sprema lozinku u system keychain ako je dostupan; inače vraća ju za JSON.""" + if _USE_KEYRING: + keyring.set_password(KEYRING_SERVICE, user, password) + return "" # Ne pohranjuj u JSON + return password + +def _get_password(acc: dict) -> str: + """Dohvaća lozinku iz keychaina ili iz JSON zapisa.""" + if _USE_KEYRING: + pwd = keyring.get_password(KEYRING_SERVICE, acc["MAIL_USER"]) + return pwd or "" + return acc.get("MAIL_PASSWORD", "") + + +# --------------------------------------------------------------------------- +# Konfiguracija (load / save) +# --------------------------------------------------------------------------- + +def load_config(): + global config_accounts + if not os.path.exists(CONFIG_FILE): + config_accounts = [] + return + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + config_accounts = data + elif isinstance(data, dict) and data: + config_accounts = [data] + else: + config_accounts = [] + except Exception as exc: + logger.error("Greška pri učitavanju konfiguracije: %s", exc) + config_accounts = [] + + +def save_config(): + """Sprema konfiguraciju — lozinke u keychain ako je dostupan, inače u JSON.""" + to_save = [] + for acc in config_accounts: + if _USE_KEYRING: + # Keychain čuva lozinku — ne treba u JSON + entry = {k: v for k, v in acc.items() if k != "MAIL_PASSWORD"} + else: + # Nema keychaina — lozinka ostaje u JSON (upozorenje je već ispisano) + entry = dict(acc) + to_save.append(entry) + try: + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(to_save, f, indent=4, ensure_ascii=False) + except Exception as exc: + logger.error("Greška pri snimanju konfiguracije: %s", exc) + + +# --------------------------------------------------------------------------- +# Zvuk i TTS — bez shell injection +# --------------------------------------------------------------------------- + +def speak_text(text: str, gender: str = "Female"): + """Dodaje TTS poruku u red čekanja — govori se serijski, ne paralelno.""" + _tts_queue.put((text, gender)) + + +def play_sound_file(file_path: str): + """Reproducira zvučnu datoteku bez shell injection.""" + if not file_path or not os.path.exists(file_path): + return + + def _find_player(candidates: list[str]) -> str | None: + for player in candidates: + try: + subprocess.run(["which", player], check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return player + except subprocess.CalledProcessError: + continue + return None + + def _run(): + ext = file_path.lower().rsplit(".", 1)[-1] + if ext == "wav": + player = _find_player(["aplay", "paplay"]) + if player: + subprocess.run([player, file_path], check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + player = _find_player(["mpv", "ffplay", "paplay"]) + if player == "mpv": + subprocess.run(["mpv", "--no-video", file_path], check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elif player == "ffplay": + subprocess.run(["ffplay", "-nodisp", "-autoexit", file_path], check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elif player: + subprocess.run([player, file_path], check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + threading.Thread(target=_run, daemon=True).start() + + +def _send_notification(title: str, body: str): + """Šalje desktop notifikaciju u zasebnom threadu — greške ne ruše tray.""" + def _run(): + try: + subprocess.run( + ["notify-send", "--expire-time=5000", title, body], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + ) + except FileNotFoundError: + logger.warning("notify-send nije pronađen.") + except Exception as exc: + logger.warning("notify-send greška (ignorirana): %s", exc) + threading.Thread(target=_run, daemon=True).start() + + +# --------------------------------------------------------------------------- +# Hrvatska deklinacija +# --------------------------------------------------------------------------- + +def get_croatian_mail_word(count: int) -> str: + if count % 100 in range(11, 15): + return "poruka" + last = count % 10 + if last == 1: + return "poruku" + if last in (2, 3, 4): + return "poruke" + return "poruka" + + +# --------------------------------------------------------------------------- +# Provjera maila — s retry i ispravnim cleanup-om +# --------------------------------------------------------------------------- + +def _check_single_account(acc: dict) -> int | None: + """ + Provjerava nepročitane mailove za jedan račun. + Vraća broj nepročitanih ili None ako provjera ne uspije ni nakon retry-a. + """ + server = acc["IMAP_SERVER"] + user = acc["MAIL_USER"] + password = _get_password(acc) + + for attempt in range(1, MAX_RETRIES + 1): + mail = None + try: + mail = imaplib.IMAP4_SSL(server, timeout=15) + mail.login(user, password) + mail.select("inbox") + status, response = mail.search(None, "UNSEEN") + if status == "OK": + return len(response[0].split()) + logger.warning("[%s] IMAP search vratio status: %s", user, status) + return None + except imaplib.IMAP4.error as exc: + # Auth greška — nema smisla retry + logger.error("[%s] Auth/IMAP greška: %s", user, exc) + return None + except Exception as exc: + logger.warning("[%s] Pokušaj %d/%d nije uspio: %s", user, attempt, MAX_RETRIES, exc) + if attempt < MAX_RETRIES: + time.sleep(RETRY_DELAY * attempt) # exponential backoff + finally: + if mail: + try: + mail.logout() + except Exception: + pass + + return None + + +def check_all_mails(): + global last_unread_counts, tray_icon, config_accounts + + with _state_lock: + accounts_snapshot = list(config_accounts) + + if not accounts_snapshot: + if tray_icon: + tray_icon.icon = create_icon_image("gray", 0) + tray_icon.title = "Mail Checker: Nema konfiguriranih računa" + return + + total_unread = 0 + any_error = False + + for acc in accounts_snapshot: + user = acc["MAIL_USER"] + sound = acc.get("SOUND_FILE", "") + voice_enabled = acc.get("VOICE_ALERT", True) + voice_gender = acc.get("VOICE_GENDER", "Female") + + current_count = _check_single_account(acc) + + with _state_lock: + previous_count = last_unread_counts.get(user) # None = prva provjera + + if current_count is None: + any_error = True + logger.error("[%s] Provjera neuspješna.", user) + with _state_lock: + prev = last_unread_counts.get(user) + if prev is not None: + total_unread += prev + continue + + with _state_lock: + last_unread_counts[user] = current_count + + total_unread += current_count + + is_first = previous_count is None + + # Koristi nadimak ako postoji, inače dio emaila prije @ + name = acc.get("NICKNAME") or user.split("@")[0] + + if is_first: + # Pokretanje — uvijek obavijesti o trenutnom stanju + if current_count > 0: + word = get_croatian_mail_word(current_count) + _send_notification(f"Mail: {user}", f"Nepročitanih poruka: {current_count}") + if sound: + play_sound_file(sound) + elif voice_enabled: + speak_text(f"{name}, imate {current_count} {word}", gender=voice_gender) + logger.info("[%s] Inicijalna provjera: %d nepročitanih.", user, current_count) + + elif current_count != previous_count: + # Broj se promijenio nakon pokretanja + if current_count > 0: + word = get_croatian_mail_word(current_count) + _send_notification( + f"Mail: {user}", + f"Nova poruka! Ukupno nepročitanih: {current_count}" + ) + if sound: + play_sound_file(sound) + elif voice_enabled: + speak_text( + f"{name}, stigla je nova poruka. Ukupno imate {current_count} {word}", + gender=voice_gender + ) + logger.info("[%s] %d nepročitanih (ranije: %d)", user, current_count, previous_count) + else: + _send_notification(f"Mail: {user}", "Sve poruke su pročitane.") + if voice_enabled: + speak_text(f"{name}, nemate novih poruka.", gender=voice_gender) + logger.info("[%s] Inbox čist.", user) + + # Tray ikona + if tray_icon: + if any_error and total_unread == 0: + tray_icon.icon = create_icon_image("gray", 0) + tray_icon.title = "Mail Checker: Greška veze na nekim računima" + elif total_unread > 0: + tray_icon.icon = create_icon_image("green", total_unread) + tray_icon.title = f"Mail Checker: {total_unread} ukupno nepročitanih" + else: + tray_icon.icon = create_icon_image("blue", 0) + tray_icon.title = "Mail Checker: Inbox čist" + + +# --------------------------------------------------------------------------- +# Background petlja s točnim timerom +# --------------------------------------------------------------------------- + +def background_loop(): + while is_running: + start = time.monotonic() + check_all_mails() + elapsed = time.monotonic() - start + + with _state_lock: + accounts_snapshot = list(config_accounts) + + if accounts_snapshot: + intervals = [acc.get("CHECK_INTERVAL", 60) for acc in accounts_snapshot] + interval = max(10, min(intervals)) # min 10s da ne spammamo server + else: + interval = 60 + + remaining = interval - elapsed + if remaining > 0: + # Kratki sleep u petlji kako bismo mogli reagirati na is_running = False + deadline = time.monotonic() + remaining + while is_running and time.monotonic() < deadline: + time.sleep(1) + + +# --------------------------------------------------------------------------- +# Tray ikona +# --------------------------------------------------------------------------- + +def create_icon_image(color: str = "blue", count: int = 0) -> Image.Image: + image = Image.new("RGB", (64, 64), color) + draw = ImageDraw.Draw(image) + draw.rectangle([12, 18, 52, 46], fill=None, outline="white", width=2) + draw.line([12, 18, 32, 32, 52, 18], fill="white", width=2) + if count > 0: + label = str(count) if count < 100 else "99+" + # Crveni krug u donjem desnom kutu + draw.ellipse([38, 38, 62, 62], fill="red") + # Broj unutar kruga — pozicioniraj ovisno o duljini + tx = 44 if len(label) == 1 else 39 + draw.text((tx, 40), label, fill="white") + return image + + +def on_exit(icon, item): + global is_running + is_running = False + icon.stop() + + +def show_settings_from_tray(icon, item): + threading.Thread(target=open_settings_window, daemon=True).start() + + +# --------------------------------------------------------------------------- +# GUI — Settings prozor (enkapsuliran, bez globalnog GUI stanja) +# --------------------------------------------------------------------------- + +def open_settings_window(): + """Otvara GUI prozor za upravljanje računima.""" + load_config() + + root = tk.Tk() + root.title("Mail Checker – Upravljanje računima") + root.resizable(True, True) + # Prozor se pozicionira na sredinu nakon što tkinter izračuna pravu veličinu + root.update_idletasks() + root.eval("tk::PlaceWindow . center") + + # Lokalna varijabla umjesto globalne + selected_idx: list[int | None] = [None] + + # Lijevi stupac (popis) se širi, desni (formular) ostaje fiksne širine + root.columnconfigure(0, weight=1) + root.columnconfigure(1, weight=0) + root.rowconfigure(0, weight=1) + + # --- Lijeva strana: popis računa --- + frame_list = tk.LabelFrame(root, text=" Registrirani računi ") + frame_list.grid(row=0, column=0, padx=10, pady=10, sticky="nsew") + frame_list.rowconfigure(0, weight=1) + frame_list.columnconfigure(0, weight=1) + + scrollbar_x = tk.Scrollbar(frame_list, orient="horizontal") + account_box = tk.Listbox(frame_list, width=30, height=22, xscrollcommand=scrollbar_x.set) + scrollbar_x.config(command=account_box.xview) + account_box.pack(padx=5, pady=2, fill="both", expand=True) + scrollbar_x.pack(padx=5, pady=2, fill="x") + + frame_order = tk.Frame(frame_list) + frame_order.pack(pady=4) + + def _move(direction: int): + """Pomiče odabrani račun gore (-1) ili dolje (+1).""" + if selected_idx[0] is None: + return + idx = selected_idx[0] + new_idx = idx + direction + if new_idx < 0 or new_idx >= len(config_accounts): + return + with _state_lock: + config_accounts[idx], config_accounts[new_idx] = config_accounts[new_idx], config_accounts[idx] + selected_idx[0] = new_idx + save_config() + refresh_list() + account_box.select_set(new_idx) + account_box.see(new_idx) + + tk.Button(frame_order, text="▲ Gore", width=10, command=lambda: _move(-1)).pack(side="left", padx=4) + tk.Button(frame_order, text="▼ Dolje", width=10, command=lambda: _move(1)).pack(side="left", padx=4) + + def refresh_list(): + account_box.delete(0, tk.END) + for i, acc in enumerate(config_accounts): + nickname = acc.get("NICKNAME") or acc.get("MAIL_USER", "Nepoznato") + label = f"{i+1}. {nickname}" + account_box.insert(tk.END, label) + + # --- Desna strana: formular --- + frame_form = tk.LabelFrame(root, text=" Podaci računa ") + frame_form.grid(row=0, column=1, padx=10, pady=10, sticky="n") + + def _row(label_text: str, row: int, show: str = "") -> tk.Entry: + tk.Label(frame_form, text=label_text).grid(row=row, column=0, padx=8, pady=4, sticky="w") + entry = tk.Entry(frame_form, width=28, show=show) + entry.grid(row=row, column=1, columnspan=2, padx=8, pady=4, sticky="ew") + return entry + + # Redovi 0–5: polja za unos + entry_nickname = _row("Nadimak:", 0) + entry_server = _row("IMAP Server:", 1) + entry_user = _row("Email:", 2) + entry_password = _row("Lozinka:", 3, show="*") + entry_interval = _row("Interval (sek):", 4) + entry_interval.insert(0, "60") + entry_sound = _row("Zvučna datoteka:", 5) + + # Red 6: Browse gumb + def on_browse(): + path = filedialog.askopenfilename( + title="Odaberi zvučnu datoteku", + filetypes=[("Audio", "*.mp3 *.wav"), ("Sve datoteke", "*.*")] + ) + if path: + entry_sound.delete(0, tk.END) + entry_sound.insert(0, path) + + tk.Button(frame_form, text="Pretraži...", command=on_browse).grid( + row=6, column=1, padx=8, pady=2, sticky="w") + + # Red 7: TTS checkbox + radio gumbi spol + var_voice = tk.BooleanVar(value=True) + tk.Checkbutton(frame_form, text="Glasovni alert (TTS)", variable=var_voice).grid( + row=7, column=0, padx=8, pady=6, sticky="w") + + var_gender = tk.StringVar(value="Female") + frame_gender = tk.Frame(frame_form) + frame_gender.grid(row=7, column=1, columnspan=2, sticky="w") + tk.Radiobutton(frame_gender, text="Ženski", variable=var_gender, value="Female").pack(side="left") + tk.Radiobutton(frame_gender, text="Muški", variable=var_gender, value="Male").pack(side="left", padx=6) + + # Red 8: Separator + ttk.Separator(frame_form, orient="horizontal").grid( + row=8, column=0, columnspan=3, sticky="ew", padx=8, pady=6) + + # Red 9: Pregled poruka checkbox + var_preview = tk.BooleanVar(value=True) + tk.Checkbutton(frame_form, text="Prikaži u pregledu poruka", variable=var_preview).grid( + row=9, column=0, columnspan=2, padx=8, pady=2, sticky="w") + + # Red 10: Broj poruka + tk.Label(frame_form, text="Broj poruka u pregledu:").grid( + row=10, column=0, padx=8, pady=4, sticky="w") + entry_preview_count = tk.Entry(frame_form, width=6) + entry_preview_count.insert(0, "5") + entry_preview_count.grid(row=10, column=1, padx=8, pady=4, sticky="w") + + # Red 11: Separator + ttk.Separator(frame_form, orient="horizontal").grid( + row=11, column=0, columnspan=3, sticky="ew", padx=8, pady=6) + + # Red 12–14: Akcijski gumbi + tk.Button(frame_form, text="Dodaj novi račun", width=22, command=lambda: on_add()).grid( + row=12, column=0, columnspan=3, padx=8, pady=4) + tk.Button(frame_form, text="Ažuriraj račun", width=22, fg="blue", command=lambda: on_update()).grid( + row=13, column=0, columnspan=3, padx=8, pady=4) + tk.Button(frame_form, text="Obriši odabrani", width=22, fg="red", command=lambda: on_delete()).grid( + row=14, column=0, columnspan=3, padx=8, pady=4) + + # Red 15: Status label + lbl_status = tk.Label(frame_form, text="", fg="gray") + lbl_status.grid(row=15, column=0, columnspan=3, pady=6) + + def on_select(event): + sel = account_box.curselection() + if not sel: + return + selected_idx[0] = sel[0] + acc = config_accounts[sel[0]] + + entry_nickname.delete(0, tk.END) + entry_nickname.insert(0, acc.get("NICKNAME", "")) + entry_server.delete(0, tk.END) + entry_server.insert(0, acc.get("IMAP_SERVER", "")) + entry_user.delete(0, tk.END) + entry_user.insert(0, acc.get("MAIL_USER", "")) + entry_password.delete(0, tk.END) + entry_password.insert(0, _get_password(acc)) + entry_interval.delete(0, tk.END) + entry_interval.insert(0, str(acc.get("CHECK_INTERVAL", 60))) + entry_sound.delete(0, tk.END) + entry_sound.insert(0, acc.get("SOUND_FILE", "")) + var_voice.set(acc.get("VOICE_ALERT", True)) + var_gender.set(acc.get("VOICE_GENDER", "Female")) + var_preview.set(acc.get("PREVIEW_ENABLED", True)) + entry_preview_count.delete(0, tk.END) + entry_preview_count.insert(0, str(acc.get("PREVIEW_COUNT", 5))) + lbl_status.config(text="") + + account_box.bind("<>", on_select) + refresh_list() + + def _read_form() -> dict | None: + nickname = entry_nickname.get().strip() + server = entry_server.get().strip() + user = entry_user.get().strip() + password = entry_password.get().strip() + interval = entry_interval.get().strip() + sound = entry_sound.get().strip() + + if not all([nickname, server, user, password, interval]): + messagebox.showerror("Greška", "Sva polja moraju biti popunjena (uključujući nadimak)!") + return None + try: + int_val = int(interval) + if int_val < 10: + raise ValueError + except ValueError: + messagebox.showerror("Greška", "Interval mora biti broj ≥ 10!") + return None + + preview_count_str = entry_preview_count.get().strip() + try: + preview_count = int(preview_count_str) + if preview_count < 1: + raise ValueError + except ValueError: + messagebox.showerror("Greška", "Broj poruka za pregled mora biti broj ≥ 1!") + return None + + return { + "NICKNAME": nickname, + "IMAP_SERVER": server, + "MAIL_USER": user, + "MAIL_PASSWORD": password, + "CHECK_INTERVAL": int(interval), + "SOUND_FILE": sound, + "VOICE_ALERT": var_voice.get(), + "VOICE_GENDER": var_gender.get(), + "PREVIEW_ENABLED": var_preview.get(), + "PREVIEW_COUNT": preview_count, + } + + def _test_connection(server: str, user: str, password: str) -> bool: + """Testira IMAP vezu — poziva se iz background threada, GUI se ažurira kroz root.after.""" + root.after(0, lambda: lbl_status.config(text="Testiranje veze...", fg="gray")) + mail = None + try: + mail = imaplib.IMAP4_SSL(server, timeout=10) + mail.login(user, password) + return True + except Exception as exc: + root.after(0, lambda e=exc: lbl_status.config(text=f"Greška: {e}", fg="red")) + return False + finally: + if mail: + try: + mail.logout() + except Exception: + pass + + def _clear_form(): + for entry in (entry_nickname, entry_server, entry_user, entry_password, entry_sound): + entry.delete(0, tk.END) + entry_interval.delete(0, tk.END) + entry_interval.insert(0, "60") + entry_preview_count.delete(0, tk.END) + entry_preview_count.insert(0, "5") + var_preview.set(True) + selected_idx[0] = None + lbl_status.config(text="") + + def on_add(): + data = _read_form() + if not data: + return + if any(a["MAIL_USER"] == data["MAIL_USER"] for a in config_accounts): + messagebox.showerror("Greška", "Račun s ovim emailom već postoji!") + return + if any(a.get("NICKNAME", "").lower() == data["NICKNAME"].lower() for a in config_accounts): + messagebox.showerror("Greška", "Račun s ovim nadimkom već postoji!") + return + + def _do(): + if not _test_connection(data["IMAP_SERVER"], data["MAIL_USER"], data["MAIL_PASSWORD"]): + return + # Spremi lozinku u keychain ako je dostupan, inače ostaje u dict-u za JSON + if _USE_KEYRING: + _save_password(data["MAIL_USER"], data.pop("MAIL_PASSWORD")) + with _state_lock: + config_accounts.append(data) + save_config() + root.after(0, lambda: (refresh_list(), _clear_form(), + lbl_status.config(text="Račun dodan!", fg="green"), + threading.Thread(target=check_all_mails, daemon=True).start())) + + threading.Thread(target=_do, daemon=True).start() + + def on_update(): + if selected_idx[0] is None: + messagebox.showerror("Greška", "Odaberi račun za ažuriranje!") + return + idx = selected_idx[0] + data = _read_form() + if not data: + return + for i, a in enumerate(config_accounts): + if i != idx and a["MAIL_USER"] == data["MAIL_USER"]: + messagebox.showerror("Greška", "Drugi račun s ovim emailom već postoji!") + return + + def _do(): + if not _test_connection(data["IMAP_SERVER"], data["MAIL_USER"], data["MAIL_PASSWORD"]): + return + # Spremi lozinku u keychain ako je dostupan, inače ostaje u dict-u za JSON + if _USE_KEYRING: + _save_password(data["MAIL_USER"], data.pop("MAIL_PASSWORD")) + with _state_lock: + config_accounts[idx] = data + last_unread_counts.pop(data["MAIL_USER"], None) + save_config() + root.after(0, lambda: (refresh_list(), account_box.select_set(idx), + lbl_status.config(text="Račun ažuriran!", fg="green"), + threading.Thread(target=check_all_mails, daemon=True).start())) + + threading.Thread(target=_do, daemon=True).start() + + def on_delete(): + if selected_idx[0] is None: + messagebox.showerror("Greška", "Odaberi račun za brisanje!") + return + idx = selected_idx[0] + user = config_accounts[idx]["MAIL_USER"] + if not messagebox.askyesno("Potvrda", f"Obriši račun {user}?"): + return + + with _state_lock: + config_accounts.pop(idx) + last_unread_counts.pop(user, None) + + if _USE_KEYRING: + try: + keyring.delete_password(KEYRING_SERVICE, user) + except Exception: + pass + + save_config() + refresh_list() + _clear_form() + messagebox.showinfo("Uspjeh", "Račun obrisan.") + threading.Thread(target=check_all_mails, daemon=True).start() + + # Gumbi su već dodani gore u frame_form definiciji + + root.mainloop() + + +# --------------------------------------------------------------------------- +# Pregled nepročitanih poruka +# --------------------------------------------------------------------------- + +def _decode_header(raw: str) -> str: + """Dekodira MIME encoded header (npr. =?UTF-8?...) u čitljiv string.""" + parts = email.header.decode_header(raw or "") + decoded = [] + for part, charset in parts: + if isinstance(part, bytes): + try: + decoded.append(part.decode(charset or "utf-8", errors="replace")) + except Exception: + decoded.append(part.decode("utf-8", errors="replace")) + else: + decoded.append(part) + return "".join(decoded) + + +def _fetch_messages(acc: dict, limit: int = 5) -> list[dict]: + """ + Dohvaća zadnjih `limit` poruka iz inboxa (sve, ne samo nepročitane). + Sortira po datumu — najnovije na vrhu. + Vraća listu dict-ova: {from, subject, date, unread} + """ + server = acc["IMAP_SERVER"] + user = acc["MAIL_USER"] + password = _get_password(acc) + messages = [] + mail_conn = None + try: + mail_conn = imaplib.IMAP4_SSL(server, timeout=15) + mail_conn.login(user, password) + mail_conn.select("inbox", readonly=True) + + # Dohvati sve poruke, sortirane po datumu silazno (najnovije prve) + # Koristimo ALL umjesto UNSEEN + status, response = mail_conn.search(None, "ALL") + if status != "OK" or not response[0]: + return [] + + all_ids = response[0].split() + # Zadnjih `limit` ID-ova — IMAP vraća po redoslijedu dolaska, zadnji = najnoviji + ids_to_fetch = all_ids[-limit:][::-1] + + # Dohvati i listu nepročitanih da možemo označiti + status2, unseen_resp = mail_conn.search(None, "UNSEEN") + unseen_ids = set(unseen_resp[0].split()) if status2 == "OK" else set() + + for msg_id in ids_to_fetch: + try: + status3, data = mail_conn.fetch( + msg_id, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])" + ) + if status3 != "OK" or not data or not data[0]: + continue + raw_headers = data[0][1] + msg = email.message_from_bytes(raw_headers) + + from_raw = msg.get("From", "") + parsed_from = email.utils.parseaddr(from_raw) + sender = parsed_from[0] if parsed_from[0] else parsed_from[1] + sender = _decode_header(sender) or parsed_from[1] + + subject = _decode_header(msg.get("Subject", "(bez naslova)")) + + date_raw = msg.get("Date", "") + try: + dt = email.utils.parsedate_to_datetime(date_raw) + date_str = dt.strftime("%d.%m.%Y %H:%M") + except Exception: + date_str = date_raw[:16] if date_raw else "?" + + is_unread = msg_id in unseen_ids + messages.append({ + "from": sender, + "subject": subject, + "date": date_str, + "unread": is_unread, + }) + except Exception as exc: + logger.warning("[%s] Greška pri dohvatu poruke %s: %s", user, msg_id, exc) + continue + + except Exception as exc: + logger.error("[%s] Greška pri dohvatu poruka: %s", user, exc) + finally: + if mail_conn: + try: + mail_conn.logout() + except Exception: + pass + return messages + + +# Singleton referenca na prozor poruka +_messages_win: list = [None] + +# GUI zadaci koje treba pokrenuti iz glavnog threada (tray callbacks) +_pending_gui: list = [] + + +def open_messages_window(icon=None, item=None): + """Otvara prozor s pregledom poruka. Poziva se iz tray threada — koristi Toplevel.""" + + # Ako je prozor već otvoren, samo ga podigne na vrh + if _messages_win[0] is not None: + try: + _messages_win[0].lift() + _messages_win[0].focus_force() + return + except Exception: + _messages_win[0] = None + + with _state_lock: + accounts = [a for a in config_accounts if a.get("PREVIEW_ENABLED", True)] + + if not accounts: + _send_notification("Mail Checker", "Nema računa s uključenim pregledom poruka.") + return + + def _build_window(): + # Koristimo Toplevel koji dijeli event loop s ostalim tk prozorima + win = tk.Toplevel() + _messages_win[0] = win + win.title("Mail Checker — Pregled poruka") + win.geometry("760x460") + win.resizable(True, True) + + def _on_close(): + _messages_win[0] = None + win.destroy() + win.protocol("WM_DELETE_WINDOW", _on_close) + + btn_frame = tk.Frame(win) + btn_frame.pack(fill="x", padx=10, pady=(8, 0)) + + lbl_loading = tk.Label(btn_frame, text="", fg="gray") + lbl_loading.pack(side="left") + + notebook = ttk.Notebook(win) + notebook.pack(fill="both", expand=True, padx=10, pady=6) + + win.update_idletasks() + # Toplevel nema eval — centriramo ručno + win.update_idletasks() + sw = win.winfo_screenwidth() + sh = win.winfo_screenheight() + ww = win.winfo_width() + wh = win.winfo_height() + x = (sw - ww) // 2 + y = (sh - wh) // 2 + win.geometry(f"+{x}+{y}") + + def _build_tab(name, messages): + """Kreira jedan tab — poziva se iz glavnog threada.""" + frame = tk.Frame(notebook) + notebook.add(frame, text=f" {name} ") + + cols = ("status", "from", "subject", "date") + tree = ttk.Treeview(frame, columns=cols, show="headings", height=12) + tree.heading("status", text="") + tree.heading("from", text="Pošiljatelj") + tree.heading("subject", text="Naslov") + tree.heading("date", text="Datum") + tree.column("status", width=22, anchor="center", stretch=False) + tree.column("from", width=200, anchor="w") + tree.column("subject", width=320, anchor="w") + tree.column("date", width=130, anchor="center") + tree.tag_configure("unread", font=("TkDefaultFont", 9, "bold")) + + vsb = ttk.Scrollbar(frame, orient="vertical", command=tree.yview) + tree.configure(yscrollcommand=vsb.set) + tree.pack(side="left", fill="both", expand=True) + vsb.pack(side="right", fill="y") + + if messages: + for m in messages: + marker = "●" if m["unread"] else "" + tags = ("unread",) if m["unread"] else () + tree.insert("", "end", + values=(marker, m["from"], m["subject"], m["date"]), + tags=tags) + else: + tree.insert("", "end", values=("", "—", "Nema poruka", "—")) + + def _do_refresh(): + """Background thread: dohvati podatke pa ažuriraj GUI kroz win.after.""" + with _state_lock: + accs = [a for a in config_accounts if a.get("PREVIEW_ENABLED", True)] + + win.after(0, lambda: lbl_loading.config(text="Dohvaćanje poruka...", fg="gray")) + + results = [] + for acc in accs: + name = acc.get("NICKNAME") or acc["MAIL_USER"].split("@")[0] + limit = acc.get("PREVIEW_COUNT", 5) + msgs = _fetch_messages(acc, limit=limit) + results.append((name, msgs)) + logger.info("Dohvaćeno %d poruka za %s", len(msgs), name) + + def _finish_ui(): + # Očisti stare tabove + for tab in notebook.tabs(): + notebook.forget(tab) + # Dodaj nove + for name, msgs in results: + _build_tab(name, msgs) + lbl_loading.config(text=f"Zadnje osvježavanje: {time.strftime('%H:%M:%S')}", fg="gray") + + win.after(0, _finish_ui) + + tk.Button( + btn_frame, text="↻ Osvježi", width=10, + command=lambda: threading.Thread(target=_do_refresh, daemon=True).start() + ).pack(side="right") + + # Pokretanje prvog dohvata + threading.Thread(target=_do_refresh, daemon=True).start() + + # Ubaci u pending queue — glavna petlja (setup_tray) će pokrenuti u pravom threadu + _pending_gui.append(_build_window) + + +# --------------------------------------------------------------------------- +# Tray setup i main +# --------------------------------------------------------------------------- + +def show_status(icon=None, item=None): + """Prikazuje notifikaciju s brojem nepročitanih po svakom računu.""" + with _state_lock: + counts = dict(last_unread_counts) + accounts = list(config_accounts) + + if not accounts: + _send_notification("Mail Checker", "Nema konfiguriranih računa.") + return + + lines = [] + total = 0 + for acc in accounts: + user = acc["MAIL_USER"] + name = acc.get("NICKNAME") or user.split("@")[0] + count = counts.get(user) + if count is None: + lines.append(f"{name}: još nije provjereno") + elif count == 0: + lines.append(f"{name}: inbox čist") + else: + word = get_croatian_mail_word(count) + lines.append(f"{name}: {count} {word}") + total += count + + body = "\n".join(lines) + title = f"Mail Checker — ukupno: {total}" if total > 0 else "Mail Checker — inbox čist" + _send_notification(title, body) + + +def setup_tray(): + global tray_icon + + menu = pystray.Menu( + pystray.MenuItem("Provjeri odmah", lambda: threading.Thread( + target=check_all_mails, daemon=True).start()), + pystray.MenuItem("Status", show_status), + pystray.MenuItem("Pregled poruka", open_messages_window), + pystray.MenuItem("Postavke", show_settings_from_tray), + pystray.MenuItem("Izlaz", on_exit), + ) + + load_config() + + color = "blue" if config_accounts else "gray" + title = "Mail Checker" if config_accounts else "Mail Checker: Nema konfiguriranih računa" + + tray_icon = pystray.Icon("mail_checker", create_icon_image(color), title, menu) + + checker_thread = threading.Thread(target=background_loop, daemon=True) + checker_thread.start() + + if not config_accounts: + threading.Thread(target=open_settings_window, daemon=True).start() + + def _process_pending(): + """Procesira GUI zadatke zahtijevane iz tray threada.""" + while _pending_gui: + fn = _pending_gui.pop(0) + try: + fn() + except Exception as exc: + logger.error("Greška pri pokretanju GUI zadatka: %s", exc) + + def _run_tray_with_gui_pump(): + """Pokreće tray u zasebnom threadu, a glavni thread pumpuje GUI zadatke.""" + tray_thread = threading.Thread(target=tray_icon.run, daemon=True) + tray_thread.start() + # Drži glavni thread živ i procesira pending GUI zadatke + import tkinter as _tk + root = _tk.Tk() + root.withdraw() # nevidljiv root — samo drži event loop živ + + def _pump(): + _process_pending() + if is_running: + root.after(200, _pump) + else: + root.destroy() + + root.after(200, _pump) + try: + root.mainloop() + except Exception: + pass + + try: + _run_tray_with_gui_pump() + except Exception as exc: + logger.critical("Tray ikona se srušila: %s", exc) + + +if __name__ == "__main__": + setup_tray()