From d92adbe9dc79b3f2abd4e1504c80acb7117ca5c0 Mon Sep 17 00:00:00 2001 From: Spider Date: Sun, 17 May 2026 22:50:05 +0200 Subject: [PATCH] First --- .mail_checker.key | 1 + app_settings.json | 5 + config.json | 22 + lang_de.json | 97 +++ lang_en.json | 97 +++ lang_hr.json | 97 +++ mail_checker.1 | 1242 ++++++++++++++++++++++++++++++++++++++ mail_checker.log | 262 ++++++++ mail_checker.py.2 | 1369 ++++++++++++++++++++++++++++++++++++++++++ mail_checker_qt.py | 1414 ++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 4606 insertions(+) create mode 100644 .mail_checker.key create mode 100644 app_settings.json create mode 100644 config.json create mode 100644 lang_de.json create mode 100644 lang_en.json create mode 100644 lang_hr.json create mode 100644 mail_checker.1 create mode 100644 mail_checker.log create mode 100644 mail_checker.py.2 create mode 100644 mail_checker_qt.py diff --git a/.mail_checker.key b/.mail_checker.key new file mode 100644 index 0000000..d831b94 --- /dev/null +++ b/.mail_checker.key @@ -0,0 +1 @@ +dECv26IE8i4T0ZvYWgexKVFjpuQHKbUaEEOxygL9HPw= \ No newline at end of file diff --git a/app_settings.json b/app_settings.json new file mode 100644 index 0000000..4e2ec2f --- /dev/null +++ b/app_settings.json @@ -0,0 +1,5 @@ +{ + "language": "Hrvatski", + "VOICE_ALERT": true, + "VOICE_GENDER": "Female" +} \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..126d710 --- /dev/null +++ b/config.json @@ -0,0 +1,22 @@ +[ + { + "NICKNAME": "Dado", + "IMAP_SERVER": "mail.haustor.eu", + "MAIL_USER": "davor@haustor.eu", + "MAIL_PASSWORD": "gAAAAABqCilBc82m-DaOLiEw9hcUJjoEzuuZERMYhSyrQc1-4U41SUcWNnCsXgvhw8ztqZrcj3vxpEdQXzGJkqUgAukrpCsvHw==", + "CHECK_INTERVAL": 60, + "SOUND_FILE": "", + "PREVIEW_ENABLED": true, + "PREVIEW_COUNT": 10 + }, + { + "NICKNAME": "Dado Sesvete", + "IMAP_SERVER": "mail.sesvete.org", + "MAIL_USER": "davor@sesvete.org", + "MAIL_PASSWORD": "gAAAAABqCilEe-Ww5lilxhPlXdF4Fbd-WoUMJljenIYWLfyJQcYw38iS6rdkqhCEVHXT0Zv-Ifm0eycDeh81PiMeYOdW_X-eyw==", + "CHECK_INTERVAL": 60, + "SOUND_FILE": "", + "PREVIEW_ENABLED": false, + "PREVIEW_COUNT": 5 + } +] \ No newline at end of file diff --git a/lang_de.json b/lang_de.json new file mode 100644 index 0000000..b49318a --- /dev/null +++ b/lang_de.json @@ -0,0 +1,97 @@ +{ + "_language_name": "Deutsch", + "_language_code": "de", + "app_name": "Mail Checker", + "tray_no_accounts": "Keine Konten konfiguriert", + "tray_error": "Verbindungsfehler bei einigen Konten", + "tray_unread": "{total} ungelesene Nachrichten", + "tray_clear": "Posteingang leer", + "menu_check_now": "Jetzt prüfen", + "menu_status": "Status", + "menu_messages": "Nachrichten anzeigen", + "menu_settings": "Einstellungen", + "menu_exit": "Beenden", + "notify_unread": "Ungelesene Nachrichten: {count}", + "notify_new": "Neue Nachricht! Gesamt: {count}", + "notify_clear": "Alle Nachrichten gelesen.", + "notify_no_accounts": "Keine Konten konfiguriert.", + "notify_no_preview_accounts": "Keine Konten mit aktivierter Vorschau.", + "tts_initial": "{name}, Sie haben {count} {word}", + "tts_new": "{name}, eine neue Nachricht ist angekommen. Sie haben insgesamt {count} {word}", + "tts_clear": "{name}, Sie haben keine neuen Nachrichten.", + "tts_word_1": "Nachricht", + "tts_word_234": "Nachrichten", + "tts_word_other": "Nachrichten", + "settings_title": "Mail Checker — Konten verwalten", + "settings_accounts_label": "Registrierte Konten", + "settings_btn_up": "Hoch", + "settings_btn_down": "Runter", + "settings_group_details": "Kontodetails", + "settings_nickname": "Spitzname:", + "settings_server": "IMAP-Server:", + "settings_email": "E-Mail:", + "settings_password": "Passwort:", + "settings_interval": "Intervall:", + "settings_interval_suffix": " Sek", + "settings_sound": "Audiodatei:", + "settings_browse": "Durchsuchen...", + "settings_voice_alert": "Sprachbenachrichtigung (TTS)", + "settings_voice_female": "Weiblich", + "settings_voice_male": "Männlich", + "settings_preview_enabled": "In Nachrichtenvorschau anzeigen", + "settings_preview_count": "Anzahl Nachrichten in Vorschau:", + "settings_language": "Sprache:", + "settings_btn_add": "Neues Konto hinzufügen", + "settings_btn_update": "Konto aktualisieren", + "settings_btn_delete": "Ausgewähltes löschen", + "settings_testing": "Verbindung testen...", + "settings_saved": "Gespeichert!", + "settings_err_fields": "Alle Felder müssen ausgefüllt sein!", + "settings_err_email_exists": "Ein Konto mit dieser E-Mail existiert bereits!", + "settings_err_nickname_exists": "Ein Konto mit diesem Spitznamen existiert bereits!", + "settings_err_email_other": "Ein anderes Konto mit dieser E-Mail existiert bereits!", + "settings_err_no_selection_update": "Bitte wählen Sie ein Konto zum Aktualisieren!", + "settings_err_no_selection_delete": "Bitte wählen Sie ein Konto zum Löschen!", + "settings_confirm_delete": "Konto {user} löschen?", + "settings_confirm_title": "Bestätigung", + "settings_sound_dialog_title": "Audiodatei auswählen", + "settings_sound_filter": "Audiodateien (*.mp3 *.wav);;Alle Dateien (*)", + "messages_title": "Mail Checker — Nachrichtenvorschau", + "messages_loading": "Nachrichten werden geladen...", + "messages_refresh": "Aktualisieren", + "messages_no_accounts": "Keine Konten mit aktivierter Vorschau.", + "messages_last_refresh": "Letzte Aktualisierung: {time}", + "messages_col_status": "", + "messages_col_from": "Absender", + "messages_col_subject": "Betreff", + "messages_col_date": "Datum", + "messages_no_messages": "Keine Nachrichten", + "messages_btn_read": "Nachricht lesen", + "messages_btn_delete": "Löschen", + "messages_warn_select": "Bitte wählen Sie eine Nachricht.", + "messages_confirm_delete": "Diese Nachricht löschen?", + "messages_err_delete": "Löschen fehlgeschlagen.", + "read_title": "Nachricht — {subject}", + "read_from": "Von:", + "read_subject": "Betreff:", + "read_date": "Datum:", + "read_loading": "Inhalt wird geladen...", + "read_btn_browser": "Im Browser öffnen", + "read_btn_delete": "Nachricht löschen", + "read_deleting": "Wird gelöscht...", + "read_deleted": "Nachricht gelöscht.", + "read_err_delete": "Fehler beim Löschen!", + "read_confirm_delete": "Diese Nachricht löschen?", + "read_tab_plain": "Nur-Text", + "read_tab_html": "HTML", + "read_tab_empty": "Nachricht", + "read_empty": "(Leerer Inhalt)", + "read_err_browser": "Browser kann nicht geöffnet werden: {error}", + "status_not_checked": "noch nicht geprüft", + "status_clear": "Posteingang leer", + "status_total": "Mail Checker — gesamt: {total}", + "status_all_clear": "Mail Checker — Posteingang leer", + "tab_accounts": "Konten", + "tab_general": "Allgemein", + "tab_about": "Über" +} \ No newline at end of file diff --git a/lang_en.json b/lang_en.json new file mode 100644 index 0000000..3ce5e32 --- /dev/null +++ b/lang_en.json @@ -0,0 +1,97 @@ +{ + "_language_name": "English", + "_language_code": "en", + "app_name": "Mail Checker", + "tray_no_accounts": "No accounts configured", + "tray_error": "Connection error on some accounts", + "tray_unread": "{total} unread messages", + "tray_clear": "Inbox clear", + "menu_check_now": "Check now", + "menu_status": "Status", + "menu_messages": "View messages", + "menu_settings": "Settings", + "menu_exit": "Exit", + "notify_unread": "Unread messages: {count}", + "notify_new": "New message! Total: {count}", + "notify_clear": "All messages read.", + "notify_no_accounts": "No accounts configured.", + "notify_no_preview_accounts": "No accounts with preview enabled.", + "tts_initial": "{name}, you have {count} {word}", + "tts_new": "{name}, a new message arrived. You have {count} {word} total", + "tts_clear": "{name}, you have no new messages.", + "tts_word_1": "message", + "tts_word_234": "messages", + "tts_word_other": "messages", + "settings_title": "Mail Checker — Manage Accounts", + "settings_accounts_label": "Registered accounts", + "settings_btn_up": "Up", + "settings_btn_down": "Down", + "settings_group_details": "Account details", + "settings_nickname": "Nickname:", + "settings_server": "IMAP Server:", + "settings_email": "Email:", + "settings_password": "Password:", + "settings_interval": "Interval:", + "settings_interval_suffix": " sec", + "settings_sound": "Sound file:", + "settings_browse": "Browse...", + "settings_voice_alert": "Voice alert (TTS)", + "settings_voice_female": "Female", + "settings_voice_male": "Male", + "settings_preview_enabled": "Show in message preview", + "settings_preview_count": "Number of messages in preview:", + "settings_language": "Interface language:", + "settings_btn_add": "Add new account", + "settings_btn_update": "Update account", + "settings_btn_delete": "Delete selected", + "settings_testing": "Testing connection...", + "settings_saved": "Saved!", + "settings_err_fields": "All fields must be filled in!", + "settings_err_email_exists": "An account with this email already exists!", + "settings_err_nickname_exists": "An account with this nickname already exists!", + "settings_err_email_other": "Another account with this email already exists!", + "settings_err_no_selection_update": "Please select an account to update!", + "settings_err_no_selection_delete": "Please select an account to delete!", + "settings_confirm_delete": "Delete account {user}?", + "settings_confirm_title": "Confirm", + "settings_sound_dialog_title": "Select sound file", + "settings_sound_filter": "Audio files (*.mp3 *.wav);;All files (*)", + "messages_title": "Mail Checker — Message preview", + "messages_loading": "Fetching messages...", + "messages_refresh": "Refresh", + "messages_no_accounts": "No accounts with preview enabled.", + "messages_last_refresh": "Last refresh: {time}", + "messages_col_status": "", + "messages_col_from": "Sender", + "messages_col_subject": "Subject", + "messages_col_date": "Date", + "messages_no_messages": "No messages", + "messages_btn_read": "Read message", + "messages_btn_delete": "Delete", + "messages_warn_select": "Please select a message.", + "messages_confirm_delete": "Delete this message?", + "messages_err_delete": "Deletion failed.", + "read_title": "Message — {subject}", + "read_from": "From:", + "read_subject": "Subject:", + "read_date": "Date:", + "read_loading": "Fetching content...", + "read_btn_browser": "Open in browser", + "read_btn_delete": "Delete message", + "read_deleting": "Deleting...", + "read_deleted": "Message deleted.", + "read_err_delete": "Error deleting message!", + "read_confirm_delete": "Delete this message?", + "read_tab_plain": "Plain text", + "read_tab_html": "HTML", + "read_tab_empty": "Message", + "read_empty": "(Empty content)", + "read_err_browser": "Cannot open browser: {error}", + "status_not_checked": "not checked yet", + "status_clear": "inbox clear", + "status_total": "Mail Checker — total: {total}", + "status_all_clear": "Mail Checker — inbox clear", + "tab_accounts": "Accounts", + "tab_general": "General", + "tab_about": "About" +} \ No newline at end of file diff --git a/lang_hr.json b/lang_hr.json new file mode 100644 index 0000000..0f784f0 --- /dev/null +++ b/lang_hr.json @@ -0,0 +1,97 @@ +{ + "_language_name": "Hrvatski", + "_language_code": "hr", + "app_name": "Mail Checker", + "tray_no_accounts": "Nema konfiguriranih računa", + "tray_error": "Greška veze na nekim računima", + "tray_unread": "{total} ukupno nepročitanih", + "tray_clear": "Inbox čist", + "menu_check_now": "Provjeri odmah", + "menu_status": "Status", + "menu_messages": "Pregled poruka", + "menu_settings": "Postavke", + "menu_exit": "Izlaz", + "notify_unread": "Nepročitanih poruka: {count}", + "notify_new": "Nova poruka! Ukupno: {count}", + "notify_clear": "Sve poruke su pročitane.", + "notify_no_accounts": "Nema konfiguriranih računa.", + "notify_no_preview_accounts": "Nema računa s uključenim pregledom poruka.", + "tts_initial": "{name}, imate {count} {word}", + "tts_new": "{name}, stigla je nova poruka. Ukupno imate {count} {word}", + "tts_clear": "{name}, nemate novih poruka.", + "tts_word_1": "poruku", + "tts_word_234": "poruke", + "tts_word_other": "poruka", + "settings_title": "Mail Checker — Upravljanje računima", + "settings_accounts_label": "Registrirani računi", + "settings_btn_up": "Gore", + "settings_btn_down": "Dolje", + "settings_group_details": "Podaci računa", + "settings_nickname": "Nadimak:", + "settings_server": "IMAP Server:", + "settings_email": "Email:", + "settings_password": "Lozinka:", + "settings_interval": "Interval:", + "settings_interval_suffix": " sek", + "settings_sound": "Zvučna datoteka:", + "settings_browse": "Pretraži...", + "settings_voice_alert": "Glasovni alert (TTS)", + "settings_voice_female": "Ženski", + "settings_voice_male": "Muški", + "settings_preview_enabled": "Prikaži u pregledu poruka", + "settings_preview_count": "Broj poruka u pregledu:", + "settings_language": "Jezik sučelja:", + "settings_btn_add": "Dodaj novi račun", + "settings_btn_update": "Ažuriraj račun", + "settings_btn_delete": "Obriši odabrani", + "settings_testing": "Testiranje veze...", + "settings_saved": "Spremljeno!", + "settings_err_fields": "Sva polja moraju biti popunjena!", + "settings_err_email_exists": "Račun s ovim emailom već postoji!", + "settings_err_nickname_exists": "Račun s ovim nadimkom već postoji!", + "settings_err_email_other": "Drugi račun s ovim emailom već postoji!", + "settings_err_no_selection_update": "Odaberi račun za ažuriranje!", + "settings_err_no_selection_delete": "Odaberi račun za brisanje!", + "settings_confirm_delete": "Obriši račun {user}?", + "settings_confirm_title": "Potvrda", + "settings_sound_dialog_title": "Odaberi zvučnu datoteku", + "settings_sound_filter": "Audio datoteke (*.mp3 *.wav);;Sve datoteke (*)", + "messages_title": "Mail Checker — Pregled poruka", + "messages_loading": "Dohvaćanje poruka...", + "messages_refresh": "Osvježi", + "messages_no_accounts": "Nema računa s uključenim pregledom.", + "messages_last_refresh": "Zadnje osvježavanje: {time}", + "messages_col_status": "", + "messages_col_from": "Pošiljatelj", + "messages_col_subject": "Naslov", + "messages_col_date": "Datum", + "messages_no_messages": "Nema poruka", + "messages_btn_read": "Čitaj poruku", + "messages_btn_delete": "Obriši", + "messages_warn_select": "Odaberi poruku.", + "messages_confirm_delete": "Obriši ovu poruku?", + "messages_err_delete": "Brisanje nije uspjelo.", + "read_title": "Poruka — {subject}", + "read_from": "Od:", + "read_subject": "Naslov:", + "read_date": "Datum:", + "read_loading": "Dohvaćanje sadržaja...", + "read_btn_browser": "Otvori u browseru", + "read_btn_delete": "Obriši poruku", + "read_deleting": "Brisanje...", + "read_deleted": "Poruka obrisana.", + "read_err_delete": "Greška pri brisanju!", + "read_confirm_delete": "Obriši ovu poruku?", + "read_tab_plain": "Plain text", + "read_tab_html": "HTML", + "read_tab_empty": "Poruka", + "read_empty": "(Prazan sadržaj)", + "read_err_browser": "Ne mogu otvoriti browser: {error}", + "status_not_checked": "još nije provjereno", + "status_clear": "inbox čist", + "status_total": "Mail Checker — ukupno: {total}", + "status_all_clear": "Mail Checker — inbox čist", + "tab_accounts": "Računi", + "tab_general": "Opće", + "tab_about": "O programu" +} \ No newline at end of file diff --git a/mail_checker.1 b/mail_checker.1 new file mode 100644 index 0000000..f72b498 --- /dev/null +++ b/mail_checker.1 @@ -0,0 +1,1242 @@ +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({ + "msg_id": msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id), + "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 + + +def _fetch_body(acc: dict, msg_id: str) -> str: + """Dohvaća tijelo poruke (plain text). Označava poruku kao pročitanu.""" + server = acc["IMAP_SERVER"] + password = _get_password(acc) + mail_conn = None + try: + mail_conn = imaplib.IMAP4_SSL(server, timeout=15) + mail_conn.login(acc["MAIL_USER"], password) + mail_conn.select("inbox") # bez readonly — da može označiti kao pročitano + status, data = mail_conn.fetch(msg_id.encode(), "(RFC822)") + if status != "OK" or not data or not data[0]: + return "(Nije moguće dohvatiti sadržaj poruke)" + raw = data[0][1] + msg = email.message_from_bytes(raw) + body = "" + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + cd = str(part.get("Content-Disposition", "")) + if ct == "text/plain" and "attachment" not in cd: + charset = part.get_content_charset() or "utf-8" + try: + body = part.get_payload(decode=True).decode(charset, errors="replace") + except Exception: + body = part.get_payload(decode=True).decode("utf-8", errors="replace") + break + if not body: + # Fallback na HTML ako nema plain text + for part in msg.walk(): + if part.get_content_type() == "text/html": + charset = part.get_content_charset() or "utf-8" + html = part.get_payload(decode=True).decode(charset, errors="replace") + import re + body = re.sub(r"<[^>]+>", "", html) + break + else: + charset = msg.get_content_charset() or "utf-8" + try: + body = msg.get_payload(decode=True).decode(charset, errors="replace") + except Exception: + body = str(msg.get_payload()) + return body.strip() or "(Prazan sadržaj)" + except Exception as exc: + logger.error("Greška pri dohvatu tijela poruke: %s", exc) + return f"(Greška: {exc})" + finally: + if mail_conn: + try: + mail_conn.logout() + except Exception: + pass + + +def _delete_message(acc: dict, msg_id: str) -> bool: + """Premješta poruku u Trash (označava \Deleted i expunge).""" + server = acc["IMAP_SERVER"] + password = _get_password(acc) + mail_conn = None + try: + mail_conn = imaplib.IMAP4_SSL(server, timeout=15) + mail_conn.login(acc["MAIL_USER"], password) + mail_conn.select("inbox") + # Pokušaj premjestiti u Trash (Gmail / standardni IMAP) + trash_folders = ["[Gmail]/Trash", "Trash", "INBOX.Trash", "Deleted Items", "Deleted Messages"] + moved = False + for folder in trash_folders: + try: + result = mail_conn.copy(msg_id.encode(), folder) + if result[0] == "OK": + moved = True + break + except Exception: + continue + # Označi kao obrisano i expunge + mail_conn.store(msg_id.encode(), "+FLAGS", "\Deleted") + mail_conn.expunge() + return True + except Exception as exc: + logger.error("Greška pri brisanju poruke: %s", exc) + return False + finally: + if mail_conn: + try: + mail_conn.logout() + except Exception: + pass + + +def _open_read_window(acc: dict, msg: dict, on_deleted=None): + """Otvara prozor za čitanje poruke.""" + read_win = tk.Toplevel() + read_win.title(f"Poruka — {msg['subject']}") + read_win.geometry("700x500") + read_win.resizable(True, True) + + # Zaglavlje + hdr = tk.Frame(read_win, bd=1, relief="groove") + hdr.pack(fill="x", padx=10, pady=(10, 4)) + tk.Label(hdr, text=f"Od: {msg['from']}", anchor="w").pack(fill="x", padx=6, pady=1) + tk.Label(hdr, text=f"Naslov: {msg['subject']}", anchor="w").pack(fill="x", padx=6, pady=1) + tk.Label(hdr, text=f"Datum: {msg['date']}", anchor="w").pack(fill="x", padx=6, pady=(1, 4)) + + # Tijelo — Text widget s scrollbarom + body_frame = tk.Frame(read_win) + body_frame.pack(fill="both", expand=True, padx=10, pady=4) + txt = tk.Text(body_frame, wrap="word", state="disabled", font=("TkDefaultFont", 10)) + vsb = ttk.Scrollbar(body_frame, command=txt.yview) + txt.configure(yscrollcommand=vsb.set) + txt.pack(side="left", fill="both", expand=True) + vsb.pack(side="right", fill="y") + + lbl_status = tk.Label(read_win, text="Dohvaćanje sadržaja...", fg="gray") + lbl_status.pack(pady=4) + + # Gumb za brisanje + def _do_delete(): + if not messagebox.askyesno("Potvrda", "Obriši ovu poruku?", parent=read_win): + return + lbl_status.config(text="Brisanje...", fg="gray") + def _bg(): + ok = _delete_message(acc, msg["msg_id"]) + def _ui(): + if ok: + lbl_status.config(text="Poruka obrisana.", fg="green") + read_win.after(800, read_win.destroy) + if on_deleted: + on_deleted() + else: + lbl_status.config(text="Greška pri brisanju!", fg="red") + read_win.after(0, _ui) + threading.Thread(target=_bg, daemon=True).start() + + tk.Button(read_win, text="🗑 Obriši poruku", fg="red", command=_do_delete).pack(pady=(0, 8)) + + # Dohvati tijelo u backgroundu + def _load_body(): + body = _fetch_body(acc, msg["msg_id"]) + def _show(): + txt.configure(state="normal") + txt.delete("1.0", "end") + txt.insert("end", body) + txt.configure(state="disabled") + lbl_status.config(text="") + read_win.after(0, _show) + + threading.Thread(target=_load_body, daemon=True).start() + + +# 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, acc_ref): + """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) + + # Donji frame s gumbima + action_frame = tk.Frame(frame) + action_frame.pack(side="bottom", fill="x", pady=4) + + # Pohrani poruke po iid za brzi lookup + msg_map = {} + + if messages: + for m in messages: + marker = "●" if m["unread"] else "" + tags = ("unread",) if m["unread"] else () + iid = tree.insert("", "end", + values=(marker, m["from"], m["subject"], m["date"]), + tags=tags) + msg_map[iid] = m + else: + tree.insert("", "end", values=("", "—", "Nema poruka", "—")) + + tree.pack(side="left", fill="both", expand=True) + vsb.pack(side="right", fill="y") + + def _get_selected_msg(): + sel = tree.selection() + if not sel: + return None + return msg_map.get(sel[0]) + + def _on_double_click(event): + m = _get_selected_msg() + if m and "msg_id" in m: + _open_read_window(acc_ref, m, + on_deleted=lambda: threading.Thread(target=_do_refresh, daemon=True).start()) + + def _on_delete_btn(): + m = _get_selected_msg() + if not m or "msg_id" not in m: + messagebox.showwarning("Upozorenje", "Odaberi poruku za brisanje.", parent=win) + return + if not messagebox.askyesno("Potvrda", "Obriši poruku: {m['subject']}?", parent=win): + return + def _bg(): + ok = _delete_message(acc_ref, m["msg_id"]) + if ok: + win.after(0, lambda: threading.Thread( + target=_do_refresh, daemon=True).start()) + else: + win.after(0, lambda: messagebox.showerror( + "Greška", "Brisanje nije uspjelo.", parent=win)) + threading.Thread(target=_bg, daemon=True).start() + + tree.bind("", _on_double_click) + + tk.Button(action_frame, text="📖 Čitaj poruku", width=16, + command=lambda: _on_double_click(None)).pack(side="left", padx=8) + tk.Button(action_frame, text="🗑 Obriši", width=12, fg="red", + command=_on_delete_btn).pack(side="left", padx=4) + + 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, acc)) + 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, acc_ref in results: + _build_tab(name, msgs, acc_ref) + 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() diff --git a/mail_checker.log b/mail_checker.log new file mode 100644 index 0000000..cbcaaa3 --- /dev/null +++ b/mail_checker.log @@ -0,0 +1,262 @@ +2026-05-16 12:52:52,807 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 12:52:53,064 [INFO] [davor@haustor.eu] Inicijalna provjera: 16 nepročitanih. +2026-05-16 12:53:03,280 [ERROR] [davor@haustor.eu] Auth/IMAP greška: b'[AUTHENTICATIONFAILED] Authentication failed.' +2026-05-16 12:53:03,311 [ERROR] [davor@haustor.eu] Provjera neuspješna. +2026-05-16 12:53:55,095 [ERROR] [davor@haustor.eu] Auth/IMAP greška: b'[AUTHENTICATIONFAILED] Authentication failed.' +2026-05-16 12:53:55,125 [ERROR] [davor@haustor.eu] Provjera neuspješna. +2026-05-16 12:54:55,283 [ERROR] [davor@haustor.eu] Auth/IMAP greška: b'[AUTHENTICATIONFAILED] Authentication failed.' +2026-05-16 12:54:55,314 [ERROR] [davor@haustor.eu] Provjera neuspješna. +2026-05-16 12:55:19,532 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 12:56:09,231 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 12:56:11,382 [ERROR] [davor@haustor.eu] Auth/IMAP greška: b'[AUTHENTICATIONFAILED] Authentication failed.' +2026-05-16 12:56:11,409 [ERROR] [davor@haustor.eu] Provjera neuspješna. +2026-05-16 12:56:41,147 [ERROR] [davor@haustor.eu] Auth/IMAP greška: b'[AUTHENTICATIONFAILED] Authentication failed.' +2026-05-16 12:56:41,174 [ERROR] [davor@haustor.eu] Provjera neuspješna. +2026-05-16 12:56:42,882 [CRITICAL] Tray ikona se srušila: g-dbus-error-quark: GDBus.Error:org.freedesktop.DBus.Error.NoReply: Message recipient disconnected from message bus without replying (4) +2026-05-16 12:56:46,240 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 12:56:48,419 [ERROR] [davor@haustor.eu] Auth/IMAP greška: b'[AUTHENTICATIONFAILED] Authentication failed.' +2026-05-16 12:56:48,450 [ERROR] [davor@haustor.eu] Provjera neuspješna. +2026-05-16 12:56:53,275 [ERROR] [davor@haustor.eu] Auth/IMAP greška: b'[AUTHENTICATIONFAILED] Authentication failed.' +2026-05-16 12:56:53,301 [ERROR] [davor@haustor.eu] Provjera neuspješna. +2026-05-16 12:57:10,381 [ERROR] [davor@haustor.eu] Auth/IMAP greška: b'[AUTHENTICATIONFAILED] Authentication failed.' +2026-05-16 12:57:10,411 [ERROR] [davor@haustor.eu] Provjera neuspješna. +2026-05-16 12:57:48,125 [ERROR] [davor@haustor.eu] Auth/IMAP greška: b'[AUTHENTICATIONFAILED] Authentication failed.' +2026-05-16 12:57:48,157 [ERROR] [davor@haustor.eu] Provjera neuspješna. +2026-05-16 12:58:40,643 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 12:58:42,637 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 12:58:44,290 [ERROR] [davor@haustor.eu] Auth/IMAP greška: b'[AUTHENTICATIONFAILED] Authentication failed.' +2026-05-16 12:58:44,315 [ERROR] [davor@haustor.eu] Provjera neuspješna. +2026-05-16 12:58:57,895 [INFO] [davor@haustor.eu] Inicijalna provjera: 16 nepročitanih. +2026-05-16 12:59:02,619 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 12:59:05,211 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 12:59:05,476 [INFO] [davor@haustor.eu] Inicijalna provjera: 16 nepročitanih. +2026-05-16 13:00:26,338 [INFO] [davor@haustor.eu] Inicijalna provjera: 16 nepročitanih. +2026-05-16 13:02:18,499 [CRITICAL] Tray ikona se srušila: g-dbus-error-quark: GDBus.Error:org.freedesktop.DBus.Error.NoReply: Message recipient disconnected from message bus without replying (4) +2026-05-16 13:02:24,908 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 13:02:25,258 [INFO] [davor@haustor.eu] Inicijalna provjera: 16 nepročitanih. +2026-05-16 13:04:58,794 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 13:05:26,478 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 13:05:26,821 [INFO] [davor@haustor.eu] Inicijalna provjera: 16 nepročitanih. +2026-05-16 13:05:49,691 [INFO] [davor@sesvete.org] Inicijalna provjera: 1541 nepročitanih. +2026-05-16 13:05:56,139 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 13:05:58,919 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 13:05:59,254 [INFO] [davor@haustor.eu] Inicijalna provjera: 16 nepročitanih. +2026-05-16 13:05:59,509 [INFO] [davor@sesvete.org] Inicijalna provjera: 1541 nepročitanih. +2026-05-16 13:07:42,332 [CRITICAL] Tray ikona se srušila: g-dbus-error-quark: GDBus.Error:org.freedesktop.DBus.Error.NoReply: Message recipient disconnected from message bus without replying (4) +2026-05-16 13:08:04,301 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 13:08:04,636 [INFO] [davor@haustor.eu] Inicijalna provjera: 16 nepročitanih. +2026-05-16 13:08:04,935 [INFO] [davor@sesvete.org] Inicijalna provjera: 1541 nepročitanih. +2026-05-16 13:13:07,587 [INFO] [davor@sesvete.org] 1530 nepročitanih (ranije: 1541) +2026-05-16 13:14:08,246 [INFO] [davor@sesvete.org] 1164 nepročitanih (ranije: 1530) +2026-05-16 13:15:08,923 [INFO] [davor@sesvete.org] 715 nepročitanih (ranije: 1164) +2026-05-16 13:16:09,572 [INFO] [davor@sesvete.org] 640 nepročitanih (ranije: 715) +2026-05-16 13:19:10,873 [INFO] [davor@haustor.eu] 17 nepročitanih (ranije: 16) +2026-05-16 13:19:11,129 [INFO] [davor@sesvete.org] 641 nepročitanih (ranije: 640) +2026-05-16 13:30:09,984 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 13:30:13,195 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 13:30:13,547 [INFO] [davor@haustor.eu] Inicijalna provjera: 17 nepročitanih. +2026-05-16 13:30:13,802 [INFO] [davor@sesvete.org] Inicijalna provjera: 641 nepročitanih. +2026-05-16 13:50:23,698 [INFO] [davor@haustor.eu] 7 nepročitanih (ranije: 17) +2026-05-16 15:17:08,421 [INFO] [davor@haustor.eu] 8 nepročitanih (ranije: 7) +2026-05-16 15:32:19,932 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 15:32:24,210 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 15:32:24,595 [INFO] [davor@haustor.eu] Inicijalna provjera: 8 nepročitanih. +2026-05-16 15:32:24,860 [INFO] [davor@sesvete.org] Inicijalna provjera: 641 nepročitanih. +2026-05-16 15:34:24,659 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 15:34:31,788 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 15:34:32,263 [INFO] [davor@haustor.eu] Inicijalna provjera: 8 nepročitanih. +2026-05-16 15:34:32,585 [INFO] [davor@sesvete.org] Inicijalna provjera: 641 nepročitanih. +2026-05-16 17:09:21,283 [INFO] [davor@haustor.eu] 9 nepročitanih (ranije: 8) +2026-05-16 17:09:21,576 [INFO] [davor@sesvete.org] 642 nepročitanih (ranije: 641) +2026-05-16 17:10:22,167 [INFO] [davor@sesvete.org] 641 nepročitanih (ranije: 642) +2026-05-16 17:23:28,630 [INFO] [davor@haustor.eu] 10 nepročitanih (ranije: 9) +2026-05-16 17:24:29,283 [INFO] [davor@haustor.eu] 2 nepročitanih (ranije: 10) +2026-05-16 17:25:29,944 [INFO] [davor@haustor.eu] Inbox čist. +2026-05-16 17:25:37,056 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 17:25:40,267 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 17:25:40,518 [INFO] [davor@haustor.eu] Inicijalna provjera: 0 nepročitanih. +2026-05-16 17:25:40,876 [INFO] [davor@sesvete.org] Inicijalna provjera: 641 nepročitanih. +2026-05-16 17:26:41,491 [INFO] [davor@sesvete.org] Inbox čist. +2026-05-16 17:51:36,123 [CRITICAL] Tray ikona se srušila: g-dbus-error-quark: GDBus.Error:org.freedesktop.DBus.Error.NoReply: Message recipient disconnected from message bus without replying (4) +2026-05-16 17:52:23,017 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 17:52:23,286 [INFO] [davor@haustor.eu] Inicijalna provjera: 0 nepročitanih. +2026-05-16 17:52:23,520 [INFO] [davor@sesvete.org] Inicijalna provjera: 0 nepročitanih. +2026-05-16 17:53:58,306 [CRITICAL] Tray ikona se srušila: g-dbus-error-quark: GDBus.Error:org.freedesktop.DBus.Error.NoReply: Message recipient disconnected from message bus without replying (4) +2026-05-16 17:55:03,425 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 17:55:03,777 [INFO] [davor@haustor.eu] Inicijalna provjera: 1 nepročitanih. +2026-05-16 17:55:04,023 [INFO] [davor@sesvete.org] Inicijalna provjera: 0 nepročitanih. +2026-05-16 17:57:42,954 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 17:57:53,666 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 17:57:54,014 [INFO] [davor@haustor.eu] Inicijalna provjera: 1 nepročitanih. +2026-05-16 17:57:54,258 [INFO] [davor@sesvete.org] Inicijalna provjera: 0 nepročitanih. +2026-05-16 17:59:45,737 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 18:00:26,913 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 18:00:27,279 [INFO] [davor@haustor.eu] Inicijalna provjera: 1 nepročitanih. +2026-05-16 18:00:27,522 [INFO] [davor@sesvete.org] Inicijalna provjera: 0 nepročitanih. +2026-05-16 18:01:36,114 [CRITICAL] Tray ikona se srušila: g-dbus-error-quark: GDBus.Error:org.freedesktop.DBus.Error.NoReply: Message recipient disconnected from message bus without replying (4) +2026-05-16 18:02:31,426 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 18:02:31,745 [INFO] [davor@haustor.eu] Inicijalna provjera: 1 nepročitanih. +2026-05-16 18:02:32,009 [INFO] [davor@sesvete.org] Inicijalna provjera: 0 nepročitanih. +2026-05-16 18:04:20,494 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 18:04:28,322 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 18:04:28,622 [INFO] [davor@haustor.eu] Inicijalna provjera: 1 nepročitanih. +2026-05-16 18:04:28,891 [INFO] [davor@sesvete.org] Inicijalna provjera: 0 nepročitanih. +2026-05-16 18:04:36,606 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 18:04:39,947 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 18:04:40,322 [INFO] [davor@haustor.eu] Inicijalna provjera: 1 nepročitanih. +2026-05-16 18:04:40,590 [INFO] [davor@sesvete.org] Inicijalna provjera: 0 nepročitanih. +2026-05-16 18:05:06,760 [CRITICAL] Tray ikona se srušila: notification-daemon-error-quark: GDBus.Error:org.gtk.GDBus.UnmappedGError.Quark._notification_2ddaemon_2derror_2dquark.Code100: 0 is not a valid notification ID (100) +2026-05-16 18:06:44,687 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 18:06:45,004 [INFO] [davor@haustor.eu] Inicijalna provjera: 1 nepročitanih. +2026-05-16 18:06:45,233 [INFO] [davor@sesvete.org] Inicijalna provjera: 0 nepročitanih. +2026-05-16 18:06:47,797 [ERROR] Greška pri pokretanju GUI zadatka: 'Toplevel' object has no attribute 'eval' +2026-05-16 18:06:58,458 [ERROR] Greška pri pokretanju GUI zadatka: 'Toplevel' object has no attribute 'eval' +2026-05-16 18:07:13,101 [INFO] [davor@haustor.eu] Inicijalna provjera: 1 nepročitanih. +2026-05-16 18:07:19,425 [ERROR] Greška pri pokretanju GUI zadatka: 'Toplevel' object has no attribute 'eval' +2026-05-16 18:08:47,638 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 18:08:47,965 [INFO] [davor@haustor.eu] Inicijalna provjera: 1 nepročitanih. +2026-05-16 18:08:48,231 [INFO] [davor@sesvete.org] Inicijalna provjera: 0 nepročitanih. +2026-05-16 18:08:50,765 [INFO] Dohvaćeno 5 poruka za Dado +2026-05-16 18:08:50,999 [INFO] Dohvaćeno 0 poruka za davor +2026-05-16 18:08:56,977 [INFO] Dohvaćeno 5 poruka za Dado +2026-05-16 18:08:57,220 [INFO] Dohvaćeno 0 poruka za davor +2026-05-16 18:09:13,861 [INFO] Dohvaćeno 5 poruka za Dado +2026-05-16 18:09:14,120 [INFO] Dohvaćeno 0 poruka za davor +2026-05-16 19:16:52,712 [INFO] Dohvaćeno 5 poruka za Dado +2026-05-16 19:16:52,953 [INFO] Dohvaćeno 0 poruka za davor +2026-05-16 19:21:06,853 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-16 19:21:07,184 [INFO] [davor@haustor.eu] Inicijalna provjera: 1 nepročitanih. +2026-05-16 19:21:07,422 [INFO] [davor@sesvete.org] Inicijalna provjera: 0 nepročitanih. +2026-05-16 19:21:11,642 [INFO] Dohvaćeno 5 poruka za Dado +2026-05-16 19:21:11,910 [INFO] Dohvaćeno 0 poruka za davor +2026-05-16 19:21:34,244 [INFO] Dohvaćeno 5 poruka za Dado +2026-05-16 19:21:34,513 [INFO] Dohvaćeno 0 poruka za davor +2026-05-16 19:22:04,008 [INFO] Dohvaćeno 5 poruka za Dado +2026-05-16 19:22:04,275 [INFO] Dohvaćeno 0 poruka za davor +2026-05-16 19:22:07,710 [INFO] [davor@haustor.eu] Inbox čist. +2026-05-16 19:22:33,744 [INFO] [davor@haustor.eu] Inicijalna provjera: 0 nepročitanih. +2026-05-16 19:22:39,016 [INFO] Dohvaćeno 49 poruka za Dado +2026-05-16 19:22:39,285 [INFO] Dohvaćeno 0 poruka za davor +2026-05-17 09:00:14,301 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-17 09:00:14,714 [INFO] [davor@haustor.eu] Inicijalna provjera: 5 nepročitanih. +2026-05-17 09:00:15,187 [INFO] [davor@sesvete.org] Inicijalna provjera: 5 nepročitanih. +2026-05-17 09:07:04,536 [WARNING] Biblioteka 'keyring' nije instalirana. Lozinke se čuvaju lokalno. +2026-05-17 09:07:04,881 [INFO] [davor@haustor.eu] Inicijalna provjera: 5 nepročitanih. +2026-05-17 09:07:05,150 [INFO] [davor@sesvete.org] Inicijalna provjera: 5 nepročitanih. +2026-05-17 09:07:10,422 [INFO] Dohvaćeno 55 poruka za Dado +2026-05-17 09:07:10,876 [INFO] Dohvaćeno 5 poruka za davor +2026-05-17 09:07:18,736 [INFO] Dohvaćeno 54 poruka za Dado +2026-05-17 09:07:19,181 [INFO] Dohvaćeno 5 poruka za davor +2026-05-17 09:07:24,285 [INFO] Dohvaćeno 53 poruka za Dado +2026-05-17 09:07:24,687 [INFO] Dohvaćeno 5 poruka za davor +2026-05-17 09:07:29,130 [INFO] Dohvaćeno 52 poruka za Dado +2026-05-17 09:07:29,600 [INFO] Dohvaćeno 5 poruka za davor +2026-05-17 09:07:33,713 [INFO] Dohvaćeno 51 poruka za Dado +2026-05-17 09:07:34,165 [INFO] Dohvaćeno 5 poruka za davor +2026-05-17 09:07:42,602 [INFO] Dohvaćeno 51 poruka za Dado +2026-05-17 09:07:42,980 [INFO] Dohvaćeno 4 poruka za davor +2026-05-17 09:07:49,415 [INFO] Dohvaćeno 51 poruka za Dado +2026-05-17 09:07:49,807 [INFO] Dohvaćeno 3 poruka za davor +2026-05-17 09:07:55,203 [INFO] Dohvaćeno 51 poruka za Dado +2026-05-17 09:07:55,564 [INFO] Dohvaćeno 2 poruka za davor +2026-05-17 09:08:01,294 [INFO] Dohvaćeno 51 poruka za Dado +2026-05-17 09:08:01,589 [INFO] Dohvaćeno 1 poruka za davor +2026-05-17 09:08:05,447 [INFO] [davor@haustor.eu] 2 nepročitanih (ranije: 5) +2026-05-17 09:08:05,707 [INFO] [davor@sesvete.org] 1 nepročitanih (ranije: 5) +2026-05-17 10:12:25,199 [INFO] [davor@haustor.eu] 1 nepročitanih (ranije: 2) +2026-05-17 11:23:32,576 [INFO] Dohvaćeno 51 poruka za Dado +2026-05-17 11:23:35,163 [INFO] Dohvaćeno 1 poruka za davor +2026-05-17 11:23:42,381 [INFO] Dohvaćeno 51 poruka za Dado +2026-05-17 11:23:45,006 [INFO] Dohvaćeno 1 poruka za davor +2026-05-17 11:25:44,754 [INFO] Dohvaćeno 51 poruka za Dado +2026-05-17 11:25:47,184 [INFO] Dohvaćeno 1 poruka za davor +2026-05-17 11:25:50,030 [INFO] Dohvaćeno 51 poruka za Dado +2026-05-17 11:25:52,512 [INFO] Dohvaćeno 1 poruka za davor +2026-05-17 11:26:54,226 [INFO] [davor@haustor.eu] 2 nepročitanih (ranije: 1) +2026-05-17 11:26:56,359 [INFO] [davor@sesvete.org] 2 nepročitanih (ranije: 1) +2026-05-17 11:34:46,451 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. +2026-05-17 11:34:48,478 [INFO] [davor@sesvete.org] Inicijalna provjera: 2 nepročitanih. +2026-05-17 11:35:08,795 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. +2026-05-17 11:35:08,970 [INFO] Dohvaćeno 52 poruka za Dado +2026-05-17 11:35:11,520 [INFO] Dohvaćeno 52 poruka za Dado +2026-05-17 11:35:11,816 [INFO] Dohvaćeno 2 poruka za davor +2026-05-17 11:35:14,197 [INFO] Dohvaćeno 2 poruka za davor +2026-05-17 11:35:21,320 [INFO] [davor@sesvete.org] Inicijalna provjera: 2 nepročitanih. +2026-05-17 11:39:08,254 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. +2026-05-17 11:39:10,254 [INFO] [davor@sesvete.org] Inicijalna provjera: 2 nepročitanih. +2026-05-17 11:39:27,878 [INFO] Testiranje veze: mail.haustor.eu / davor@haustor.eu +2026-05-17 11:39:29,172 [INFO] Test veze uspješan: davor@haustor.eu +2026-05-17 11:39:29,406 [INFO] Račun ažuriran: davor@haustor.eu +2026-05-17 11:39:31,387 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. +2026-05-17 11:39:47,454 [INFO] Testiranje veze: mail.sesvete.org / davor@sesvete.org +2026-05-17 11:39:48,744 [INFO] Test veze uspješan: davor@sesvete.org +2026-05-17 11:39:48,972 [INFO] Račun ažuriran: davor@sesvete.org +2026-05-17 11:39:52,968 [INFO] [davor@sesvete.org] Inicijalna provjera: 2 nepročitanih. +2026-05-17 11:40:11,845 [INFO] Dohvaćeno 52 poruka za Dado +2026-05-17 11:40:14,559 [INFO] Dohvaćeno 2 poruka za Dado Sesvete +2026-05-17 17:06:17,108 [INFO] tkinterweb nije instaliran — HTML poruke prikazuju se kao plain text. +2026-05-17 17:06:19,499 [INFO] [davor@haustor.eu] Inicijalna provjera: 4 nepročitanih. +2026-05-17 17:06:21,830 [INFO] [davor@sesvete.org] Inicijalna provjera: 4 nepročitanih. +2026-05-17 17:06:42,752 [INFO] Dohvaćeno 54 poruka za Dado +2026-05-17 17:06:46,030 [INFO] Dohvaćeno 4 poruka za Dado Sesvete +2026-05-17 17:08:57,006 [INFO] [davor@haustor.eu] Inicijalna provjera: 4 nepročitanih. +2026-05-17 17:08:59,269 [INFO] [davor@sesvete.org] Inicijalna provjera: 4 nepročitanih. +2026-05-17 17:09:59,492 [INFO] [davor@sesvete.org] 3 nepročitanih (ranije: 4) +2026-05-17 17:10:43,262 [INFO] [davor@haustor.eu] Inicijalna provjera: 4 nepročitanih. +2026-05-17 17:10:45,435 [INFO] [davor@sesvete.org] Inicijalna provjera: 3 nepročitanih. +2026-05-17 17:11:45,116 [INFO] [davor@sesvete.org] 2 nepročitanih (ranije: 3) +2026-05-17 17:17:52,726 [INFO] [davor@haustor.eu] Inicijalna provjera: 4 nepročitanih. +2026-05-17 17:17:54,817 [INFO] [davor@sesvete.org] Inicijalna provjera: 2 nepročitanih. +2026-05-17 17:17:59,833 [INFO] [davor@haustor.eu] Inicijalna provjera: 4 nepročitanih. +2026-05-17 17:18:01,888 [INFO] [davor@sesvete.org] Inicijalna provjera: 2 nepročitanih. +2026-05-17 17:19:00,409 [INFO] [davor@haustor.eu] 3 nepročitanih (ranije: 4) +2026-05-17 17:19:41,228 [INFO] [davor@haustor.eu] Inicijalna provjera: 3 nepročitanih. +2026-05-17 17:19:43,807 [INFO] [davor@sesvete.org] Inicijalna provjera: 2 nepročitanih. +2026-05-17 17:26:44,886 [INFO] Jezik: Hrvatski +2026-05-17 17:26:46,834 [INFO] [davor@haustor.eu] Inicijalna provjera: 3 nepročitanih. +2026-05-17 17:26:48,868 [INFO] [davor@sesvete.org] Inicijalna provjera: 2 nepročitanih. +2026-05-17 17:27:32,969 [INFO] Jezik: Hrvatski +2026-05-17 17:27:34,967 [INFO] [davor@haustor.eu] Inicijalna provjera: 3 nepročitanih. +2026-05-17 17:28:39,725 [INFO] Jezik: Hrvatski +2026-05-17 17:28:41,868 [INFO] [davor@haustor.eu] Inicijalna provjera: 3 nepročitanih. +2026-05-17 17:28:44,085 [INFO] [davor@sesvete.org] Inicijalna provjera: 2 nepročitanih. +2026-05-17 17:28:59,297 [INFO] Jezik: English +2026-05-17 17:29:04,616 [INFO] Jezik: Deutsch +2026-05-17 17:29:09,184 [INFO] Jezik: Hrvatski +2026-05-17 17:29:10,646 [INFO] Jezik: English +2026-05-17 17:30:21,009 [INFO] Jezik: Hrvatski +2026-05-17 19:52:43,972 [INFO] [davor@sesvete.org] Inbox čist. +2026-05-17 19:54:41,989 [INFO] [davor@haustor.eu] 4 nepročitanih (ranije: 3) +2026-05-17 19:56:41,951 [INFO] [davor@haustor.eu] 5 nepročitanih (ranije: 4) +2026-05-17 19:57:41,956 [INFO] [davor@haustor.eu] 3 nepročitanih (ranije: 5) +2026-05-17 20:00:41,999 [INFO] [davor@haustor.eu] Inbox čist. +2026-05-17 20:55:42,029 [INFO] [davor@haustor.eu] 1 nepročitanih (ranije: 0) +2026-05-17 20:58:41,972 [INFO] [davor@haustor.eu] 2 nepročitanih (ranije: 1) +2026-05-17 20:58:43,987 [INFO] [davor@sesvete.org] 1 nepročitanih (ranije: 0) +2026-05-17 21:42:42,126 [INFO] [davor@haustor.eu] 3 nepročitanih (ranije: 2) +2026-05-17 21:42:44,297 [INFO] [davor@sesvete.org] 2 nepročitanih (ranije: 1) +2026-05-17 22:37:41,973 [INFO] [davor@haustor.eu] 4 nepročitanih (ranije: 3) +2026-05-17 22:37:43,980 [INFO] [davor@sesvete.org] 3 nepročitanih (ranije: 2) +2026-05-17 22:38:41,944 [INFO] [davor@haustor.eu] 2 nepročitanih (ranije: 4) +2026-05-17 22:38:56,630 [INFO] Jezik: Hrvatski +2026-05-17 22:38:58,610 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. +2026-05-17 22:39:00,620 [INFO] [davor@sesvete.org] Inicijalna provjera: 3 nepročitanih. +2026-05-17 22:39:57,067 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. +2026-05-17 22:39:59,052 [INFO] [davor@sesvete.org] Inicijalna provjera: 3 nepročitanih. +2026-05-17 22:40:52,252 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. +2026-05-17 22:40:54,255 [INFO] [davor@sesvete.org] Inicijalna provjera: 3 nepročitanih. +2026-05-17 22:41:16,489 [INFO] Jezik: English +2026-05-17 22:43:25,628 [INFO] Jezik: Hrvatski +2026-05-17 22:43:27,705 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. +2026-05-17 22:43:29,683 [INFO] [davor@sesvete.org] Inicijalna provjera: 3 nepročitanih. +2026-05-17 22:43:35,487 [INFO] Jezik: English +2026-05-17 22:45:41,129 [INFO] Jezik: English +2026-05-17 22:45:42,915 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. +2026-05-17 22:45:44,919 [INFO] [davor@sesvete.org] Inicijalna provjera: 3 nepročitanih. +2026-05-17 22:45:47,444 [INFO] Jezik: Hrvatski +2026-05-17 22:45:53,659 [INFO] Jezik: Hrvatski +2026-05-17 22:45:55,604 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. +2026-05-17 22:45:57,610 [INFO] [davor@sesvete.org] Inicijalna provjera: 3 nepročitanih. +2026-05-17 22:47:00,587 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. +2026-05-17 22:47:02,921 [INFO] [davor@sesvete.org] Inicijalna provjera: 3 nepročitanih. +2026-05-17 22:47:03,112 [INFO] [davor@haustor.eu] Inicijalna provjera: 2 nepročitanih. diff --git a/mail_checker.py.2 b/mail_checker.py.2 new file mode 100644 index 0000000..e6ac325 --- /dev/null +++ b/mail_checker.py.2 @@ -0,0 +1,1369 @@ +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 / enkripcija lozinke +# --------------------------------------------------------------------------- +try: + import keyring + _USE_KEYRING = True +except ImportError: + _USE_KEYRING = False + +# Fernet enkripcija kao fallback kad keyring nije dostupan +_CRYPTO_KEY_FILE = ".mail_checker.key" + +def _get_fernet(): + try: + from cryptography.fernet import Fernet + if not os.path.exists(_CRYPTO_KEY_FILE): + key = Fernet.generate_key() + with open(_CRYPTO_KEY_FILE, "wb") as f: + f.write(key) + os.chmod(_CRYPTO_KEY_FILE, 0o600) + else: + with open(_CRYPTO_KEY_FILE, "rb") as f: + key = f.read() + return Fernet(key) + except ImportError: + return None + except Exception as exc: + logger.error("Greška pri učitavanju ključa: %s", exc) + return None + +_fernet = None if _USE_KEYRING else _get_fernet() + +if not _USE_KEYRING and _fernet is None: + logger.warning("Ni keyring ni cryptography nisu dostupni. Lozinke se čuvaju nezaštićeno.") + logger.warning("Instaliraj: pip install cryptography --break-system-packages") + +KEYRING_SERVICE = "MailChecker" + +def _save_password(user: str, password: str) -> str: + """Sprema lozinku — keychain > Fernet enkripcija > plain text.""" + if _USE_KEYRING: + keyring.set_password(KEYRING_SERVICE, user, password) + return "" + if _fernet: + return _fernet.encrypt(password.encode()).decode() + return password # fallback — plain text + +def _get_password(acc: dict) -> str: + """Dohvaća lozinku iz keychaina ili dekriptira iz JSON-a.""" + if _USE_KEYRING: + pwd = keyring.get_password(KEYRING_SERVICE, acc["MAIL_USER"]) + return pwd or "" + raw = acc.get("MAIL_PASSWORD", "") + if _fernet and raw: + try: + return _fernet.decrypt(raw.encode()).decode() + except Exception: + return raw # nije enkriptirana (stari zapis) — vrati kao jest + return raw + + +# --------------------------------------------------------------------------- +# 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: + logger.info("Testiranje veze: %s / %s", server, user) + mail = imaplib.IMAP4_SSL(server, timeout=10) + mail.login(user, password) + logger.info("Test veze uspješan: %s", user) + return True + except Exception as exc: + logger.error("Test veze neuspješan: %s — %s", user, 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 + # Enkriptiraj / pohrani lozinku + pwd = data["MAIL_PASSWORD"] + if _USE_KEYRING: + data.pop("MAIL_PASSWORD") + _save_password(data["MAIL_USER"], pwd) + else: + data["MAIL_PASSWORD"] = _save_password(data["MAIL_USER"], pwd) + with _state_lock: + config_accounts.append(data) + save_config() + logger.info("Račun dodan: %s", data["MAIL_USER"]) + 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 + # Enkriptiraj / pohrani lozinku + pwd = data["MAIL_PASSWORD"] + if _USE_KEYRING: + data.pop("MAIL_PASSWORD") + _save_password(data["MAIL_USER"], pwd) + else: + # Fernet enkripcija ili plain text fallback + data["MAIL_PASSWORD"] = _save_password(data["MAIL_USER"], pwd) + with _state_lock: + config_accounts[idx] = data + last_unread_counts.pop(data["MAIL_USER"], None) + save_config() + logger.info("Račun ažuriran: %s", data["MAIL_USER"]) + 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({ + "msg_id": msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id), + "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 + + +def _fetch_body(acc: dict, msg_id: str) -> tuple[str, str]: + """ + Dohvaća tijelo poruke. Označava poruku kao pročitanu. + Vraća tuple (plain_text, html) — koristi što je dostupno. + """ + import re as _re + server = acc["IMAP_SERVER"] + password = _get_password(acc) + mail_conn = None + try: + mail_conn = imaplib.IMAP4_SSL(server, timeout=15) + mail_conn.login(acc["MAIL_USER"], password) + mail_conn.select("inbox") + status, data = mail_conn.fetch(msg_id.encode(), "(RFC822)") + if status != "OK" or not data or not data[0]: + return ("(Nije moguće dohvatiti sadržaj poruke)", "") + raw = data[0][1] + msg = email.message_from_bytes(raw) + plain = "" + html = "" + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + cd = str(part.get("Content-Disposition", "")) + if "attachment" in cd: + continue + payload = part.get_payload(decode=True) + if payload is None: + continue + charset = part.get_content_charset() or "utf-8" + decoded = payload.decode(charset, errors="replace") + if ct == "text/plain" and not plain: + plain = decoded + elif ct == "text/html" and not html: + html = decoded + else: + charset = msg.get_content_charset() or "utf-8" + payload = msg.get_payload(decode=True) + if payload: + ct = msg.get_content_type() + decoded = payload.decode(charset, errors="replace") + if ct == "text/html": + html = decoded + else: + plain = decoded + if not plain and html: + import re as _re2 + # Ukloni ", "", html, + flags=_re2.IGNORECASE | _re2.DOTALL) + cleaned = _re2.sub(r"]*>.*?", "", cleaned, + flags=_re2.IGNORECASE | _re2.DOTALL) + cleaned = _re2.sub(r"]*>.*?", "", cleaned, + flags=_re2.IGNORECASE | _re2.DOTALL) + #
i

-> newline + cleaned = _re2.sub(r"", "\n", cleaned, flags=_re2.IGNORECASE) + cleaned = _re2.sub(r"

", "\n", cleaned, flags=_re2.IGNORECASE) + cleaned = _re2.sub(r"", "\n", cleaned, flags=_re2.IGNORECASE) + # Ukloni sve preostale HTML tagove + cleaned = _re2.sub(r"<[^>]+>", "", cleaned) + # Dekodira HTML entitete (&   itd.) + import html as _html + cleaned = _html.unescape(cleaned) + # Makni višestruke prazne retke i razmake + cleaned = _re2.sub(r"[ \t]+", " ", cleaned) + cleaned = _re2.sub(r"\n{3,}", "\n\n", cleaned) + plain = cleaned + return (plain.strip() or "(Prazan sadržaj)", html) + except Exception as exc: + logger.error("Greška pri dohvatu tijela poruke: %s", exc) + return (f"(Greška: {exc})", "") + finally: + if mail_conn: + try: + mail_conn.logout() + except Exception: + pass + + +def _delete_message(acc: dict, msg_id: str) -> bool: + """Premješta poruku u Trash (označava Deleted flag i expunge).""" + server = acc["IMAP_SERVER"] + password = _get_password(acc) + mail_conn = None + try: + mail_conn = imaplib.IMAP4_SSL(server, timeout=15) + mail_conn.login(acc["MAIL_USER"], password) + mail_conn.select("inbox") + # Pokušaj premjestiti u Trash (Gmail / standardni IMAP) + trash_folders = ["[Gmail]/Trash", "Trash", "INBOX.Trash", "Deleted Items", "Deleted Messages"] + for folder in trash_folders: + try: + result = mail_conn.copy(msg_id.encode(), folder) + if result[0] == "OK": + break + except Exception: + continue + # Označi kao obrisano i expunge + mail_conn.store(msg_id.encode(), "+FLAGS", r"\Deleted") + mail_conn.expunge() + return True + except Exception as exc: + logger.error("Greška pri brisanju poruke: %s", exc) + return False + finally: + if mail_conn: + try: + mail_conn.logout() + except Exception: + pass + + +try: + from tkinterweb import HtmlFrame as _HtmlFrame + _HAVE_HTMLFRAME = True +except ImportError: + _HAVE_HTMLFRAME = False + logger.info("tkinterweb nije instaliran — HTML poruke prikazuju se kao plain text.") + + +def _open_read_window(acc: dict, msg: dict, on_deleted=None): + """Otvara prozor za čitanje poruke. + HTML poruke prikazuje u HTML rendereru (tkinterweb), plain u Text widgetu. + """ + read_win = tk.Toplevel() + read_win.title(f"Poruka — {msg['subject']}") + read_win.geometry("860x640") + read_win.resizable(True, True) + + # Zaglavlje + hdr = tk.Frame(read_win, bd=1, relief="groove", padx=6, pady=4) + hdr.pack(fill="x", padx=10, pady=(10, 4)) + tk.Label(hdr, text=f"Od: {msg['from']}", + anchor="w", font=("TkDefaultFont", 10)).pack(fill="x", pady=1) + tk.Label(hdr, text=f"Naslov: {msg['subject']}", + anchor="w", font=("TkDefaultFont", 10, "bold")).pack(fill="x", pady=1) + tk.Label(hdr, text=f"Datum: {msg['date']}", + anchor="w", font=("TkDefaultFont", 10)).pack(fill="x", pady=1) + + # Toolbar + toolbar = tk.Frame(read_win) + toolbar.pack(fill="x", padx=10, pady=(0, 4)) + + lbl_status = tk.Label(toolbar, text="Dohvaćanje sadržaja...", fg="gray") + lbl_status.pack(side="left") + + def _do_delete(): + if not messagebox.askyesno("Potvrda", "Obriši ovu poruku?", parent=read_win): + return + lbl_status.config(text="Brisanje...", fg="gray") + def _bg(): + ok = _delete_message(acc, msg["msg_id"]) + def _ui(): + if ok: + lbl_status.config(text="Poruka obrisana.", fg="green") + read_win.after(1000, read_win.destroy) + if on_deleted: + on_deleted() + else: + lbl_status.config(text="Greška pri brisanju!", fg="red") + read_win.after(0, _ui) + threading.Thread(target=_bg, daemon=True).start() + + tk.Button(toolbar, text="🗑 Obriši poruku", fg="red", + command=_do_delete).pack(side="right") + + # Rezervirani frame za tijelo — popunit će se nakon dohvata + body_frame = tk.Frame(read_win) + body_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10)) + + def _show_plain(text): + """Prikaži plain text u Text widgetu.""" + txt = tk.Text(body_frame, wrap="word", state="normal", + font=("Monospace", 10), relief="sunken", bd=1) + vsb = ttk.Scrollbar(body_frame, command=txt.yview) + txt.configure(yscrollcommand=vsb.set) + vsb.pack(side="right", fill="y") + txt.pack(fill="both", expand=True) + txt.insert("end", text) + txt.configure(state="disabled") + + def _show_html(html_content): + """Prikaži HTML u HtmlFrame (tkinterweb) ili fallback na plain.""" + if _HAVE_HTMLFRAME: + try: + hf = _HtmlFrame(body_frame, messages_enabled=False) + hf.pack(fill="both", expand=True) + hf.load_html(html_content) + return + except Exception as exc: + logger.warning("tkinterweb greška: %s — fallback na plain", exc) + # Fallback: konvertiraj HTML u plain + import re as _re2 + import html as _html + cleaned = _re2.sub(r"]*>.*?", "", html_content, + flags=_re2.IGNORECASE | _re2.DOTALL) + cleaned = _re2.sub(r"]*>.*?", "", cleaned, + flags=_re2.IGNORECASE | _re2.DOTALL) + cleaned = _re2.sub(r"]*>.*?", "", cleaned, + flags=_re2.IGNORECASE | _re2.DOTALL) + cleaned = _re2.sub(r"", "\n", cleaned, flags=_re2.IGNORECASE) + cleaned = _re2.sub(r"

||", "\n", cleaned, flags=_re2.IGNORECASE) + cleaned = _re2.sub(r"<[^>]+>", "", cleaned) + cleaned = _html.unescape(cleaned) + cleaned = _re2.sub(r"[ \t]+", " ", cleaned) + cleaned = _re2.sub(r"\n{3,}", "\n\n", cleaned) + _show_plain(cleaned.strip()) + + # Dohvati tijelo u backgroundu + def _load_body(): + plain, html = _fetch_body(acc, msg["msg_id"]) + def _show(): + lbl_status.config(text="") + if html: + # HTML mail — prikaži u HTML rendereru + _show_html(html) + elif plain and plain != "(Prazan sadržaj)": + # Čisti plain text mail + _show_plain(plain) + else: + _show_plain("(Prazan sadržaj)") + read_win.after(0, _show) + + threading.Thread(target=_load_body, daemon=True).start() + + +# 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, acc_ref): + """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) + + # Donji frame s gumbima + action_frame = tk.Frame(frame) + action_frame.pack(side="bottom", fill="x", pady=4) + + # Pohrani poruke po iid za brzi lookup + msg_map = {} + + if messages: + for m in messages: + marker = "●" if m["unread"] else "" + tags = ("unread",) if m["unread"] else () + iid = tree.insert("", "end", + values=(marker, m["from"], m["subject"], m["date"]), + tags=tags) + msg_map[iid] = m + else: + tree.insert("", "end", values=("", "—", "Nema poruka", "—")) + + tree.pack(side="left", fill="both", expand=True) + vsb.pack(side="right", fill="y") + + def _get_selected_msg(): + sel = tree.selection() + if not sel: + return None + return msg_map.get(sel[0]) + + def _on_double_click(event): + m = _get_selected_msg() + if m and "msg_id" in m: + _open_read_window(acc_ref, m, + on_deleted=lambda: win.after( + 0, lambda: threading.Thread(target=_do_refresh, daemon=True).start())) + + def _on_delete_btn(): + m = _get_selected_msg() + if not m or "msg_id" not in m: + messagebox.showwarning("Upozorenje", "Odaberi poruku za brisanje.", parent=win) + return + if not messagebox.askyesno("Potvrda", "Obriši ovu poruku?", parent=win): + return + def _bg(): + ok = _delete_message(acc_ref, m["msg_id"]) + if ok: + win.after(0, lambda: threading.Thread( + target=_do_refresh, daemon=True).start()) + else: + win.after(0, lambda: messagebox.showerror( + "Greška", "Brisanje nije uspjelo.", parent=win)) + threading.Thread(target=_bg, daemon=True).start() + + tree.bind("", _on_double_click) + + tk.Button(action_frame, text="📖 Čitaj poruku", width=16, + command=lambda: _on_double_click(None)).pack(side="left", padx=8) + tk.Button(action_frame, text="🗑 Obriši", width=12, fg="red", + command=_on_delete_btn).pack(side="left", padx=4) + + 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, acc)) + 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, acc_ref in results: + _build_tab(name, msgs, acc_ref) + 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() diff --git a/mail_checker_qt.py b/mail_checker_qt.py new file mode 100644 index 0000000..6245a3c --- /dev/null +++ b/mail_checker_qt.py @@ -0,0 +1,1414 @@ +""" +Mail Checker — PyQt6 verzija +Potrebno instalirati: + sudo apt install python3-pyqt6 python3-pyqt6.qtwebengine python3-pyqt6.qtmultimedia python3-cryptography +""" + +import sys +import os +import json +import time +import imaplib +import email +import email.header +import email.utils +import logging +import queue +import subprocess +import threading +import tempfile +import re +import html as _html_mod + +from PyQt6.QtWidgets import ( + QApplication, QSystemTrayIcon, QMenu, QDialog, QWidget, + QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QLineEdit, + QPushButton, QListWidget, QListWidgetItem, QCheckBox, + QRadioButton, QButtonGroup, QSplitter, QFrame, QFileDialog, + QMessageBox, QTabWidget, QTreeWidget, QTreeWidgetItem, + QHeaderView, QSpinBox, QSizePolicy, QGroupBox, + QStatusBar, QMainWindow, QProgressBar, QPlainTextEdit +) +from PyQt6.QtCore import ( + Qt, QThread, pyqtSignal, QTimer, QSize, QObject +) +from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QPen, QBrush +from PyQt6.QtWebEngineWidgets import QWebEngineView + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +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__) + +# --------------------------------------------------------------------------- +# Konstante +# --------------------------------------------------------------------------- +CONFIG_FILE = "config.json" +APP_SETTINGS_FILE = "app_settings.json" +MAX_RETRIES = 3 +RETRY_DELAY = 5 + +# --------------------------------------------------------------------------- +# Lokalizacija +# --------------------------------------------------------------------------- +LANG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lang") +_current_lang: dict = {} +_lang_change_callbacks: list = [] + +def _find_lang_files() -> dict: + """Vraća dict {language_name: file_path} svih dostupnih lang datoteka.""" + langs = {} + search_dirs = [LANG_DIR, os.path.dirname(os.path.abspath(__file__))] + for d in search_dirs: + if not os.path.isdir(d): + continue + for fname in sorted(os.listdir(d)): + if fname.startswith("lang_") and fname.endswith(".json"): + path = os.path.join(d, fname) + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + name = data.get("_language_name", fname) + if name not in langs: + langs[name] = path + except Exception: + pass + return langs + +def load_language(lang_name: str = None): + """Učitava jezičnu datoteku po imenu. Fallback na HR pa EN pa prvi dostupni.""" + global _current_lang + available = _find_lang_files() + path = None + if lang_name and lang_name in available: + path = available[lang_name] + elif "Hrvatski" in available: + path = available["Hrvatski"] + elif "English" in available: + path = available["English"] + elif available: + path = next(iter(available.values())) + if path: + try: + with open(path, "r", encoding="utf-8") as f: + _current_lang = json.load(f) + logger.info("Jezik: %s", _current_lang.get("_language_name", path)) + return + except Exception as exc: + logger.error("Greška pri učitavanju jezika: %s", exc) + _current_lang = {"_language_name": "English", "_language_code": "en"} + +def t(key: str, **kwargs) -> str: + """Vraća prevedeni string za ključ.""" + text = _current_lang.get(key, key) + if kwargs: + try: + text = text.format(**kwargs) + except Exception: + pass + return text + +def register_lang_callback(fn): + _lang_change_callbacks.append(fn) + +def change_language(lang_name: str): + """Mijenja jezik i ažurira sve prozore.""" + load_language(lang_name) + for fn in list(_lang_change_callbacks): + try: + fn() + except Exception as exc: + logger.warning("Lang callback greška: %s", exc) + +# Inicijalno učitavanje jezika — poziva se iz load_app_settings() pri startu +# Ne pozivaj ovdje — _app_settings još nije učitan + +# --------------------------------------------------------------------------- +# Enkripcija lozinke (Fernet) +# --------------------------------------------------------------------------- +_CRYPTO_KEY_FILE = ".mail_checker.key" + +def _get_fernet(): + try: + from cryptography.fernet import Fernet + if not os.path.exists(_CRYPTO_KEY_FILE): + key = Fernet.generate_key() + with open(_CRYPTO_KEY_FILE, "wb") as f: + f.write(key) + os.chmod(_CRYPTO_KEY_FILE, 0o600) + else: + with open(_CRYPTO_KEY_FILE, "rb") as f: + key = f.read() + return Fernet(key) + except ImportError: + logger.warning("cryptography nije instaliran — lozinke se čuvaju plain text!") + return None + except Exception as exc: + logger.error("Greška pri učitavanju ključa: %s", exc) + return None + +_fernet = _get_fernet() + +def encrypt_password(password: str) -> str: + if _fernet and password: + return _fernet.encrypt(password.encode()).decode() + return password + +def decrypt_password(encrypted: str) -> str: + if _fernet and encrypted: + try: + return _fernet.decrypt(encrypted.encode()).decode() + except Exception: + return encrypted # stari plain text zapis + return encrypted + +# --------------------------------------------------------------------------- +# Konfiguracija +# --------------------------------------------------------------------------- +_config_lock = threading.Lock() +config_accounts: list[dict] = [] + +_app_settings: dict = {} + +def load_app_settings(): + global _app_settings + if os.path.exists(APP_SETTINGS_FILE): + try: + with open(APP_SETTINGS_FILE, "r", encoding="utf-8") as f: + _app_settings = json.load(f) + except Exception: + _app_settings = {} + load_language(_app_settings.get("language")) + +def save_app_settings(): + try: + with open(APP_SETTINGS_FILE, "w", encoding="utf-8") as f: + json.dump(_app_settings, f, indent=4, ensure_ascii=False) + except Exception as exc: + logger.error("Greška pri snimanju postavki: %s", exc) + +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(): + with _config_lock: + try: + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config_accounts, f, indent=4, ensure_ascii=False) + except Exception as exc: + logger.error("Greška pri snimanju konfiguracije: %s", exc) + +def get_password(acc: dict) -> str: + return decrypt_password(acc.get("MAIL_PASSWORD", "")) + +# --------------------------------------------------------------------------- +# TTS — serijski red čekanja +# --------------------------------------------------------------------------- +_tts_queue: queue.Queue = queue.Queue() + +def _tts_worker(): + while True: + item = _tts_queue.get() + if item is None: + break + text, gender = item + voice = "hr+f3" if gender == "Female" else "hr" + try: + subprocess.run( + ["espeak-ng", "-v", voice, "-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() + +threading.Thread(target=_tts_worker, daemon=True).start() + +def speak_text(text: str, gender: str = "Female"): + _tts_queue.put((text, gender)) + +# --------------------------------------------------------------------------- +# Zvuk +# --------------------------------------------------------------------------- +def play_sound_file(file_path: str): + if not file_path or not os.path.exists(file_path): + return + def _run(): + ext = file_path.lower().rsplit(".", 1)[-1] + if ext == "wav": + subprocess.run(["aplay", file_path], check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + for player in ["mpv", "ffplay"]: + try: + subprocess.run([player, "--no-video" if player == "mpv" else "-nodisp", + "-autoexit" if player == "ffplay" else "", + file_path], check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + break + except FileNotFoundError: + continue + threading.Thread(target=_run, daemon=True).start() + +# --------------------------------------------------------------------------- +# Notifikacija +# --------------------------------------------------------------------------- +def send_notification(title: str, body: str): + def _run(): + try: + subprocess.run( + ["notify-send", "--expire-time=5000", title, body], + check=False, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=5 + ) + except Exception as exc: + logger.warning("notify-send greška: %s", exc) + threading.Thread(target=_run, daemon=True).start() + +# --------------------------------------------------------------------------- +# Hrvatska deklinacija +# --------------------------------------------------------------------------- +def 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" + +# --------------------------------------------------------------------------- +# Tray ikona — crtanje u Qt +# --------------------------------------------------------------------------- +def make_tray_icon(color: str = "blue", count: int = 0) -> QIcon: + px = QPixmap(64, 64) + px.fill(QColor(color)) + painter = QPainter(px) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + pen = QPen(QColor("white"), 2) + painter.setPen(pen) + # Omotnica + painter.drawRect(12, 18, 40, 28) + painter.drawLine(12, 18, 32, 32) + painter.drawLine(52, 18, 32, 32) + # Broj nepročitanih + if count > 0: + label = str(count) if count < 100 else "99+" + painter.setBrush(QBrush(QColor("red"))) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(36, 36, 26, 26) + painter.setPen(QPen(QColor("white"))) + font = QFont() + font.setBold(True) + font.setPixelSize(13) + painter.setFont(font) + painter.drawText(36, 36, 26, 26, Qt.AlignmentFlag.AlignCenter, label) + painter.end() + return QIcon(px) + +# --------------------------------------------------------------------------- +# IMAP worker thread +# --------------------------------------------------------------------------- +class MailCheckWorker(QThread): + """Provjerava mailove u backgroundu i emitira signale.""" + result_ready = pyqtSignal(dict) # {user: count} + error_occurred = pyqtSignal(str, str) # user, poruka + + def __init__(self, accounts: list[dict]): + super().__init__() + self.accounts = accounts + + def run(self): + for acc in self.accounts: + user = acc["MAIL_USER"] + server = acc["IMAP_SERVER"] + 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": + count = len(response[0].split()) + self.result_ready.emit({user: count}) + break + except imaplib.IMAP4.error as exc: + self.error_occurred.emit(user, str(exc)) + break + except Exception as exc: + if attempt == MAX_RETRIES: + self.error_occurred.emit(user, str(exc)) + else: + time.sleep(RETRY_DELAY * attempt) + finally: + if mail: + try: mail.logout() + except: pass + +class ConnectionTestWorker(QThread): + """Testira IMAP vezu i emitira signal s rezultatom.""" + done = pyqtSignal(bool, str) + + def __init__(self, server, user, password): + super().__init__() + self.server = server + self.user = user + self.password = password + + def run(self): + mail = None + try: + mail = imaplib.IMAP4_SSL(self.server, timeout=10) + mail.login(self.user, self.password) + self.done.emit(True, "") + except Exception as exc: + self.done.emit(False, str(exc)) + finally: + if mail: + try: mail.logout() + except: pass + +class FetchMessagesWorker(QThread): + """Dohvaća poruke za jedan račun.""" + done = pyqtSignal(str, list) # nickname, poruke + + def __init__(self, acc: dict): + super().__init__() + self.acc = acc + + def run(self): + name = self.acc.get("NICKNAME") or self.acc["MAIL_USER"].split("@")[0] + limit = self.acc.get("PREVIEW_COUNT", 5) + messages = [] + mail_conn = None + try: + mail_conn = imaplib.IMAP4_SSL(self.acc["IMAP_SERVER"], timeout=15) + mail_conn.login(self.acc["MAIL_USER"], get_password(self.acc)) + mail_conn.select("inbox", readonly=True) + + status, response = mail_conn.search(None, "ALL") + if status != "OK" or not response[0]: + self.done.emit(name, []) + return + + all_ids = response[0].split() + ids_to_fetch = all_ids[-limit:][::-1] + + 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: + s, data = mail_conn.fetch( + msg_id, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])") + if s != "OK" or not data or not data[0]: + continue + msg = email.message_from_bytes(data[0][1]) + + from_raw = msg.get("From", "") + parsed = email.utils.parseaddr(from_raw) + sender = _decode_header(parsed[0] if parsed[0] else parsed[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 "?" + + mid = msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id) + messages.append({ + "msg_id": mid, + "from": sender, + "subject": subject, + "date": date_str, + "unread": msg_id in unseen_ids, + }) + except Exception as exc: + logger.warning("Greška pri dohvatu poruke: %s", exc) + except Exception as exc: + logger.error("[%s] Greška pri dohvatu poruka: %s", self.acc["MAIL_USER"], exc) + finally: + if mail_conn: + try: mail_conn.logout() + except: pass + self.done.emit(name, messages) + +class FetchBodyWorker(QThread): + """Dohvaća tijelo poruke.""" + done = pyqtSignal(str, str) # plain, html + + def __init__(self, acc: dict, msg_id: str): + super().__init__() + self.acc = acc + self.msg_id = msg_id + + def run(self): + mail_conn = None + plain = "" + html = "" + try: + mail_conn = imaplib.IMAP4_SSL(self.acc["IMAP_SERVER"], timeout=15) + mail_conn.login(self.acc["MAIL_USER"], get_password(self.acc)) + mail_conn.select("inbox") + status, data = mail_conn.fetch(self.msg_id.encode(), "(RFC822)") + if status != "OK" or not data or not data[0]: + self.done.emit("(Nije moguće dohvatiti sadržaj)", "") + return + msg = email.message_from_bytes(data[0][1]) + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + cd = str(part.get("Content-Disposition", "")) + if "attachment" in cd: + continue + payload = part.get_payload(decode=True) + if payload is None: + continue + charset = part.get_content_charset() or "utf-8" + decoded = payload.decode(charset, errors="replace") + if ct == "text/plain" and not plain: + plain = decoded + elif ct == "text/html" and not html: + html = decoded + else: + charset = msg.get_content_charset() or "utf-8" + payload = msg.get_payload(decode=True) + if payload: + decoded = payload.decode(charset, errors="replace") + if msg.get_content_type() == "text/html": + html = decoded + else: + plain = decoded + except Exception as exc: + logger.error("Greška pri dohvatu tijela: %s", exc) + plain = f"(Greška: {exc})" + finally: + if mail_conn: + try: mail_conn.logout() + except: pass + self.done.emit(plain, html) + +class DeleteMessageWorker(QThread): + done = pyqtSignal(bool) + + def __init__(self, acc: dict, msg_id: str): + super().__init__() + self.acc = acc + self.msg_id = msg_id + + def run(self): + mail_conn = None + try: + mail_conn = imaplib.IMAP4_SSL(self.acc["IMAP_SERVER"], timeout=15) + mail_conn.login(self.acc["MAIL_USER"], get_password(self.acc)) + mail_conn.select("inbox") + trash_folders = ["[Gmail]/Trash", "Trash", "INBOX.Trash", + "Deleted Items", "Deleted Messages"] + for folder in trash_folders: + try: + res = mail_conn.copy(self.msg_id.encode(), folder) + if res[0] == "OK": + break + except Exception: + continue + mail_conn.store(self.msg_id.encode(), "+FLAGS", r"\Deleted") + mail_conn.expunge() + self.done.emit(True) + except Exception as exc: + logger.error("Greška pri brisanju: %s", exc) + self.done.emit(False) + finally: + if mail_conn: + try: mail_conn.logout() + except: pass + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- +def _decode_header(raw: str) -> str: + parts = email.header.decode_header(raw or "") + decoded = [] + for part, charset in parts: + if isinstance(part, bytes): + decoded.append(part.decode(charset or "utf-8", errors="replace")) + else: + decoded.append(str(part)) + return "".join(decoded) + +def html_to_plain(html_content: str) -> str: + t = re.sub(r"]*>.*?", "", html_content, + flags=re.IGNORECASE | re.DOTALL) + t = re.sub(r"]*>.*?", "", t, + flags=re.IGNORECASE | re.DOTALL) + t = re.sub(r"]*>.*?", "", t, + flags=re.IGNORECASE | re.DOTALL) + t = re.sub(r"", "\n", t, flags=re.IGNORECASE) + t = re.sub(r"

||", "\n", t, flags=re.IGNORECASE) + t = re.sub(r"<[^>]+>", "", t) + t = _html_mod.unescape(t) + t = re.sub(r"[ \t]+", " ", t) + t = re.sub(r"\n{3,}", "\n\n", t) + return t.strip() + +# --------------------------------------------------------------------------- +# Prozor za čitanje poruke +# --------------------------------------------------------------------------- +class ReadMessageWindow(QDialog): + def __init__(self, acc: dict, msg: dict, parent=None, on_deleted=None): + super().__init__(parent) + self.acc = acc + self.msg = msg + self.on_deleted = on_deleted + self._html_content = "" + self._tmp_file = "" + self._workers = [] + + self.setWindowTitle(f"Poruka — {msg['subject']}") + self.resize(900, 680) + self.setWindowFlag(Qt.WindowType.Window) + + layout = QVBoxLayout(self) + + # Zaglavlje + hdr = QFrame() + hdr.setFrameShape(QFrame.Shape.StyledPanel) + hdr_layout = QFormLayout(hdr) + hdr_layout.addRow("Od:", QLabel(msg["from"])) + subj_lbl = QLabel(msg["subject"]) + subj_lbl.setFont(QFont("", -1, QFont.Weight.Bold)) + hdr_layout.addRow("Naslov:", subj_lbl) + hdr_layout.addRow("Datum:", QLabel(msg["date"])) + layout.addWidget(hdr) + + # Toolbar + toolbar = QHBoxLayout() + self.lbl_status = QLabel(t("read_loading")) + toolbar.addWidget(self.lbl_status) + toolbar.addStretch() + self.btn_browser = QPushButton(t("read_btn_browser")) + self.btn_browser.setEnabled(False) + self.btn_browser.clicked.connect(self._open_in_browser) + toolbar.addWidget(self.btn_browser) + btn_delete = QPushButton(t("read_btn_delete")) + btn_delete.setStyleSheet("color: red;") + btn_delete.clicked.connect(self._do_delete) + toolbar.addWidget(btn_delete) + layout.addLayout(toolbar) + + # Tab — plain / HTML (dodaje se dinamički nakon dohvata) + self.tabs = QTabWidget() + layout.addWidget(self.tabs) + + # Dohvati tijelo + worker = FetchBodyWorker(acc, msg["msg_id"]) + worker.done.connect(self._on_body_loaded) + self._workers.append(worker) + worker.start() + + def _on_body_loaded(self, plain: str, html: str): + self.lbl_status.setText("") + self._html_content = html + + if html: + # HTML poruka — prikaži WebEngine view + web_view = QWebEngineView() + web_view.setHtml(html) + self.tabs.addTab(web_view, "HTML") + self.btn_browser.setEnabled(True) + elif plain and plain != t("read_empty"): + # Plain text poruka — prikaži plain text editor + plain_view = QPlainTextEdit() + plain_view.setReadOnly(True) + plain_view.setFont(QFont("Monospace", 10)) + plain_view.setPlainText(plain) + self.tabs.addTab(plain_view, t("read_tab_plain")) + else: + plain_view = QPlainTextEdit() + plain_view.setReadOnly(True) + plain_view.setPlainText(t("read_empty")) + self.tabs.addTab(plain_view, "Poruka") + + def _open_in_browser(self): + if not self._html_content: + return + import webbrowser + try: + tmp = tempfile.NamedTemporaryFile( + mode="w", suffix=".html", delete=False, + encoding="utf-8", prefix="mail_checker_" + ) + tmp.write(self._html_content) + tmp.close() + self._tmp_file = tmp.name + webbrowser.open(f"file://{tmp.name}") + except Exception as exc: + QMessageBox.critical(self, "Greška", f"Ne mogu otvoriti browser: {exc}") + + def _do_delete(self): + if QMessageBox.question(self, t("settings_confirm_title"), t("messages_confirm_delete")) != QMessageBox.StandardButton.Yes: + return + self.lbl_status.setText(t("read_deleting")) + worker = DeleteMessageWorker(self.acc, self.msg["msg_id"]) + worker.done.connect(self._on_deleted) + self._workers.append(worker) + worker.start() + + def _on_deleted(self, ok: bool): + if ok: + self.lbl_status.setText(t("read_deleted")) + if self.on_deleted: + self.on_deleted() + QTimer.singleShot(1000, self.close) + else: + self.lbl_status.setText(t("read_err_delete")) + + def closeEvent(self, event): + if self._tmp_file: + try: os.remove(self._tmp_file) + except: pass + super().closeEvent(event) + +# --------------------------------------------------------------------------- +# Prozor za pregled poruka +# --------------------------------------------------------------------------- +class MessagesWindow(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(t("messages_title")) + self.resize(820, 520) + self.setWindowFlag(Qt.WindowType.Window) + self._workers = [] + self._read_windows = [] + + layout = QVBoxLayout(self) + + # Toolbar + toolbar = QHBoxLayout() + self.lbl_status = QLabel("") + toolbar.addWidget(self.lbl_status) + toolbar.addStretch() + btn_refresh = QPushButton(t("messages_refresh")) + btn_refresh.clicked.connect(self.load_messages) + toolbar.addWidget(btn_refresh) + layout.addLayout(toolbar) + + # Tab po računu + self.tabs = QTabWidget() + layout.addWidget(self.tabs) + + self.load_messages() + + def _retranslate(self): + self.setWindowTitle(t("messages_title")) + if hasattr(self, "btn_refresh"): + self.btn_refresh.setText(t("messages_refresh")) + + def load_messages(self): + self.tabs.clear() + self.lbl_status.setText(t("messages_loading")) + with _config_lock: + accs = [a for a in config_accounts if a.get("PREVIEW_ENABLED", True)] + if not accs: + self.lbl_status.setText(t("messages_no_accounts")) + return + self._pending = len(accs) + for acc in accs: + worker = FetchMessagesWorker(acc) + worker.done.connect(self._on_messages_ready) + self._workers.append(worker) + worker.start() + + def _on_messages_ready(self, name: str, messages: list): + # Pronađi acc za ovaj tab + with _config_lock: + acc = next((a for a in config_accounts + if (a.get("NICKNAME") or a["MAIL_USER"].split("@")[0]) == name), None) + + tab = QWidget() + tab_layout = QVBoxLayout(tab) + + tree = QTreeWidget() + tree.setColumnCount(4) + tree.setHeaderLabels([t("messages_col_status"), t("messages_col_from"), t("messages_col_subject"), t("messages_col_date")]) + tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) + tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + tree.header().setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) + tree.setColumnWidth(0, 24) + tree.setColumnWidth(1, 200) + tree.setColumnWidth(3, 140) + tree.setAlternatingRowColors(True) + tree.setRootIsDecorated(False) + + bold_font = QFont() + bold_font.setBold(True) + + msg_map = {} + if messages: + for m in messages: + item = QTreeWidgetItem([ + "●" if m["unread"] else "", + m["from"], m["subject"], m["date"] + ]) + if m["unread"]: + for col in range(4): + item.setFont(col, bold_font) + tree.addTopLevelItem(item) + msg_map[id(item)] = m + else: + tree.addTopLevelItem(QTreeWidgetItem(["", "—", t("messages_no_messages"), "—"])) + + tab_layout.addWidget(tree) + + # Gumbi + btn_row = QHBoxLayout() + btn_read = QPushButton(t("messages_btn_read")) + btn_delete = QPushButton(t("messages_btn_delete")) + btn_delete.setStyleSheet("color: red;") + btn_row.addWidget(btn_read) + btn_row.addWidget(btn_delete) + btn_row.addStretch() + tab_layout.addLayout(btn_row) + + def get_selected_msg(): + sel = tree.selectedItems() + if not sel: + return None + return msg_map.get(id(sel[0])) + + def open_read(): + m = get_selected_msg() + if not m or not acc: + return + win = ReadMessageWindow(acc, m, self, + on_deleted=self.load_messages) + self._read_windows.append(win) + win.show() + + def do_delete(): + m = get_selected_msg() + if not m or not acc: + QMessageBox.warning(self, "Upozorenje", t("messages_warn_select")) + return + if QMessageBox.question(self, t("settings_confirm_title"), t("messages_confirm_delete")) != QMessageBox.StandardButton.Yes: + return + worker = DeleteMessageWorker(acc, m["msg_id"]) + worker.done.connect(lambda ok: self.load_messages() if ok else + QMessageBox.critical(self, "Greška", t("messages_err_delete"))) + self._workers.append(worker) + worker.start() + + tree.doubleClicked.connect(lambda: open_read()) + btn_read.clicked.connect(open_read) + btn_delete.clicked.connect(do_delete) + + self.tabs.addTab(tab, f" {name} ") + + self._pending -= 1 + if self._pending <= 0: + self.lbl_status.setText( + t("messages_last_refresh", time=time.strftime("%H:%M:%S"))) + +# --------------------------------------------------------------------------- +# Prozor postavki — tabovi: Računi | Opće | O programu +# --------------------------------------------------------------------------- +class SettingsDialog(QDialog): + accounts_changed = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(t("settings_title")) + self.resize(860, 560) + self._test_worker = None + self._selected_idx = None + + main_layout = QVBoxLayout(self) + self.tab_widget = QTabWidget() + main_layout.addWidget(self.tab_widget) + + self._build_accounts_tab() + self._build_general_tab() + self._build_about_tab() + + register_lang_callback(self._retranslate) + self._refresh_list() + + # ── Tab: Računi ─────────────────────────────────────────────────────────── + def _build_accounts_tab(self): + tab = QWidget() + layout = QHBoxLayout(tab) + + # Lijevo — popis + left = QVBoxLayout() + self.lbl_accounts = QLabel(t("settings_accounts_label")) + left.addWidget(self.lbl_accounts) + self.account_list = QListWidget() + self.account_list.currentRowChanged.connect(self._on_select) + left.addWidget(self.account_list, 1) + + order_row = QHBoxLayout() + self.btn_up = QPushButton(t("settings_btn_up")) + self.btn_dn = QPushButton(t("settings_btn_down")) + self.btn_up.clicked.connect(lambda: self._move(-1)) + self.btn_dn.clicked.connect(lambda: self._move(1)) + order_row.addWidget(self.btn_up) + order_row.addWidget(self.btn_dn) + left.addLayout(order_row) + layout.addLayout(left, 1) + + # Desno — formular + right = QVBoxLayout() + self.form_group = QGroupBox(t("settings_group_details")) + form = QFormLayout(self.form_group) + + self.e_nickname = QLineEdit() + self.e_server = QLineEdit() + self.e_user = QLineEdit() + self.e_password = QLineEdit() + self.e_password.setEchoMode(QLineEdit.EchoMode.Password) + self.e_interval = QSpinBox() + self.e_interval.setRange(10, 86400) + self.e_interval.setValue(60) + self.e_interval.setSuffix(t("settings_interval_suffix")) + + sound_row = QHBoxLayout() + self.e_sound = QLineEdit() + self.btn_browse = QPushButton(t("settings_browse")) + self.btn_browse.clicked.connect(self._browse_sound) + sound_row.addWidget(self.e_sound) + sound_row.addWidget(self.btn_browse) + + self.lbl_nickname = QLabel(t("settings_nickname")) + self.lbl_server = QLabel(t("settings_server")) + self.lbl_email = QLabel(t("settings_email")) + self.lbl_password = QLabel(t("settings_password")) + self.lbl_interval = QLabel(t("settings_interval")) + self.lbl_sound = QLabel(t("settings_sound")) + self.lbl_preview_count = QLabel(t("settings_preview_count")) + + form.addRow(self.lbl_nickname, self.e_nickname) + form.addRow(self.lbl_server, self.e_server) + form.addRow(self.lbl_email, self.e_user) + form.addRow(self.lbl_password, self.e_password) + form.addRow(self.lbl_interval, self.e_interval) + form.addRow(self.lbl_sound, sound_row) + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + form.addRow(sep) + + self.chk_preview = QCheckBox(t("settings_preview_enabled")) + self.chk_preview.setChecked(True) + self.spin_preview = QSpinBox() + self.spin_preview.setRange(1, 50) + self.spin_preview.setValue(5) + form.addRow(self.chk_preview) + form.addRow(self.lbl_preview_count, self.spin_preview) + + right.addWidget(self.form_group) + + self.lbl_status = QLabel("") + self.lbl_status.setAlignment(Qt.AlignmentFlag.AlignCenter) + right.addWidget(self.lbl_status) + + self.btn_add = QPushButton(t("settings_btn_add")) + self.btn_update = QPushButton(t("settings_btn_update")) + self.btn_update.setStyleSheet("color: blue;") + self.btn_delete = QPushButton(t("settings_btn_delete")) + self.btn_delete.setStyleSheet("color: red;") + self.btn_add.clicked.connect(self._on_add) + self.btn_update.clicked.connect(self._on_update) + self.btn_delete.clicked.connect(self._on_delete) + right.addWidget(self.btn_add) + right.addWidget(self.btn_update) + right.addWidget(self.btn_delete) + right.addStretch() + + layout.addLayout(right, 2) + self.tab_widget.addTab(tab, t("tab_accounts")) + + # ── Tab: Opće postavke ──────────────────────────────────────────────────── + def _build_general_tab(self): + from PyQt6.QtWidgets import QComboBox + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Jezik + self.lang_group = QGroupBox(t("settings_language")) + lang_form = QFormLayout(self.lang_group) + self.lang_combo = QComboBox() + for name in _find_lang_files(): + self.lang_combo.addItem(name) + idx_l = self.lang_combo.findText(_current_lang.get("_language_name", "")) + if idx_l >= 0: + self.lang_combo.setCurrentIndex(idx_l) + self.lang_combo.currentTextChanged.connect(self._on_lang_changed) + lang_form.addRow(t("settings_language"), self.lang_combo) + layout.addWidget(self.lang_group) + + # TTS + self.tts_group = QGroupBox(t("settings_voice_alert")) + tts_form = QFormLayout(self.tts_group) + self.chk_voice = QCheckBox(t("settings_voice_alert")) + self.chk_voice.setChecked(_app_settings.get("VOICE_ALERT", True)) + self.rb_female = QRadioButton(t("settings_voice_female")) + self.rb_male = QRadioButton(t("settings_voice_male")) + gender = _app_settings.get("VOICE_GENDER", "Female") + self.rb_female.setChecked(gender == "Female") + self.rb_male.setChecked(gender == "Male") + self._gender_group = QButtonGroup() + self._gender_group.addButton(self.rb_female) + self._gender_group.addButton(self.rb_male) + tts_row = QHBoxLayout() + tts_row.addWidget(self.rb_female) + tts_row.addWidget(self.rb_male) + tts_row.addStretch() + tts_form.addRow(self.chk_voice) + tts_form.addRow(tts_row) + self.chk_voice.toggled.connect(self._save_general) + self.rb_female.toggled.connect(self._save_general) + layout.addWidget(self.tts_group) + layout.addStretch() + + self.tab_widget.addTab(tab, t("tab_general")) + + # ── Tab: O programu ─────────────────────────────────────────────────────── + def _build_about_tab(self): + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + lbl_name = QLabel("Mail Checker") + font = QFont() + font.setPointSize(18) + font.setBold(True) + lbl_name.setFont(font) + lbl_name.setAlignment(Qt.AlignmentFlag.AlignCenter) + + lbl_ver = QLabel("Verzija 2.0") + lbl_ver.setAlignment(Qt.AlignmentFlag.AlignCenter) + + lbl_desc = QLabel( + "Aplikacija za praćenje IMAP e-mail sandučića.\n" + "Podržava više računa, glasovne obavijesti i pregled poruka." + ) + lbl_desc.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl_desc.setWordWrap(True) + + lbl_deps = QLabel( + "Zahtijeva:\n" + "python3-pyqt6 • python3-pyqt6.qtwebengine\n" + "python3-cryptography • espeak-ng" + ) + lbl_deps.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl_deps.setStyleSheet("color: gray; font-size: 10px;") + + layout.addWidget(lbl_name) + layout.addSpacing(8) + layout.addWidget(lbl_ver) + layout.addSpacing(16) + layout.addWidget(lbl_desc) + layout.addSpacing(16) + layout.addWidget(lbl_deps) + + self.tab_widget.addTab(tab, t("tab_about")) + + # ── Metode ──────────────────────────────────────────────────────────────── + def _refresh_list(self): + self.account_list.clear() + for i, acc in enumerate(config_accounts): + name = acc.get("NICKNAME") or acc.get("MAIL_USER", "?") + self.account_list.addItem(f"{i+1}. {name}") + + def _on_select(self, idx: int): + if idx < 0 or idx >= len(config_accounts): + return + self._selected_idx = idx + acc = config_accounts[idx] + self.e_nickname.setText(acc.get("NICKNAME", "")) + self.e_server.setText(acc.get("IMAP_SERVER", "")) + self.e_user.setText(acc.get("MAIL_USER", "")) + self.e_password.setText(decrypt_password(acc.get("MAIL_PASSWORD", ""))) + self.e_interval.setValue(acc.get("CHECK_INTERVAL", 60)) + self.e_sound.setText(acc.get("SOUND_FILE", "")) + self.chk_preview.setChecked(acc.get("PREVIEW_ENABLED", True)) + self.spin_preview.setValue(acc.get("PREVIEW_COUNT", 5)) + self.lbl_status.setText("") + + def _read_form(self): + nickname = self.e_nickname.text().strip() + server = self.e_server.text().strip() + user = self.e_user.text().strip() + password = self.e_password.text().strip() + if not all([nickname, server, user, password]): + QMessageBox.critical(self, "Greška", t("settings_err_fields")) + return None + return { + "NICKNAME": nickname, + "IMAP_SERVER": server, + "MAIL_USER": user, + "MAIL_PASSWORD": encrypt_password(password), + "CHECK_INTERVAL": self.e_interval.value(), + "SOUND_FILE": self.e_sound.text().strip(), + "PREVIEW_ENABLED": self.chk_preview.isChecked(), + "PREVIEW_COUNT": self.spin_preview.value(), + } + + def _browse_sound(self): + path, _ = QFileDialog.getOpenFileName( + self, t("settings_sound_dialog_title"), "", + t("settings_sound_filter") + ) + if path: + self.e_sound.setText(path) + + def _save_general(self): + _app_settings["VOICE_ALERT"] = self.chk_voice.isChecked() + _app_settings["VOICE_GENDER"] = "Female" if self.rb_female.isChecked() else "Male" + save_app_settings() + + def _test_and_save(self, data: dict, idx=None): + self.lbl_status.setText(t("settings_testing")) + plain_pwd = decrypt_password(data["MAIL_PASSWORD"]) + worker = ConnectionTestWorker(data["IMAP_SERVER"], data["MAIL_USER"], plain_pwd) + + def on_done(ok, err): + if not ok: + self.lbl_status.setText(f"Greška: {err}") + return + with _config_lock: + if idx is None: + config_accounts.append(data) + else: + config_accounts[idx] = data + save_config() + self._refresh_list() + self.lbl_status.setText(t("settings_saved")) + if idx is not None: + self.account_list.setCurrentRow(idx) + self.accounts_changed.emit() + + worker.done.connect(on_done) + self._test_worker = worker + worker.start() + + def _on_add(self): + data = self._read_form() + if not data: + return + if any(a["MAIL_USER"] == data["MAIL_USER"] for a in config_accounts): + QMessageBox.critical(self, "Greška", t("settings_err_email_exists")) + return + if any(a.get("NICKNAME", "").lower() == data["NICKNAME"].lower() for a in config_accounts): + QMessageBox.critical(self, "Greška", t("settings_err_nickname_exists")) + return + self._test_and_save(data) + + def _on_update(self): + if self._selected_idx is None: + QMessageBox.warning(self, "Upozorenje", t("settings_err_no_selection_update")) + return + data = self._read_form() + if not data: + return + idx = self._selected_idx + for i, a in enumerate(config_accounts): + if i != idx and a["MAIL_USER"] == data["MAIL_USER"]: + QMessageBox.critical(self, "Greška", t("settings_err_email_other")) + return + self._test_and_save(data, idx) + + def _on_delete(self): + if self._selected_idx is None: + QMessageBox.warning(self, "Upozorenje", t("settings_err_no_selection_delete")) + return + idx = self._selected_idx + user = config_accounts[idx]["MAIL_USER"] + if QMessageBox.question( + self, t("settings_confirm_title"), + t("settings_confirm_delete", user=user) + ) != QMessageBox.StandardButton.Yes: + return + with _config_lock: + config_accounts.pop(idx) + save_config() + self._selected_idx = None + self._refresh_list() + self.lbl_status.setText("") + self.accounts_changed.emit() + + def _on_lang_changed(self, lang_name: str): + _app_settings["language"] = lang_name + save_app_settings() + change_language(lang_name) + + def _save_general(self): + _app_settings["VOICE_ALERT"] = self.chk_voice.isChecked() + _app_settings["VOICE_GENDER"] = "Female" if self.rb_female.isChecked() else "Male" + save_app_settings() + + def _retranslate(self): + self.setWindowTitle(t("settings_title")) + self.tab_widget.setTabText(0, t("tab_accounts")) + self.tab_widget.setTabText(1, t("tab_general")) + self.tab_widget.setTabText(2, t("tab_about")) + self.lbl_accounts.setText(t("settings_accounts_label")) + self.btn_up.setText(t("settings_btn_up")) + self.btn_dn.setText(t("settings_btn_down")) + self.form_group.setTitle(t("settings_group_details")) + self.lbl_nickname.setText(t("settings_nickname")) + self.lbl_server.setText(t("settings_server")) + self.lbl_email.setText(t("settings_email")) + self.lbl_password.setText(t("settings_password")) + self.lbl_interval.setText(t("settings_interval")) + self.lbl_sound.setText(t("settings_sound")) + self.btn_browse.setText(t("settings_browse")) + self.e_interval.setSuffix(t("settings_interval_suffix")) + self.chk_preview.setText(t("settings_preview_enabled")) + self.lbl_preview_count.setText(t("settings_preview_count")) + self.btn_add.setText(t("settings_btn_add")) + self.btn_update.setText(t("settings_btn_update")) + self.btn_delete.setText(t("settings_btn_delete")) + self.lang_group.setTitle(t("settings_language")) + self.tts_group.setTitle(t("settings_voice_alert")) + self.chk_voice.setText(t("settings_voice_alert")) + self.rb_female.setText(t("settings_voice_female")) + self.rb_male.setText(t("settings_voice_male")) + + def _move(self, direction: int): + idx = self._selected_idx + if idx is None: + return + new_idx = idx + direction + if new_idx < 0 or new_idx >= len(config_accounts): + return + with _config_lock: + config_accounts[idx], config_accounts[new_idx] = \ + config_accounts[new_idx], config_accounts[idx] + self._selected_idx = new_idx + save_config() + self._refresh_list() + self.account_list.setCurrentRow(new_idx) + + +# --------------------------------------------------------------------------- +# Glavni controller — tray + mail provjera +# --------------------------------------------------------------------------- +class MailCheckerApp(QObject): + def __init__(self): + super().__init__() + self._last_counts: dict = {} + self._check_workers: list = [] + self._settings_win = None + self._messages_win = None + + # Učitaj postavke (jezik) PRIJE kreiranja GUI-a + load_app_settings() + load_config() + + # Tray + self.tray = QSystemTrayIcon() + self.tray.setIcon(make_tray_icon("blue", 0)) + self.tray.setToolTip("Mail Checker") + self.tray.activated.connect(self._on_tray_activated) + + self._menu = QMenu() + self._action_check = self._menu.addAction(t("menu_check_now"), self._check_now) + self._action_status = self._menu.addAction(t("menu_status"), self._show_status) + self._action_msgs = self._menu.addAction(t("menu_messages"), self._show_messages) + self._menu.addSeparator() + self._action_settings = self._menu.addAction(t("menu_settings"), self._show_settings) + self._menu.addSeparator() + self._action_exit = self._menu.addAction(t("menu_exit"), QApplication.instance().quit) + self.tray.setContextMenu(self._menu) + self.tray.show() + + # Ažuriraj meni kad se jezik promijeni + register_lang_callback(self._retranslate_menu) + + # Timer za periodičnu provjeru + self._timer = QTimer() + self._timer.timeout.connect(self._check_now) + + self._start_timer() + + if not config_accounts: + self._show_settings() + else: + self._check_now() + + def _retranslate_menu(self): + self._action_check.setText(t("menu_check_now")) + self._action_status.setText(t("menu_status")) + self._action_msgs.setText(t("menu_messages")) + self._action_settings.setText(t("menu_settings")) + self._action_exit.setText(t("menu_exit")) + + def _get_interval_ms(self) -> int: + with _config_lock: + if not config_accounts: + return 60_000 + intervals = [a.get("CHECK_INTERVAL", 60) for a in config_accounts] + return max(10, min(intervals)) * 1000 + + def _start_timer(self): + self._timer.stop() + self._timer.start(self._get_interval_ms()) + + def _check_now(self): + with _config_lock: + accs = list(config_accounts) + if not accs: + self.tray.setIcon(make_tray_icon("gray", 0)) + self.tray.setToolTip("Mail Checker: Nema konfiguriranih računa") + return + worker = MailCheckWorker(accs) + worker.result_ready.connect(self._on_result) + worker.error_occurred.connect(self._on_error) + self._check_workers.append(worker) + worker.start() + + def _on_result(self, result: dict): + user, count = next(iter(result.items())) + previous = self._last_counts.get(user) + self._last_counts[user] = count + + with _config_lock: + acc = next((a for a in config_accounts if a["MAIL_USER"] == user), None) + if not acc: + return + + name = acc.get("NICKNAME") or user.split("@")[0] + sound = acc.get("SOUND_FILE", "") + voice_enabled = _app_settings.get("VOICE_ALERT", True) + voice_gender = _app_settings.get("VOICE_GENDER", "Female") + is_first = previous is None + + if is_first: + if count > 0: + word = croatian_mail_word(count) + send_notification(f"Mail: {user}", f"Nepročitanih poruka: {count}") + if sound: + play_sound_file(sound) + elif voice_enabled: + speak_text(f"{name}, imate {count} {word}", gender=voice_gender) + logger.info("[%s] Inicijalna provjera: %d nepročitanih.", user, count) + elif count != previous: + if count > 0: + word = croatian_mail_word(count) + send_notification(f"Mail: {user}", f"Nova poruka! Ukupno: {count}") + if sound: + play_sound_file(sound) + elif voice_enabled: + speak_text( + f"{name}, stigla je nova poruka. Ukupno imate {count} {word}", + gender=voice_gender) + logger.info("[%s] %d nepročitanih (ranije: %d)", user, count, previous) + 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) + + # Ažuriraj tray ikonu + total = sum(self._last_counts.values()) + if total > 0: + self.tray.setIcon(make_tray_icon("green", total)) + self.tray.setToolTip(f"Mail Checker: {total} ukupno nepročitanih") + else: + self.tray.setIcon(make_tray_icon("blue", 0)) + self.tray.setToolTip("Mail Checker: Inbox čist") + + def _on_error(self, user: str, err: str): + logger.error("[%s] Greška: %s", user, err) + self.tray.setIcon(make_tray_icon("gray", 0)) + + def _show_status(self): + with _config_lock: + accs = list(config_accounts) + if not accs: + self.tray.showMessage("Mail Checker", t("notify_no_accounts"), + QSystemTrayIcon.MessageIcon.Information) + return + lines = [] + total = 0 + for acc in accs: + user = acc["MAIL_USER"] + name = acc.get("NICKNAME") or user.split("@")[0] + count = self._last_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 = croatian_mail_word(count) + lines.append(f"{name}: {count} {word}") + total += count + title = f"Mail Checker — ukupno: {total}" if total > 0 else "Mail Checker — inbox čist" + self.tray.showMessage(title, "\n".join(lines), + QSystemTrayIcon.MessageIcon.Information, 5000) + + def _show_messages(self): + if self._messages_win and self._messages_win.isVisible(): + self._messages_win.raise_() + self._messages_win.activateWindow() + return + self._messages_win = MessagesWindow() + self._messages_win.show() + + def _show_settings(self): + if self._settings_win and self._settings_win.isVisible(): + self._settings_win.raise_() + self._settings_win.activateWindow() + return + self._settings_win = SettingsDialog() + self._settings_win.accounts_changed.connect(self._on_accounts_changed) + self._settings_win.show() + + def _on_accounts_changed(self): + self._last_counts.clear() + self._start_timer() + self._check_now() + + def _on_tray_activated(self, reason): + if reason == QSystemTrayIcon.ActivationReason.Trigger: + self._show_status() + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +if __name__ == "__main__": + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) # ne gasi se kad se zatvori prozor + checker = MailCheckerApp() + sys.exit(app.exec())