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()