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