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