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("< -> newline
cleaned = _re2.sub(r"
i
", "\n", cleaned, flags=_re2.IGNORECASE)
cleaned = _re2.sub(r"
", "\n", cleaned, flags=_re2.IGNORECASE)
cleaned = _re2.sub(r"