|
|
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("<<ListboxSelect>>", 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("<Double-1>", _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()
|