You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1049 lines
38 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 05: 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 1214: 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({
"from": sender,
"subject": subject,
"date": date_str,
"unread": is_unread,
})
except Exception as exc:
logger.warning("[%s] Greška pri dohvatu poruke %s: %s", user, msg_id, exc)
continue
except Exception as exc:
logger.error("[%s] Greška pri dohvatu poruka: %s", user, exc)
finally:
if mail_conn:
try:
mail_conn.logout()
except Exception:
pass
return messages
# Singleton referenca na prozor poruka
_messages_win: list = [None]
# GUI zadaci koje treba pokrenuti iz glavnog threada (tray callbacks)
_pending_gui: list = []
def open_messages_window(icon=None, item=None):
"""Otvara prozor s pregledom poruka. Poziva se iz tray threada — koristi Toplevel."""
# Ako je prozor već otvoren, samo ga podigne na vrh
if _messages_win[0] is not None:
try:
_messages_win[0].lift()
_messages_win[0].focus_force()
return
except Exception:
_messages_win[0] = None
with _state_lock:
accounts = [a for a in config_accounts if a.get("PREVIEW_ENABLED", True)]
if not accounts:
_send_notification("Mail Checker", "Nema računa s uključenim pregledom poruka.")
return
def _build_window():
# Koristimo Toplevel koji dijeli event loop s ostalim tk prozorima
win = tk.Toplevel()
_messages_win[0] = win
win.title("Mail Checker — Pregled poruka")
win.geometry("760x460")
win.resizable(True, True)
def _on_close():
_messages_win[0] = None
win.destroy()
win.protocol("WM_DELETE_WINDOW", _on_close)
btn_frame = tk.Frame(win)
btn_frame.pack(fill="x", padx=10, pady=(8, 0))
lbl_loading = tk.Label(btn_frame, text="", fg="gray")
lbl_loading.pack(side="left")
notebook = ttk.Notebook(win)
notebook.pack(fill="both", expand=True, padx=10, pady=6)
win.update_idletasks()
# Toplevel nema eval — centriramo ručno
win.update_idletasks()
sw = win.winfo_screenwidth()
sh = win.winfo_screenheight()
ww = win.winfo_width()
wh = win.winfo_height()
x = (sw - ww) // 2
y = (sh - wh) // 2
win.geometry(f"+{x}+{y}")
def _build_tab(name, messages):
"""Kreira jedan tab — poziva se iz glavnog threada."""
frame = tk.Frame(notebook)
notebook.add(frame, text=f" {name} ")
cols = ("status", "from", "subject", "date")
tree = ttk.Treeview(frame, columns=cols, show="headings", height=12)
tree.heading("status", text="")
tree.heading("from", text="Pošiljatelj")
tree.heading("subject", text="Naslov")
tree.heading("date", text="Datum")
tree.column("status", width=22, anchor="center", stretch=False)
tree.column("from", width=200, anchor="w")
tree.column("subject", width=320, anchor="w")
tree.column("date", width=130, anchor="center")
tree.tag_configure("unread", font=("TkDefaultFont", 9, "bold"))
vsb = ttk.Scrollbar(frame, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=vsb.set)
tree.pack(side="left", fill="both", expand=True)
vsb.pack(side="right", fill="y")
if messages:
for m in messages:
marker = "" if m["unread"] else ""
tags = ("unread",) if m["unread"] else ()
tree.insert("", "end",
values=(marker, m["from"], m["subject"], m["date"]),
tags=tags)
else:
tree.insert("", "end", values=("", "", "Nema poruka", ""))
def _do_refresh():
"""Background thread: dohvati podatke pa ažuriraj GUI kroz win.after."""
with _state_lock:
accs = [a for a in config_accounts if a.get("PREVIEW_ENABLED", True)]
win.after(0, lambda: lbl_loading.config(text="Dohvaćanje poruka...", fg="gray"))
results = []
for acc in accs:
name = acc.get("NICKNAME") or acc["MAIL_USER"].split("@")[0]
limit = acc.get("PREVIEW_COUNT", 5)
msgs = _fetch_messages(acc, limit=limit)
results.append((name, msgs))
logger.info("Dohvaćeno %d poruka za %s", len(msgs), name)
def _finish_ui():
# Očisti stare tabove
for tab in notebook.tabs():
notebook.forget(tab)
# Dodaj nove
for name, msgs in results:
_build_tab(name, msgs)
lbl_loading.config(text=f"Zadnje osvježavanje: {time.strftime('%H:%M:%S')}", fg="gray")
win.after(0, _finish_ui)
tk.Button(
btn_frame, text="↻ Osvježi", width=10,
command=lambda: threading.Thread(target=_do_refresh, daemon=True).start()
).pack(side="right")
# Pokretanje prvog dohvata
threading.Thread(target=_do_refresh, daemon=True).start()
# Ubaci u pending queue — glavna petlja (setup_tray) će pokrenuti u pravom threadu
_pending_gui.append(_build_window)
# ---------------------------------------------------------------------------
# Tray setup i main
# ---------------------------------------------------------------------------
def show_status(icon=None, item=None):
"""Prikazuje notifikaciju s brojem nepročitanih po svakom računu."""
with _state_lock:
counts = dict(last_unread_counts)
accounts = list(config_accounts)
if not accounts:
_send_notification("Mail Checker", "Nema konfiguriranih računa.")
return
lines = []
total = 0
for acc in accounts:
user = acc["MAIL_USER"]
name = acc.get("NICKNAME") or user.split("@")[0]
count = counts.get(user)
if count is None:
lines.append(f"{name}: još nije provjereno")
elif count == 0:
lines.append(f"{name}: inbox čist")
else:
word = get_croatian_mail_word(count)
lines.append(f"{name}: {count} {word}")
total += count
body = "\n".join(lines)
title = f"Mail Checker — ukupno: {total}" if total > 0 else "Mail Checker — inbox čist"
_send_notification(title, body)
def setup_tray():
global tray_icon
menu = pystray.Menu(
pystray.MenuItem("Provjeri odmah", lambda: threading.Thread(
target=check_all_mails, daemon=True).start()),
pystray.MenuItem("Status", show_status),
pystray.MenuItem("Pregled poruka", open_messages_window),
pystray.MenuItem("Postavke", show_settings_from_tray),
pystray.MenuItem("Izlaz", on_exit),
)
load_config()
color = "blue" if config_accounts else "gray"
title = "Mail Checker" if config_accounts else "Mail Checker: Nema konfiguriranih računa"
tray_icon = pystray.Icon("mail_checker", create_icon_image(color), title, menu)
checker_thread = threading.Thread(target=background_loop, daemon=True)
checker_thread.start()
if not config_accounts:
threading.Thread(target=open_settings_window, daemon=True).start()
def _process_pending():
"""Procesira GUI zadatke zahtijevane iz tray threada."""
while _pending_gui:
fn = _pending_gui.pop(0)
try:
fn()
except Exception as exc:
logger.error("Greška pri pokretanju GUI zadatka: %s", exc)
def _run_tray_with_gui_pump():
"""Pokreće tray u zasebnom threadu, a glavni thread pumpuje GUI zadatke."""
tray_thread = threading.Thread(target=tray_icon.run, daemon=True)
tray_thread.start()
# Drži glavni thread živ i procesira pending GUI zadatke
import tkinter as _tk
root = _tk.Tk()
root.withdraw() # nevidljiv root — samo drži event loop živ
def _pump():
_process_pending()
if is_running:
root.after(200, _pump)
else:
root.destroy()
root.after(200, _pump)
try:
root.mainloop()
except Exception:
pass
try:
_run_tray_with_gui_pump()
except Exception as exc:
logger.critical("Tray ikona se srušila: %s", exc)
if __name__ == "__main__":
setup_tray()

Powered by TurnKey Linux.