You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1415 lines
53 KiB
1415 lines
53 KiB
"""
|
|
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"<style[^>]*>.*?</style>", "", html_content,
|
|
flags=re.IGNORECASE | re.DOTALL)
|
|
t = re.sub(r"<script[^>]*>.*?</script>", "", t,
|
|
flags=re.IGNORECASE | re.DOTALL)
|
|
t = re.sub(r"<head[^>]*>.*?</head>", "", t,
|
|
flags=re.IGNORECASE | re.DOTALL)
|
|
t = re.sub(r"<br\s*/?>", "\n", t, flags=re.IGNORECASE)
|
|
t = re.sub(r"</p>|</tr>|</div>", "\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())
|