"""
Mail Checker — PyQt6 verzija
Potrebno instalirati:
sudo apt install python3-pyqt6 python3-pyqt6.qtwebengine python3-pyqt6.qtmultimedia python3-cryptography
"""
import sys
import os
import json
import time
import imaplib
import email
import email.header
import email.utils
import logging
import queue
import subprocess
import threading
import tempfile
import re
import html as _html_mod
from PyQt6.QtWidgets import (
QApplication, QSystemTrayIcon, QMenu, QDialog, QWidget,
QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QLineEdit,
QPushButton, QListWidget, QListWidgetItem, QCheckBox,
QRadioButton, QButtonGroup, QSplitter, QFrame, QFileDialog,
QMessageBox, QTabWidget, QTreeWidget, QTreeWidgetItem,
QHeaderView, QSpinBox, QSizePolicy, QGroupBox,
QStatusBar, QMainWindow, QProgressBar, QPlainTextEdit
)
from PyQt6.QtCore import (
Qt, QThread, pyqtSignal, QTimer, QSize, QObject
)
from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QPen, QBrush
from PyQt6.QtWebEngineWidgets import QWebEngineView
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOG_FILE = "mail_checker.log"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_FILE, encoding="utf-8"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Konstante
# ---------------------------------------------------------------------------
CONFIG_FILE = "config.json"
APP_SETTINGS_FILE = "app_settings.json"
MAX_RETRIES = 3
RETRY_DELAY = 5
# ---------------------------------------------------------------------------
# Lokalizacija
# ---------------------------------------------------------------------------
LANG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lang")
_current_lang: dict = {}
_lang_change_callbacks: list = []
def _find_lang_files() -> dict:
"""Vraća dict {language_name: file_path} svih dostupnih lang datoteka."""
langs = {}
search_dirs = [LANG_DIR, os.path.dirname(os.path.abspath(__file__))]
for d in search_dirs:
if not os.path.isdir(d):
continue
for fname in sorted(os.listdir(d)):
if fname.startswith("lang_") and fname.endswith(".json"):
path = os.path.join(d, fname)
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
name = data.get("_language_name", fname)
if name not in langs:
langs[name] = path
except Exception:
pass
return langs
def load_language(lang_name: str = None):
"""Učitava jezičnu datoteku po imenu. Fallback na HR pa EN pa prvi dostupni."""
global _current_lang
available = _find_lang_files()
path = None
if lang_name and lang_name in available:
path = available[lang_name]
elif "Hrvatski" in available:
path = available["Hrvatski"]
elif "English" in available:
path = available["English"]
elif available:
path = next(iter(available.values()))
if path:
try:
with open(path, "r", encoding="utf-8") as f:
_current_lang = json.load(f)
logger.info("Jezik: %s", _current_lang.get("_language_name", path))
return
except Exception as exc:
logger.error("Greška pri učitavanju jezika: %s", exc)
_current_lang = {"_language_name": "English", "_language_code": "en"}
def t(key: str, **kwargs) -> str:
"""Vraća prevedeni string za ključ."""
text = _current_lang.get(key, key)
if kwargs:
try:
text = text.format(**kwargs)
except Exception:
pass
return text
def register_lang_callback(fn):
_lang_change_callbacks.append(fn)
def change_language(lang_name: str):
"""Mijenja jezik i ažurira sve prozore."""
load_language(lang_name)
for fn in list(_lang_change_callbacks):
try:
fn()
except Exception as exc:
logger.warning("Lang callback greška: %s", exc)
# Inicijalno učitavanje jezika — poziva se iz load_app_settings() pri startu
# Ne pozivaj ovdje — _app_settings još nije učitan
# ---------------------------------------------------------------------------
# Enkripcija lozinke (Fernet)
# ---------------------------------------------------------------------------
_CRYPTO_KEY_FILE = ".mail_checker.key"
def _get_fernet():
try:
from cryptography.fernet import Fernet
if not os.path.exists(_CRYPTO_KEY_FILE):
key = Fernet.generate_key()
with open(_CRYPTO_KEY_FILE, "wb") as f:
f.write(key)
os.chmod(_CRYPTO_KEY_FILE, 0o600)
else:
with open(_CRYPTO_KEY_FILE, "rb") as f:
key = f.read()
return Fernet(key)
except ImportError:
logger.warning("cryptography nije instaliran — lozinke se čuvaju plain text!")
return None
except Exception as exc:
logger.error("Greška pri učitavanju ključa: %s", exc)
return None
_fernet = _get_fernet()
def encrypt_password(password: str) -> str:
if _fernet and password:
return _fernet.encrypt(password.encode()).decode()
return password
def decrypt_password(encrypted: str) -> str:
if _fernet and encrypted:
try:
return _fernet.decrypt(encrypted.encode()).decode()
except Exception:
return encrypted # stari plain text zapis
return encrypted
# ---------------------------------------------------------------------------
# Konfiguracija
# ---------------------------------------------------------------------------
_config_lock = threading.Lock()
config_accounts: list[dict] = []
_app_settings: dict = {}
def load_app_settings():
global _app_settings
if os.path.exists(APP_SETTINGS_FILE):
try:
with open(APP_SETTINGS_FILE, "r", encoding="utf-8") as f:
_app_settings = json.load(f)
except Exception:
_app_settings = {}
load_language(_app_settings.get("language"))
def save_app_settings():
try:
with open(APP_SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(_app_settings, f, indent=4, ensure_ascii=False)
except Exception as exc:
logger.error("Greška pri snimanju postavki: %s", exc)
def load_config():
global config_accounts
if not os.path.exists(CONFIG_FILE):
config_accounts = []
return
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
config_accounts = data
elif isinstance(data, dict) and data:
config_accounts = [data]
else:
config_accounts = []
except Exception as exc:
logger.error("Greška pri učitavanju konfiguracije: %s", exc)
config_accounts = []
def save_config():
with _config_lock:
try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config_accounts, f, indent=4, ensure_ascii=False)
except Exception as exc:
logger.error("Greška pri snimanju konfiguracije: %s", exc)
def get_password(acc: dict) -> str:
return decrypt_password(acc.get("MAIL_PASSWORD", ""))
# ---------------------------------------------------------------------------
# TTS — serijski red čekanja
# ---------------------------------------------------------------------------
_tts_queue: queue.Queue = queue.Queue()
def _tts_worker():
while True:
item = _tts_queue.get()
if item is None:
break
text, gender = item
voice = "hr+f3" if gender == "Female" else "hr"
try:
subprocess.run(
["espeak-ng", "-v", voice, "-s", "150", text],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
except FileNotFoundError:
logger.warning("espeak-ng nije pronađen.")
finally:
_tts_queue.task_done()
threading.Thread(target=_tts_worker, daemon=True).start()
def speak_text(text: str, gender: str = "Female"):
_tts_queue.put((text, gender))
# ---------------------------------------------------------------------------
# Zvuk
# ---------------------------------------------------------------------------
def play_sound_file(file_path: str):
if not file_path or not os.path.exists(file_path):
return
def _run():
ext = file_path.lower().rsplit(".", 1)[-1]
if ext == "wav":
subprocess.run(["aplay", file_path], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
for player in ["mpv", "ffplay"]:
try:
subprocess.run([player, "--no-video" if player == "mpv" else "-nodisp",
"-autoexit" if player == "ffplay" else "",
file_path], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
break
except FileNotFoundError:
continue
threading.Thread(target=_run, daemon=True).start()
# ---------------------------------------------------------------------------
# Notifikacija
# ---------------------------------------------------------------------------
def send_notification(title: str, body: str):
def _run():
try:
subprocess.run(
["notify-send", "--expire-time=5000", title, body],
check=False, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=5
)
except Exception as exc:
logger.warning("notify-send greška: %s", exc)
threading.Thread(target=_run, daemon=True).start()
# ---------------------------------------------------------------------------
# Hrvatska deklinacija
# ---------------------------------------------------------------------------
def croatian_mail_word(count: int) -> str:
if count % 100 in range(11, 15):
return "poruka"
last = count % 10
if last == 1: return "poruku"
if last in (2, 3, 4): return "poruke"
return "poruka"
# ---------------------------------------------------------------------------
# Tray ikona — crtanje u Qt
# ---------------------------------------------------------------------------
def make_tray_icon(color: str = "blue", count: int = 0) -> QIcon:
px = QPixmap(64, 64)
px.fill(QColor(color))
painter = QPainter(px)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen(QColor("white"), 2)
painter.setPen(pen)
# Omotnica
painter.drawRect(12, 18, 40, 28)
painter.drawLine(12, 18, 32, 32)
painter.drawLine(52, 18, 32, 32)
# Broj nepročitanih
if count > 0:
label = str(count) if count < 100 else "99+"
painter.setBrush(QBrush(QColor("red")))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(36, 36, 26, 26)
painter.setPen(QPen(QColor("white")))
font = QFont()
font.setBold(True)
font.setPixelSize(13)
painter.setFont(font)
painter.drawText(36, 36, 26, 26, Qt.AlignmentFlag.AlignCenter, label)
painter.end()
return QIcon(px)
# ---------------------------------------------------------------------------
# IMAP worker thread
# ---------------------------------------------------------------------------
class MailCheckWorker(QThread):
"""Provjerava mailove u backgroundu i emitira signale."""
result_ready = pyqtSignal(dict) # {user: count}
error_occurred = pyqtSignal(str, str) # user, poruka
def __init__(self, accounts: list[dict]):
super().__init__()
self.accounts = accounts
def run(self):
for acc in self.accounts:
user = acc["MAIL_USER"]
server = acc["IMAP_SERVER"]
password = get_password(acc)
for attempt in range(1, MAX_RETRIES + 1):
mail = None
try:
mail = imaplib.IMAP4_SSL(server, timeout=15)
mail.login(user, password)
mail.select("inbox")
status, response = mail.search(None, "UNSEEN")
if status == "OK":
count = len(response[0].split())
self.result_ready.emit({user: count})
break
except imaplib.IMAP4.error as exc:
self.error_occurred.emit(user, str(exc))
break
except Exception as exc:
if attempt == MAX_RETRIES:
self.error_occurred.emit(user, str(exc))
else:
time.sleep(RETRY_DELAY * attempt)
finally:
if mail:
try: mail.logout()
except: pass
class ConnectionTestWorker(QThread):
"""Testira IMAP vezu i emitira signal s rezultatom."""
done = pyqtSignal(bool, str)
def __init__(self, server, user, password):
super().__init__()
self.server = server
self.user = user
self.password = password
def run(self):
mail = None
try:
mail = imaplib.IMAP4_SSL(self.server, timeout=10)
mail.login(self.user, self.password)
self.done.emit(True, "")
except Exception as exc:
self.done.emit(False, str(exc))
finally:
if mail:
try: mail.logout()
except: pass
class FetchMessagesWorker(QThread):
"""Dohvaća poruke za jedan račun."""
done = pyqtSignal(str, list) # nickname, poruke
def __init__(self, acc: dict):
super().__init__()
self.acc = acc
def run(self):
name = self.acc.get("NICKNAME") or self.acc["MAIL_USER"].split("@")[0]
limit = self.acc.get("PREVIEW_COUNT", 5)
messages = []
mail_conn = None
try:
mail_conn = imaplib.IMAP4_SSL(self.acc["IMAP_SERVER"], timeout=15)
mail_conn.login(self.acc["MAIL_USER"], get_password(self.acc))
mail_conn.select("inbox", readonly=True)
status, response = mail_conn.search(None, "ALL")
if status != "OK" or not response[0]:
self.done.emit(name, [])
return
all_ids = response[0].split()
ids_to_fetch = all_ids[-limit:][::-1]
status2, unseen_resp = mail_conn.search(None, "UNSEEN")
unseen_ids = set(unseen_resp[0].split()) if status2 == "OK" else set()
for msg_id in ids_to_fetch:
try:
s, data = mail_conn.fetch(
msg_id, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])")
if s != "OK" or not data or not data[0]:
continue
msg = email.message_from_bytes(data[0][1])
from_raw = msg.get("From", "")
parsed = email.utils.parseaddr(from_raw)
sender = _decode_header(parsed[0] if parsed[0] else parsed[1])
subject = _decode_header(msg.get("Subject", "(bez naslova)"))
date_raw = msg.get("Date", "")
try:
dt = email.utils.parsedate_to_datetime(date_raw)
date_str = dt.strftime("%d.%m.%Y %H:%M")
except Exception:
date_str = date_raw[:16] if date_raw else "?"
mid = msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id)
messages.append({
"msg_id": mid,
"from": sender,
"subject": subject,
"date": date_str,
"unread": msg_id in unseen_ids,
})
except Exception as exc:
logger.warning("Greška pri dohvatu poruke: %s", exc)
except Exception as exc:
logger.error("[%s] Greška pri dohvatu poruka: %s", self.acc["MAIL_USER"], exc)
finally:
if mail_conn:
try: mail_conn.logout()
except: pass
self.done.emit(name, messages)
class FetchBodyWorker(QThread):
"""Dohvaća tijelo poruke."""
done = pyqtSignal(str, str) # plain, html
def __init__(self, acc: dict, msg_id: str):
super().__init__()
self.acc = acc
self.msg_id = msg_id
def run(self):
mail_conn = None
plain = ""
html = ""
try:
mail_conn = imaplib.IMAP4_SSL(self.acc["IMAP_SERVER"], timeout=15)
mail_conn.login(self.acc["MAIL_USER"], get_password(self.acc))
mail_conn.select("inbox")
status, data = mail_conn.fetch(self.msg_id.encode(), "(RFC822)")
if status != "OK" or not data or not data[0]:
self.done.emit("(Nije moguće dohvatiti sadržaj)", "")
return
msg = email.message_from_bytes(data[0][1])
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if "attachment" in cd:
continue
payload = part.get_payload(decode=True)
if payload is None:
continue
charset = part.get_content_charset() or "utf-8"
decoded = payload.decode(charset, errors="replace")
if ct == "text/plain" and not plain:
plain = decoded
elif ct == "text/html" and not html:
html = decoded
else:
charset = msg.get_content_charset() or "utf-8"
payload = msg.get_payload(decode=True)
if payload:
decoded = payload.decode(charset, errors="replace")
if msg.get_content_type() == "text/html":
html = decoded
else:
plain = decoded
except Exception as exc:
logger.error("Greška pri dohvatu tijela: %s", exc)
plain = f"(Greška: {exc})"
finally:
if mail_conn:
try: mail_conn.logout()
except: pass
self.done.emit(plain, html)
class DeleteMessageWorker(QThread):
done = pyqtSignal(bool)
def __init__(self, acc: dict, msg_id: str):
super().__init__()
self.acc = acc
self.msg_id = msg_id
def run(self):
mail_conn = None
try:
mail_conn = imaplib.IMAP4_SSL(self.acc["IMAP_SERVER"], timeout=15)
mail_conn.login(self.acc["MAIL_USER"], get_password(self.acc))
mail_conn.select("inbox")
trash_folders = ["[Gmail]/Trash", "Trash", "INBOX.Trash",
"Deleted Items", "Deleted Messages"]
for folder in trash_folders:
try:
res = mail_conn.copy(self.msg_id.encode(), folder)
if res[0] == "OK":
break
except Exception:
continue
mail_conn.store(self.msg_id.encode(), "+FLAGS", r"\Deleted")
mail_conn.expunge()
self.done.emit(True)
except Exception as exc:
logger.error("Greška pri brisanju: %s", exc)
self.done.emit(False)
finally:
if mail_conn:
try: mail_conn.logout()
except: pass
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _decode_header(raw: str) -> str:
parts = email.header.decode_header(raw or "")
decoded = []
for part, charset in parts:
if isinstance(part, bytes):
decoded.append(part.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(str(part))
return "".join(decoded)
def html_to_plain(html_content: str) -> str:
t = re.sub(r"", "", html_content,
flags=re.IGNORECASE | re.DOTALL)
t = re.sub(r"", "", t,
flags=re.IGNORECASE | re.DOTALL)
t = re.sub(r"
]*>.*?", "", t,
flags=re.IGNORECASE | re.DOTALL)
t = re.sub(r"
", "\n", t, flags=re.IGNORECASE)
t = re.sub(r"||", "\n", t, flags=re.IGNORECASE)
t = re.sub(r"<[^>]+>", "", t)
t = _html_mod.unescape(t)
t = re.sub(r"[ \t]+", " ", t)
t = re.sub(r"\n{3,}", "\n\n", t)
return t.strip()
# ---------------------------------------------------------------------------
# Prozor za čitanje poruke
# ---------------------------------------------------------------------------
class ReadMessageWindow(QDialog):
def __init__(self, acc: dict, msg: dict, parent=None, on_deleted=None):
super().__init__(parent)
self.acc = acc
self.msg = msg
self.on_deleted = on_deleted
self._html_content = ""
self._tmp_file = ""
self._workers = []
self.setWindowTitle(f"Poruka — {msg['subject']}")
self.resize(900, 680)
self.setWindowFlag(Qt.WindowType.Window)
layout = QVBoxLayout(self)
# Zaglavlje
hdr = QFrame()
hdr.setFrameShape(QFrame.Shape.StyledPanel)
hdr_layout = QFormLayout(hdr)
hdr_layout.addRow("Od:", QLabel(msg["from"]))
subj_lbl = QLabel(msg["subject"])
subj_lbl.setFont(QFont("", -1, QFont.Weight.Bold))
hdr_layout.addRow("Naslov:", subj_lbl)
hdr_layout.addRow("Datum:", QLabel(msg["date"]))
layout.addWidget(hdr)
# Toolbar
toolbar = QHBoxLayout()
self.lbl_status = QLabel(t("read_loading"))
toolbar.addWidget(self.lbl_status)
toolbar.addStretch()
self.btn_browser = QPushButton(t("read_btn_browser"))
self.btn_browser.setEnabled(False)
self.btn_browser.clicked.connect(self._open_in_browser)
toolbar.addWidget(self.btn_browser)
btn_delete = QPushButton(t("read_btn_delete"))
btn_delete.setStyleSheet("color: red;")
btn_delete.clicked.connect(self._do_delete)
toolbar.addWidget(btn_delete)
layout.addLayout(toolbar)
# Tab — plain / HTML (dodaje se dinamički nakon dohvata)
self.tabs = QTabWidget()
layout.addWidget(self.tabs)
# Dohvati tijelo
worker = FetchBodyWorker(acc, msg["msg_id"])
worker.done.connect(self._on_body_loaded)
self._workers.append(worker)
worker.start()
def _on_body_loaded(self, plain: str, html: str):
self.lbl_status.setText("")
self._html_content = html
if html:
# HTML poruka — prikaži WebEngine view
web_view = QWebEngineView()
web_view.setHtml(html)
self.tabs.addTab(web_view, "HTML")
self.btn_browser.setEnabled(True)
elif plain and plain != t("read_empty"):
# Plain text poruka — prikaži plain text editor
plain_view = QPlainTextEdit()
plain_view.setReadOnly(True)
plain_view.setFont(QFont("Monospace", 10))
plain_view.setPlainText(plain)
self.tabs.addTab(plain_view, t("read_tab_plain"))
else:
plain_view = QPlainTextEdit()
plain_view.setReadOnly(True)
plain_view.setPlainText(t("read_empty"))
self.tabs.addTab(plain_view, "Poruka")
def _open_in_browser(self):
if not self._html_content:
return
import webbrowser
try:
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".html", delete=False,
encoding="utf-8", prefix="mail_checker_"
)
tmp.write(self._html_content)
tmp.close()
self._tmp_file = tmp.name
webbrowser.open(f"file://{tmp.name}")
except Exception as exc:
QMessageBox.critical(self, "Greška", f"Ne mogu otvoriti browser: {exc}")
def _do_delete(self):
if QMessageBox.question(self, t("settings_confirm_title"), t("messages_confirm_delete")) != QMessageBox.StandardButton.Yes:
return
self.lbl_status.setText(t("read_deleting"))
worker = DeleteMessageWorker(self.acc, self.msg["msg_id"])
worker.done.connect(self._on_deleted)
self._workers.append(worker)
worker.start()
def _on_deleted(self, ok: bool):
if ok:
self.lbl_status.setText(t("read_deleted"))
if self.on_deleted:
self.on_deleted()
QTimer.singleShot(1000, self.close)
else:
self.lbl_status.setText(t("read_err_delete"))
def closeEvent(self, event):
if self._tmp_file:
try: os.remove(self._tmp_file)
except: pass
super().closeEvent(event)
# ---------------------------------------------------------------------------
# Prozor za pregled poruka
# ---------------------------------------------------------------------------
class MessagesWindow(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle(t("messages_title"))
self.resize(820, 520)
self.setWindowFlag(Qt.WindowType.Window)
self._workers = []
self._read_windows = []
layout = QVBoxLayout(self)
# Toolbar
toolbar = QHBoxLayout()
self.lbl_status = QLabel("")
toolbar.addWidget(self.lbl_status)
toolbar.addStretch()
btn_refresh = QPushButton(t("messages_refresh"))
btn_refresh.clicked.connect(self.load_messages)
toolbar.addWidget(btn_refresh)
layout.addLayout(toolbar)
# Tab po računu
self.tabs = QTabWidget()
layout.addWidget(self.tabs)
self.load_messages()
def _retranslate(self):
self.setWindowTitle(t("messages_title"))
if hasattr(self, "btn_refresh"):
self.btn_refresh.setText(t("messages_refresh"))
def load_messages(self):
self.tabs.clear()
self.lbl_status.setText(t("messages_loading"))
with _config_lock:
accs = [a for a in config_accounts if a.get("PREVIEW_ENABLED", True)]
if not accs:
self.lbl_status.setText(t("messages_no_accounts"))
return
self._pending = len(accs)
for acc in accs:
worker = FetchMessagesWorker(acc)
worker.done.connect(self._on_messages_ready)
self._workers.append(worker)
worker.start()
def _on_messages_ready(self, name: str, messages: list):
# Pronađi acc za ovaj tab
with _config_lock:
acc = next((a for a in config_accounts
if (a.get("NICKNAME") or a["MAIL_USER"].split("@")[0]) == name), None)
tab = QWidget()
tab_layout = QVBoxLayout(tab)
tree = QTreeWidget()
tree.setColumnCount(4)
tree.setHeaderLabels([t("messages_col_status"), t("messages_col_from"), t("messages_col_subject"), t("messages_col_date")])
tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive)
tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
tree.header().setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive)
tree.setColumnWidth(0, 24)
tree.setColumnWidth(1, 200)
tree.setColumnWidth(3, 140)
tree.setAlternatingRowColors(True)
tree.setRootIsDecorated(False)
bold_font = QFont()
bold_font.setBold(True)
msg_map = {}
if messages:
for m in messages:
item = QTreeWidgetItem([
"●" if m["unread"] else "",
m["from"], m["subject"], m["date"]
])
if m["unread"]:
for col in range(4):
item.setFont(col, bold_font)
tree.addTopLevelItem(item)
msg_map[id(item)] = m
else:
tree.addTopLevelItem(QTreeWidgetItem(["", "—", t("messages_no_messages"), "—"]))
tab_layout.addWidget(tree)
# Gumbi
btn_row = QHBoxLayout()
btn_read = QPushButton(t("messages_btn_read"))
btn_delete = QPushButton(t("messages_btn_delete"))
btn_delete.setStyleSheet("color: red;")
btn_row.addWidget(btn_read)
btn_row.addWidget(btn_delete)
btn_row.addStretch()
tab_layout.addLayout(btn_row)
def get_selected_msg():
sel = tree.selectedItems()
if not sel:
return None
return msg_map.get(id(sel[0]))
def open_read():
m = get_selected_msg()
if not m or not acc:
return
win = ReadMessageWindow(acc, m, self,
on_deleted=self.load_messages)
self._read_windows.append(win)
win.show()
def do_delete():
m = get_selected_msg()
if not m or not acc:
QMessageBox.warning(self, "Upozorenje", t("messages_warn_select"))
return
if QMessageBox.question(self, t("settings_confirm_title"), t("messages_confirm_delete")) != QMessageBox.StandardButton.Yes:
return
worker = DeleteMessageWorker(acc, m["msg_id"])
worker.done.connect(lambda ok: self.load_messages() if ok else
QMessageBox.critical(self, "Greška", t("messages_err_delete")))
self._workers.append(worker)
worker.start()
tree.doubleClicked.connect(lambda: open_read())
btn_read.clicked.connect(open_read)
btn_delete.clicked.connect(do_delete)
self.tabs.addTab(tab, f" {name} ")
self._pending -= 1
if self._pending <= 0:
self.lbl_status.setText(
t("messages_last_refresh", time=time.strftime("%H:%M:%S")))
# ---------------------------------------------------------------------------
# Prozor postavki — tabovi: Računi | Opće | O programu
# ---------------------------------------------------------------------------
class SettingsDialog(QDialog):
accounts_changed = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle(t("settings_title"))
self.resize(860, 560)
self._test_worker = None
self._selected_idx = None
main_layout = QVBoxLayout(self)
self.tab_widget = QTabWidget()
main_layout.addWidget(self.tab_widget)
self._build_accounts_tab()
self._build_general_tab()
self._build_about_tab()
register_lang_callback(self._retranslate)
self._refresh_list()
# ── Tab: Računi ───────────────────────────────────────────────────────────
def _build_accounts_tab(self):
tab = QWidget()
layout = QHBoxLayout(tab)
# Lijevo — popis
left = QVBoxLayout()
self.lbl_accounts = QLabel(t("settings_accounts_label"))
left.addWidget(self.lbl_accounts)
self.account_list = QListWidget()
self.account_list.currentRowChanged.connect(self._on_select)
left.addWidget(self.account_list, 1)
order_row = QHBoxLayout()
self.btn_up = QPushButton(t("settings_btn_up"))
self.btn_dn = QPushButton(t("settings_btn_down"))
self.btn_up.clicked.connect(lambda: self._move(-1))
self.btn_dn.clicked.connect(lambda: self._move(1))
order_row.addWidget(self.btn_up)
order_row.addWidget(self.btn_dn)
left.addLayout(order_row)
layout.addLayout(left, 1)
# Desno — formular
right = QVBoxLayout()
self.form_group = QGroupBox(t("settings_group_details"))
form = QFormLayout(self.form_group)
self.e_nickname = QLineEdit()
self.e_server = QLineEdit()
self.e_user = QLineEdit()
self.e_password = QLineEdit()
self.e_password.setEchoMode(QLineEdit.EchoMode.Password)
self.e_interval = QSpinBox()
self.e_interval.setRange(10, 86400)
self.e_interval.setValue(60)
self.e_interval.setSuffix(t("settings_interval_suffix"))
sound_row = QHBoxLayout()
self.e_sound = QLineEdit()
self.btn_browse = QPushButton(t("settings_browse"))
self.btn_browse.clicked.connect(self._browse_sound)
sound_row.addWidget(self.e_sound)
sound_row.addWidget(self.btn_browse)
self.lbl_nickname = QLabel(t("settings_nickname"))
self.lbl_server = QLabel(t("settings_server"))
self.lbl_email = QLabel(t("settings_email"))
self.lbl_password = QLabel(t("settings_password"))
self.lbl_interval = QLabel(t("settings_interval"))
self.lbl_sound = QLabel(t("settings_sound"))
self.lbl_preview_count = QLabel(t("settings_preview_count"))
form.addRow(self.lbl_nickname, self.e_nickname)
form.addRow(self.lbl_server, self.e_server)
form.addRow(self.lbl_email, self.e_user)
form.addRow(self.lbl_password, self.e_password)
form.addRow(self.lbl_interval, self.e_interval)
form.addRow(self.lbl_sound, sound_row)
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
form.addRow(sep)
self.chk_preview = QCheckBox(t("settings_preview_enabled"))
self.chk_preview.setChecked(True)
self.spin_preview = QSpinBox()
self.spin_preview.setRange(1, 50)
self.spin_preview.setValue(5)
form.addRow(self.chk_preview)
form.addRow(self.lbl_preview_count, self.spin_preview)
right.addWidget(self.form_group)
self.lbl_status = QLabel("")
self.lbl_status.setAlignment(Qt.AlignmentFlag.AlignCenter)
right.addWidget(self.lbl_status)
self.btn_add = QPushButton(t("settings_btn_add"))
self.btn_update = QPushButton(t("settings_btn_update"))
self.btn_update.setStyleSheet("color: blue;")
self.btn_delete = QPushButton(t("settings_btn_delete"))
self.btn_delete.setStyleSheet("color: red;")
self.btn_add.clicked.connect(self._on_add)
self.btn_update.clicked.connect(self._on_update)
self.btn_delete.clicked.connect(self._on_delete)
right.addWidget(self.btn_add)
right.addWidget(self.btn_update)
right.addWidget(self.btn_delete)
right.addStretch()
layout.addLayout(right, 2)
self.tab_widget.addTab(tab, t("tab_accounts"))
# ── Tab: Opće postavke ────────────────────────────────────────────────────
def _build_general_tab(self):
from PyQt6.QtWidgets import QComboBox
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Jezik
self.lang_group = QGroupBox(t("settings_language"))
lang_form = QFormLayout(self.lang_group)
self.lang_combo = QComboBox()
for name in _find_lang_files():
self.lang_combo.addItem(name)
idx_l = self.lang_combo.findText(_current_lang.get("_language_name", ""))
if idx_l >= 0:
self.lang_combo.setCurrentIndex(idx_l)
self.lang_combo.currentTextChanged.connect(self._on_lang_changed)
lang_form.addRow(t("settings_language"), self.lang_combo)
layout.addWidget(self.lang_group)
# TTS
self.tts_group = QGroupBox(t("settings_voice_alert"))
tts_form = QFormLayout(self.tts_group)
self.chk_voice = QCheckBox(t("settings_voice_alert"))
self.chk_voice.setChecked(_app_settings.get("VOICE_ALERT", True))
self.rb_female = QRadioButton(t("settings_voice_female"))
self.rb_male = QRadioButton(t("settings_voice_male"))
gender = _app_settings.get("VOICE_GENDER", "Female")
self.rb_female.setChecked(gender == "Female")
self.rb_male.setChecked(gender == "Male")
self._gender_group = QButtonGroup()
self._gender_group.addButton(self.rb_female)
self._gender_group.addButton(self.rb_male)
tts_row = QHBoxLayout()
tts_row.addWidget(self.rb_female)
tts_row.addWidget(self.rb_male)
tts_row.addStretch()
tts_form.addRow(self.chk_voice)
tts_form.addRow(tts_row)
self.chk_voice.toggled.connect(self._save_general)
self.rb_female.toggled.connect(self._save_general)
layout.addWidget(self.tts_group)
layout.addStretch()
self.tab_widget.addTab(tab, t("tab_general"))
# ── Tab: O programu ───────────────────────────────────────────────────────
def _build_about_tab(self):
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl_name = QLabel("Mail Checker")
font = QFont()
font.setPointSize(18)
font.setBold(True)
lbl_name.setFont(font)
lbl_name.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl_ver = QLabel("Verzija 2.0")
lbl_ver.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl_desc = QLabel(
"Aplikacija za praćenje IMAP e-mail sandučića.\n"
"Podržava više računa, glasovne obavijesti i pregled poruka."
)
lbl_desc.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl_desc.setWordWrap(True)
lbl_deps = QLabel(
"Zahtijeva:\n"
"python3-pyqt6 • python3-pyqt6.qtwebengine\n"
"python3-cryptography • espeak-ng"
)
lbl_deps.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl_deps.setStyleSheet("color: gray; font-size: 10px;")
layout.addWidget(lbl_name)
layout.addSpacing(8)
layout.addWidget(lbl_ver)
layout.addSpacing(16)
layout.addWidget(lbl_desc)
layout.addSpacing(16)
layout.addWidget(lbl_deps)
self.tab_widget.addTab(tab, t("tab_about"))
# ── Metode ────────────────────────────────────────────────────────────────
def _refresh_list(self):
self.account_list.clear()
for i, acc in enumerate(config_accounts):
name = acc.get("NICKNAME") or acc.get("MAIL_USER", "?")
self.account_list.addItem(f"{i+1}. {name}")
def _on_select(self, idx: int):
if idx < 0 or idx >= len(config_accounts):
return
self._selected_idx = idx
acc = config_accounts[idx]
self.e_nickname.setText(acc.get("NICKNAME", ""))
self.e_server.setText(acc.get("IMAP_SERVER", ""))
self.e_user.setText(acc.get("MAIL_USER", ""))
self.e_password.setText(decrypt_password(acc.get("MAIL_PASSWORD", "")))
self.e_interval.setValue(acc.get("CHECK_INTERVAL", 60))
self.e_sound.setText(acc.get("SOUND_FILE", ""))
self.chk_preview.setChecked(acc.get("PREVIEW_ENABLED", True))
self.spin_preview.setValue(acc.get("PREVIEW_COUNT", 5))
self.lbl_status.setText("")
def _read_form(self):
nickname = self.e_nickname.text().strip()
server = self.e_server.text().strip()
user = self.e_user.text().strip()
password = self.e_password.text().strip()
if not all([nickname, server, user, password]):
QMessageBox.critical(self, "Greška", t("settings_err_fields"))
return None
return {
"NICKNAME": nickname,
"IMAP_SERVER": server,
"MAIL_USER": user,
"MAIL_PASSWORD": encrypt_password(password),
"CHECK_INTERVAL": self.e_interval.value(),
"SOUND_FILE": self.e_sound.text().strip(),
"PREVIEW_ENABLED": self.chk_preview.isChecked(),
"PREVIEW_COUNT": self.spin_preview.value(),
}
def _browse_sound(self):
path, _ = QFileDialog.getOpenFileName(
self, t("settings_sound_dialog_title"), "",
t("settings_sound_filter")
)
if path:
self.e_sound.setText(path)
def _save_general(self):
_app_settings["VOICE_ALERT"] = self.chk_voice.isChecked()
_app_settings["VOICE_GENDER"] = "Female" if self.rb_female.isChecked() else "Male"
save_app_settings()
def _test_and_save(self, data: dict, idx=None):
self.lbl_status.setText(t("settings_testing"))
plain_pwd = decrypt_password(data["MAIL_PASSWORD"])
worker = ConnectionTestWorker(data["IMAP_SERVER"], data["MAIL_USER"], plain_pwd)
def on_done(ok, err):
if not ok:
self.lbl_status.setText(f"Greška: {err}")
return
with _config_lock:
if idx is None:
config_accounts.append(data)
else:
config_accounts[idx] = data
save_config()
self._refresh_list()
self.lbl_status.setText(t("settings_saved"))
if idx is not None:
self.account_list.setCurrentRow(idx)
self.accounts_changed.emit()
worker.done.connect(on_done)
self._test_worker = worker
worker.start()
def _on_add(self):
data = self._read_form()
if not data:
return
if any(a["MAIL_USER"] == data["MAIL_USER"] for a in config_accounts):
QMessageBox.critical(self, "Greška", t("settings_err_email_exists"))
return
if any(a.get("NICKNAME", "").lower() == data["NICKNAME"].lower() for a in config_accounts):
QMessageBox.critical(self, "Greška", t("settings_err_nickname_exists"))
return
self._test_and_save(data)
def _on_update(self):
if self._selected_idx is None:
QMessageBox.warning(self, "Upozorenje", t("settings_err_no_selection_update"))
return
data = self._read_form()
if not data:
return
idx = self._selected_idx
for i, a in enumerate(config_accounts):
if i != idx and a["MAIL_USER"] == data["MAIL_USER"]:
QMessageBox.critical(self, "Greška", t("settings_err_email_other"))
return
self._test_and_save(data, idx)
def _on_delete(self):
if self._selected_idx is None:
QMessageBox.warning(self, "Upozorenje", t("settings_err_no_selection_delete"))
return
idx = self._selected_idx
user = config_accounts[idx]["MAIL_USER"]
if QMessageBox.question(
self, t("settings_confirm_title"),
t("settings_confirm_delete", user=user)
) != QMessageBox.StandardButton.Yes:
return
with _config_lock:
config_accounts.pop(idx)
save_config()
self._selected_idx = None
self._refresh_list()
self.lbl_status.setText("")
self.accounts_changed.emit()
def _on_lang_changed(self, lang_name: str):
_app_settings["language"] = lang_name
save_app_settings()
change_language(lang_name)
def _save_general(self):
_app_settings["VOICE_ALERT"] = self.chk_voice.isChecked()
_app_settings["VOICE_GENDER"] = "Female" if self.rb_female.isChecked() else "Male"
save_app_settings()
def _retranslate(self):
self.setWindowTitle(t("settings_title"))
self.tab_widget.setTabText(0, t("tab_accounts"))
self.tab_widget.setTabText(1, t("tab_general"))
self.tab_widget.setTabText(2, t("tab_about"))
self.lbl_accounts.setText(t("settings_accounts_label"))
self.btn_up.setText(t("settings_btn_up"))
self.btn_dn.setText(t("settings_btn_down"))
self.form_group.setTitle(t("settings_group_details"))
self.lbl_nickname.setText(t("settings_nickname"))
self.lbl_server.setText(t("settings_server"))
self.lbl_email.setText(t("settings_email"))
self.lbl_password.setText(t("settings_password"))
self.lbl_interval.setText(t("settings_interval"))
self.lbl_sound.setText(t("settings_sound"))
self.btn_browse.setText(t("settings_browse"))
self.e_interval.setSuffix(t("settings_interval_suffix"))
self.chk_preview.setText(t("settings_preview_enabled"))
self.lbl_preview_count.setText(t("settings_preview_count"))
self.btn_add.setText(t("settings_btn_add"))
self.btn_update.setText(t("settings_btn_update"))
self.btn_delete.setText(t("settings_btn_delete"))
self.lang_group.setTitle(t("settings_language"))
self.tts_group.setTitle(t("settings_voice_alert"))
self.chk_voice.setText(t("settings_voice_alert"))
self.rb_female.setText(t("settings_voice_female"))
self.rb_male.setText(t("settings_voice_male"))
def _move(self, direction: int):
idx = self._selected_idx
if idx is None:
return
new_idx = idx + direction
if new_idx < 0 or new_idx >= len(config_accounts):
return
with _config_lock:
config_accounts[idx], config_accounts[new_idx] = \
config_accounts[new_idx], config_accounts[idx]
self._selected_idx = new_idx
save_config()
self._refresh_list()
self.account_list.setCurrentRow(new_idx)
# ---------------------------------------------------------------------------
# Glavni controller — tray + mail provjera
# ---------------------------------------------------------------------------
class MailCheckerApp(QObject):
def __init__(self):
super().__init__()
self._last_counts: dict = {}
self._check_workers: list = []
self._settings_win = None
self._messages_win = None
# Učitaj postavke (jezik) PRIJE kreiranja GUI-a
load_app_settings()
load_config()
# Tray
self.tray = QSystemTrayIcon()
self.tray.setIcon(make_tray_icon("blue", 0))
self.tray.setToolTip("Mail Checker")
self.tray.activated.connect(self._on_tray_activated)
self._menu = QMenu()
self._action_check = self._menu.addAction(t("menu_check_now"), self._check_now)
self._action_status = self._menu.addAction(t("menu_status"), self._show_status)
self._action_msgs = self._menu.addAction(t("menu_messages"), self._show_messages)
self._menu.addSeparator()
self._action_settings = self._menu.addAction(t("menu_settings"), self._show_settings)
self._menu.addSeparator()
self._action_exit = self._menu.addAction(t("menu_exit"), QApplication.instance().quit)
self.tray.setContextMenu(self._menu)
self.tray.show()
# Ažuriraj meni kad se jezik promijeni
register_lang_callback(self._retranslate_menu)
# Timer za periodičnu provjeru
self._timer = QTimer()
self._timer.timeout.connect(self._check_now)
self._start_timer()
if not config_accounts:
self._show_settings()
else:
self._check_now()
def _retranslate_menu(self):
self._action_check.setText(t("menu_check_now"))
self._action_status.setText(t("menu_status"))
self._action_msgs.setText(t("menu_messages"))
self._action_settings.setText(t("menu_settings"))
self._action_exit.setText(t("menu_exit"))
def _get_interval_ms(self) -> int:
with _config_lock:
if not config_accounts:
return 60_000
intervals = [a.get("CHECK_INTERVAL", 60) for a in config_accounts]
return max(10, min(intervals)) * 1000
def _start_timer(self):
self._timer.stop()
self._timer.start(self._get_interval_ms())
def _check_now(self):
with _config_lock:
accs = list(config_accounts)
if not accs:
self.tray.setIcon(make_tray_icon("gray", 0))
self.tray.setToolTip("Mail Checker: Nema konfiguriranih računa")
return
worker = MailCheckWorker(accs)
worker.result_ready.connect(self._on_result)
worker.error_occurred.connect(self._on_error)
self._check_workers.append(worker)
worker.start()
def _on_result(self, result: dict):
user, count = next(iter(result.items()))
previous = self._last_counts.get(user)
self._last_counts[user] = count
with _config_lock:
acc = next((a for a in config_accounts if a["MAIL_USER"] == user), None)
if not acc:
return
name = acc.get("NICKNAME") or user.split("@")[0]
sound = acc.get("SOUND_FILE", "")
voice_enabled = _app_settings.get("VOICE_ALERT", True)
voice_gender = _app_settings.get("VOICE_GENDER", "Female")
is_first = previous is None
if is_first:
if count > 0:
word = croatian_mail_word(count)
send_notification(f"Mail: {user}", f"Nepročitanih poruka: {count}")
if sound:
play_sound_file(sound)
elif voice_enabled:
speak_text(f"{name}, imate {count} {word}", gender=voice_gender)
logger.info("[%s] Inicijalna provjera: %d nepročitanih.", user, count)
elif count != previous:
if count > 0:
word = croatian_mail_word(count)
send_notification(f"Mail: {user}", f"Nova poruka! Ukupno: {count}")
if sound:
play_sound_file(sound)
elif voice_enabled:
speak_text(
f"{name}, stigla je nova poruka. Ukupno imate {count} {word}",
gender=voice_gender)
logger.info("[%s] %d nepročitanih (ranije: %d)", user, count, previous)
else:
send_notification(f"Mail: {user}", "Sve poruke su pročitane.")
if voice_enabled:
speak_text(f"{name}, nemate novih poruka.", gender=voice_gender)
logger.info("[%s] Inbox čist.", user)
# Ažuriraj tray ikonu
total = sum(self._last_counts.values())
if total > 0:
self.tray.setIcon(make_tray_icon("green", total))
self.tray.setToolTip(f"Mail Checker: {total} ukupno nepročitanih")
else:
self.tray.setIcon(make_tray_icon("blue", 0))
self.tray.setToolTip("Mail Checker: Inbox čist")
def _on_error(self, user: str, err: str):
logger.error("[%s] Greška: %s", user, err)
self.tray.setIcon(make_tray_icon("gray", 0))
def _show_status(self):
with _config_lock:
accs = list(config_accounts)
if not accs:
self.tray.showMessage("Mail Checker", t("notify_no_accounts"),
QSystemTrayIcon.MessageIcon.Information)
return
lines = []
total = 0
for acc in accs:
user = acc["MAIL_USER"]
name = acc.get("NICKNAME") or user.split("@")[0]
count = self._last_counts.get(user)
if count is None:
lines.append(f"{name}: još nije provjereno")
elif count == 0:
lines.append(f"{name}: inbox čist")
else:
word = croatian_mail_word(count)
lines.append(f"{name}: {count} {word}")
total += count
title = f"Mail Checker — ukupno: {total}" if total > 0 else "Mail Checker — inbox čist"
self.tray.showMessage(title, "\n".join(lines),
QSystemTrayIcon.MessageIcon.Information, 5000)
def _show_messages(self):
if self._messages_win and self._messages_win.isVisible():
self._messages_win.raise_()
self._messages_win.activateWindow()
return
self._messages_win = MessagesWindow()
self._messages_win.show()
def _show_settings(self):
if self._settings_win and self._settings_win.isVisible():
self._settings_win.raise_()
self._settings_win.activateWindow()
return
self._settings_win = SettingsDialog()
self._settings_win.accounts_changed.connect(self._on_accounts_changed)
self._settings_win.show()
def _on_accounts_changed(self):
self._last_counts.clear()
self._start_timer()
self._check_now()
def _on_tray_activated(self, reason):
if reason == QSystemTrayIcon.ActivationReason.Trigger:
self._show_status()
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False) # ne gasi se kad se zatvori prozor
checker = MailCheckerApp()
sys.exit(app.exec())