import os import time import imaplib import email import requests import logging import sys import traceback import re # ---------- Logging setup ---------- LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper() logging.basicConfig( level=getattr(logging, LOG_LEVEL, logging.INFO), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=[logging.StreamHandler(sys.stdout)], ) logger = logging.getLogger("mail-classifier") # ---------- Env variables ---------- IMAP_HOST = os.environ.get("IMAP_HOST", "mailu-front.mailu.svc") IMAP_PORT = int(os.environ.get("IMAP_PORT", "993")) IMAP_USER = os.environ.get("IMAP_USER") IMAP_PASS = os.environ.get("IMAP_PASS") # LLM backend: # - "ollama" -> volá Ollamu na OLLAMA_URL /api/chat # - "openwebui" -> volá OpenWebUI na OPENWEBUI_URL /api/chat/completions LLM_BACKEND = os.environ.get("LLM_BACKEND", "ollama").lower() # Ollama endpoint (použije se jen pokud LLM_BACKEND == "ollama") OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://ollama-service.open-webui.svc:11434") # OpenWebUI endpoint (použije se jen pokud LLM_BACKEND == "openwebui") OPENWEBUI_URL = os.environ.get( "OPENWEBUI_URL", "http://open-webui-service.open-webui.svc:8080", ) # API key pro OpenWebUI (User API token / service token), pokud je potřeba auth OPENWEBUI_API_KEY = os.environ.get("OPENWEBUI_API_KEY") # Jméno modelu – pro Ollamu to může být např. "mail-router", # pro OpenWebUI+OpenRouter např. "openai/gpt-4.1-mini" MODEL_NAME = os.environ.get("MODEL_NAME", "mail-router") MAX_BODY_CHARS = int(os.environ.get("MAX_BODY_CHARS", "2000")) CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", "300")) # v sekundách # Timeouty / retry s aliasem na staré proměnné LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", os.environ.get("OLLAMA_TIMEOUT", "120"))) LLM_MAX_RETRIES = int(os.environ.get("LLM_MAX_RETRIES", os.environ.get("OLLAMA_MAX_RETRIES", "3"))) # prahy jistoty MIN_CONFIDENCE_DEFAULT = float(os.environ.get("MIN_CONFIDENCE_DEFAULT", "0.4")) MIN_CONFIDENCE_RELAXED = float(os.environ.get("MIN_CONFIDENCE_RELAXED", "0.3")) # IMAP flag pro označení již zpracovaných zpráv PROCESSED_FLAG = "ProcessedByClassifier" # povolené složky – musí sedět se systémovým promptem ALLOWED_FOLDERS = { "INBOX", "INBOX.Finance", "INBOX.Notifikace", "INBOX.Zpravodaje", "INBOX.SocialniSite", "INBOX.Ukoly", "INBOX.Nepodstatne", "INBOX.ZTJ", "INBOX.Potvrzeni", } # ---------- System prompt ---------- SYSTEM_PROMPT = """ Jsi klasifikátor e-mailů pro uživatele Martina. Tvoje jediná práce je zařadit každý e-mail do jedné IMAP složky a stručně vysvětlit proč. Vstup dostaneš ve formátu: HEADERS: From: ... To: ... Cc: ... Subject: ... Date: ... BODY: POVOLENÉ SLOŽKY – FOLDER MUSÍ být přesně jedna z TĚCHTO hodnot: INBOX INBOX.Finance INBOX.Notifikace INBOX.Zpravodaje INBOX.SocialniSite INBOX.Ukoly INBOX.Nepodstatne INBOX.ZTJ INBOX.Potvrzeni ŽÁDNÉ JINÉ NÁZVY SLOŽEK NESMÍŠ POUŽÍT. Výslovně NESMÍŠ použít složky jako: INBOX.Osobni, INBOX.Pracovni ani žádné jiné varianty s tečkou. CÍL: Mít v INBOXU co nejméně zpráv. Pokud váháš mezi INBOX a nějakou specializovanou složkou, VŽDY zvol specializovanou složku (Ukoly, Finance, Notifikace, Zpravodaje, SocialniSite, ZTJ, Potvrzeni, Nepodstatne). ---------------------------------------------------------------------- JASNÉ VYMEZENÍ SLOŽEK ---------------------------------------------------------------------- 1) INBOX.ZTJ – cokoliv, co souvisí s organizací „Život trochu jinak“ (ZTJ): * „Život trochu jinak“, „ZTJ“ v Subject nebo v těle * cokoliv z domény @zivotjinak.cz * zážitkové kurzy, tábory, akce pro mládež, příprava programů, faktury pro ZTJ, komunikace s účastníky, rodiči, partnery ohledně ZTJ → Pokud splňuje ZTJ, vždy INBOX.ZTJ, i kdyby to byla faktura nebo e-mail s úkolem. 2) INBOX.Ukoly – hlavní smysl e-mailu je, že Martin MUSÍ něco udělat: * odpovědět, potvrdit, kliknout na odkaz, doplnit údaje * zaplatit, rezervovat, domluvit termín, vyplnit formulář – typické fráze: * „Prosíme o úhradu…“ * „Potvrď prosím účast“ * „Je potřeba doplnit…“ * „Klikni zde pro dokončení…“ * „Prosím o odpověď do…“ – NA VŠECHNY TODO dávej přednost INBOX.Ukoly před ostatními složkami (kromě ZTJ, ta má prioritu). – Patří sem i úkoly z banky, úřadů, služeb, doktorů, školky atd., pokud je jasná akce. 3) INBOX.Notifikace – automatická technická/systemová/bezpečnostní upozornění: * monitoring (alerty, výpadky) * 2FA kódy, bezpečnostní kódy, „your login code is…“ * potvrzení přihlášení, upozornění na podezřelou aktivitu * upozornění na aktivitu zařízení (např. Apple „Najít“, přihlášení z nového zařízení) * systémové logy, cron, servery, aplikace – zprávy jsou často krátké, strohé, většinou bez „Ahoj Martine“, spíše technický jazyk. 4) INBOX.Finance – finanční věci, kde hlavní účel je INFORMOVAT, ne úkol: * bankovní výpisy, potvrzení o platbě, přehledy transakcí * faktury a vyúčtování, kde už je zaplaceno a není potřeba akce * pojišťovny, finanční úřad, účetnictví, pokud po Martinovi nic konkrétního nechtějí * potvrzení platby, účtenky a faktury od Apple/Google/PayPal atd., pokud už je hotovo – Pokud e-mail z banky/pojišťovny/platební služby obsahuje ÚKOL (zaplať, doplň, potvrď), použij raději INBOX.Ukoly. 5) INBOX.Potvrzeni – potvrzení rezervací, objednávek, registrací, termínů a vstupenek, kde hlavní smysl je: „všechno je zařízené / zarezervované / domluvené“: * potvrzení rezervace hotelu / ubytování / dovolené / letenek * potvrzení rezervace v restauraci nebo na akci * potvrzení registrace na kurz, webinář, konferenci * potvrzení objednávky v e-shopu (objednávka přijata, čeká se na odeslání) * potvrzení vstupenek na koncert, divadlo, kino, akci – Pokud je hlavním účelem INFORMOVAT o tom, že něco proběhlo / proběhne (a ty už nic dělat nemusíš), ale nejde primárně o čisté finance, dej INBOX.Potvrzeni. – Pokud e-mail obsahuje zároveň jasný úkol (např. „dokonči platbu“, „potvrď účast“), dej INBOX.Ukoly (Ukoly má přednost před Potvrzeni). 6) INBOX.Zpravodaje – newslettery, marketing, promo, slevy, akce: * e-shopy, služby, aplikace (Freeletics, Decathlon, Alza, Garmin, atd.) * „Black Friday“, „% off“, „sleva“, „akce“, „speciální nabídka“, „promo“ * „novinky“, „zpravodaj“, „newsletter“ – PATŘÍ SEM i personalizované newslettery začínající např. „Ahoj Martine“, pokud jde primárně o marketing/propagaci. – Typické znaky: tlačítka „Zjistit více“, „Koupit“, odhlašovací odkaz, dlouhý formát. – Nikdy neházej marketing do INBOX nebo INBOX.Ukoly, pokud neobsahuje opravdu jasný konkrétní úkol. 7) INBOX.SocialniSite – e-maily od sociálních sítí a komunit: * Facebook, Instagram, X/Twitter, Discord, GitHub, GitLab, LinkedIn, fóra, komunity * „someone mentioned you“, „new follower“, „pull request“, „issue“, „comment“ – Patří sem i komunitní notifikace z projektů, pokud to není čistý marketing (marketingové kampaně těchto služeb dej do INBOX.Zpravodaje). 8) INBOX.Nepodstatne – zjevný spam a věci s minimální hodnotou: * náhodné soutěže, podivné nabídky, kryptoměny, pochybná schémata * nesouvisející „výhodné nabídky“ od neznámých subjektů – Pokud je e-mail podezřelý, nesrozumitelný nebo evidentně nerelevantní, použij INBOX.Nepodstatne. 9) INBOX – osobní nebo běžná konverzace od lidí: * rodina, přátelé, běžná domluva (pokud z toho není jasný úkol) * individuální e-maily, kde tě někdo normálně oslovuje a píše vlastní text, ne automatický newsletter – také e-maily, které nejde jednoznačně přiřadit do ostatních složek – INBOX by neměl být výchozí pro všechno. Pokud e-mail jasně spadá do jiné složky, použij tu jinou složku. ---------------------------------------------------------------------- ROZHODOVACÍ POSTUP (DŮLEŽITÉ – ŽÁDNÉ PŘEKRYVY) ---------------------------------------------------------------------- Postupuj VŽDY podle tohoto pořadí. Jakmile některý bod sedí, použij danou složku a přestaň hledat dál: 1) Je to ZTJ (doména @zivotjinak.cz nebo obsahuje „Život trochu jinak“/„ZTJ“ nebo je zjevně o zážitkovém kurzu/táboře/akci pro mládež)? → INBOX.ZTJ 2) Je hlavní pointa, že Martin MUSÍ něco konkrétně udělat? (zaplatit, potvrdit, odpovědět, vyplnit, rezervovat, kliknout, dokončit registraci…) → INBOX.Ukoly 3) Je to technická / bezpečnostní / systémová notifikace? (monitoring, alert, 2FA, login upozornění, server logy, „Find My“ apod.)? → INBOX.Notifikace 4) Je to čistě finanční informace bez nutnosti akce? (výpis, potvrzení, přehled, už zaplacená faktura…) → INBOX.Finance 5) Je to potvrzení rezervace / objednávky / registrace / vstupenek / termínu? (hotel, ubytování, letenky, restaurace, akce, e-shop, služba)? → INBOX.Potvrzeni 6) Je to newsletter / marketing / promo / sleva / nabídka? → INBOX.Zpravodaje 7) Je to notifikace ze sociální sítě nebo vývojářské/komunitní platformy? → INBOX.SocialniSite 8) Je to zjevný spam nebo velmi málo užitečné / náhodná nabídka? → INBOX.Nepodstatne 9) Jinak: – osobní korespondence, normální domluva bez tasku – cokoliv, co nepasuje výše → INBOX Pokud váháš mezi INBOX a nějakou specializovanou složkou, vyber raději specializovanou složku (Ukoly, Zpravodaje, Finance, Notifikace, SocialniSite, ZTJ, Potvrzeni, Nepodstatne). ---------------------------------------------------------------------- PRAVIDLA PRO CONFIDENCE A FORMÁT ---------------------------------------------------------------------- – CONFIDENCE vybírej takto: * 0.8–1.0 = jsi si jistý, typický případ (newsletter, výpis, jasný úkol, ZTJ…) * 0.4–0.7 = váháš mezi dvěma složkami, ale jednu vybereš * 0.0–0.3 = použij jen pokud je e-mail opravdu zmatený/nečitelný – Pokud e-mail zařadíš do nějaké složky s jasným důvodem, NEPOUŽÍVEJ 0.0. – V odpovědi SMÍŠ napsat slova „FOLDER:“ a „CONFIDENCE:“ POUZE jednou na prvních dvou řádcích. * Nikdy nepřidávej druhý blok „FOLDER: … CONFIDENCE: …“ do REASON ani do RULES. * V REASON a RULES NEPIŠ, že e-mail „by měl být v jiné složce“ – rovnou vyber správnou složku. – RULES má jen stručně shrnout, proč jsi vybral danou složku (1–3 krátké řádky), a nesmí obsahovat žádná další FOLDER/CONFIDENCE ani zakazující věty typu „nikdy nepoužívej INBOX.Finance“ (kromě toho, co je uvedeno výše v instrukcích). ---------------------------------------------------------------------- PÁR ZKRÁCENÝCH PŘÍKLADŮ ---------------------------------------------------------------------- 1) From: news@decathlon.cz Subject: „Ahoj Martine, 20 % sleva na běžecké boty jen tento týden“ → FOLDER: INBOX.Zpravodaje 2) From: banka@neco.cz Subject: „Výpis z účtu za měsíc listopad“ → FOLDER: INBOX.Finance 3) From: noreply@security.google.com Subject: „Your Google verification code“ → FOLDER: INBOX.Notifikace 4) From: info@zivotjinak.cz Subject: „Informace k letnímu kurzu Život trochu jinak“ → FOLDER: INBOX.ZTJ 5) From: nekdo@nekde.cz Subject: „Prosím o potvrzení termínu schůzky“ → FOLDER: INBOX.Ukoly 6) From: reservations@hotel.com Subject: „Potvrzení rezervace ubytování“ → FOLDER: INBOX.Potvrzeni 7) From: kamarád@neco.cz Subject: „Co děláte o víkendu?“ → FOLDER: INBOX ---------------------------------------------------------------------- FORMÁT VÝSTUPU ---------------------------------------------------------------------- Odpověz POUZE v tomto formátu, bez dalšího textu před ani po: FOLDER: CONFIDENCE: <číslo 0.0 až 1.0> REASON: RULES: - - Nepoužívej JSON, nepoužívej markdown, nepiš žádné shrnutí e-mailu. Nepřidávej další řádky, hlavičky ani komentáře mimo uvedený formát. """ # ---------- Config logging ---------- def log_config(): """Vypíše aktuální konfiguraci (bez hesla).""" logger.info("=== mail-classifier configuration ===") logger.info(f"IMAP_HOST = {IMAP_HOST}") logger.info(f"IMAP_PORT = {IMAP_PORT}") logger.info(f"IMAP_USER = {IMAP_USER}") logger.info("IMAP_PASS = **** (hidden)") logger.info(f"LLM_BACKEND = {LLM_BACKEND}") logger.info(f"OLLAMA_URL = {OLLAMA_URL}") logger.info(f"OPENWEBUI_URL = {OPENWEBUI_URL}") logger.info(f"MODEL_NAME = {MODEL_NAME}") logger.info(f"MAX_BODY_CHARS = {MAX_BODY_CHARS}") logger.info(f"CHECK_INTERVAL = {CHECK_INTERVAL} s") logger.info(f"LLM_TIMEOUT = {LLM_TIMEOUT} s") logger.info(f"LLM_MAX_RETRIES = {LLM_MAX_RETRIES}") logger.info(f"MIN_CONF_DEFAULT = {MIN_CONFIDENCE_DEFAULT}") logger.info(f"MIN_CONF_RELAXED = {MIN_CONFIDENCE_RELAXED}") logger.info(f"LOG_LEVEL = {LOG_LEVEL}") logger.info(f"ALLOWED_FOLDERS = {sorted(ALLOWED_FOLDERS)}") logger.info(f"PROCESSED_FLAG = {PROCESSED_FLAG}") logger.info("====================================") # ---------- IMAP helpers ---------- def connect_imap(): logger.info(f"Connecting to IMAP {IMAP_HOST}:{IMAP_PORT} as {IMAP_USER}") if not IMAP_USER or not IMAP_PASS: logger.error("IMAP_USER or IMAP_PASS is not set! Exiting.") raise RuntimeError("Missing IMAP credentials") m = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) typ, data = m.login(IMAP_USER, IMAP_PASS) if typ != "OK": logger.error(f"IMAP login failed: {typ} {data}") raise RuntimeError("IMAP login failed") logger.info("IMAP login successful") return m def get_unseen_messages(imap_conn): # Bereme jen zprávy v INBOXu, které jsou: # - UNSEEN (nepřečtené) # - NEMAJÍ flag ProcessedByClassifier typ, _ = imap_conn.select("INBOX") if typ != "OK": logger.error(f"Cannot select INBOX, got: {typ}") return [] status, data = imap_conn.search(None, "UNSEEN", "UNKEYWORD", PROCESSED_FLAG) if status != "OK": logger.error(f"UNSEEN UNKEYWORD search failed: {status}") return [] ids = data[0].split() logger.info( f"Found {len(ids)} unseen & unprocessed messages in INBOX " f"(UNSEEN UNKEYWORD {PROCESSED_FLAG})" ) if ids: logger.debug(f"Unseen/unprocessed message IDs: {[i.decode(errors='ignore') for i in ids]}") return ids # ---------- Email to prompt ---------- def build_prompt_from_email(msg): headers = [] for h in ["From", "To", "Cc", "Subject", "Date"]: value = msg.get(h, "") headers.append(f"{h}: {value}") headers_text = "\n".join(headers) body_text = "" if msg.is_multipart(): for part in msg.walk(): content_type = part.get_content_type() disp = str(part.get("Content-Disposition") or "") if content_type == "text/plain" and "attachment" not in disp.lower(): try: part_bytes = part.get_payload(decode=True) if part_bytes is None: continue body_text += part_bytes.decode( part.get_content_charset() or "utf-8", errors="ignore", ) except Exception as e: logger.debug(f"Error decoding multipart part: {e}") continue else: try: part_bytes = msg.get_payload(decode=True) if part_bytes is not None: body_text = part_bytes.decode( msg.get_content_charset() or "utf-8", errors="ignore", ) except Exception as e: logger.debug(f"Error decoding singlepart message: {e}") body_text = "" if len(body_text) > MAX_BODY_CHARS: logger.debug(f"Body truncated from {len(body_text)} to {MAX_BODY_CHARS} chars") body_text = body_text[:MAX_BODY_CHARS] prompt = f"HEADERS:\n{headers_text}\n\nBODY:\n{body_text}" logger.debug(f"Built prompt (first 500 chars): {prompt[:500].replace(chr(10), ' ')}") return prompt def build_llm_input(email_prompt: str) -> str: """ LLM dostane jako user obsah e-mailu v podobě HEADERS/BODY. Všechny instrukce jsou v SYSTEM_PROMPT. """ return email_prompt # ---------- LLM warm-up ---------- def warmup_model(): """ Jednoduchý warm-up dotaz, aby si backend natáhl model před prvním reálným mailem. """ logger.info(f"Warming up model {MODEL_NAME} using backend {LLM_BACKEND}...") warmup_email = ( "HEADERS:\n" "From: warmup@example.com\n" "To: martin@example.com\n" "Subject: Warmup\n" "Date: Thu, 01 Jan 1970 00:00:00 +0000\n\n" "BODY:\n" "Toto je testovací e-mail pouze pro warmup modelu.\n" ) messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": warmup_email}, ] try: if LLM_BACKEND == "ollama": content = _call_ollama_chat(messages) elif LLM_BACKEND == "openwebui": content = _call_openwebui_chat(messages) else: logger.warning(f"Unknown LLM_BACKEND={LLM_BACKEND}, skipping warm-up") return logger.info( "Warm-up response (first 200 chars): " f"{str(content)[:200].replace(chr(10), ' ')}" ) except Exception as e: logger.warning(f"Warm-up failed (will continue anyway): {e}") # ---------- Parsing model output ---------- FOLDER_RE = re.compile(r"FOLDER:\s*([A-Za-z0-9_.]+)") CONF_RE = re.compile(r"CONFIDENCE:\s*([0-9.]+)") REASON_RE = re.compile(r"REASON:\s*(.*?)(?:RULES:|$)", re.DOTALL) RULES_BLOCK_RE = re.compile(r"RULES:\s*(.*)$", re.DOTALL) def parse_model_output(content: str) -> dict: """ Očekávaný formát: FOLDER: INBOX.Zpravodaje CONFIDENCE: 0.8 REASON: ... RULES: - ... - ... Vrací dict {folder, confidence, reason, rules} nebo vyhodí výjimku. """ folder_match = FOLDER_RE.search(content) conf_match = CONF_RE.search(content) reason_match = REASON_RE.search(content) rules_match = RULES_BLOCK_RE.search(content) if not folder_match or not conf_match or not reason_match or not rules_match: raise ValueError("Missing one of FOLDER/CONFIDENCE/REASON/RULES in model output") folder = folder_match.group(1).strip() try: confidence = float(conf_match.group(1)) except Exception: confidence = 0.0 reason_raw = reason_match.group(1).strip() reason = reason_raw.splitlines()[0].strip() rules_block = rules_match.group(1).strip() rules = [] for line in rules_block.splitlines(): l = line.strip() if l.startswith("- "): rules.append(l[2:].strip()) return { "folder": folder, "confidence": confidence, "reason": reason, "rules": rules, } # ---------- LLM call backend-specific helpers ---------- def _call_ollama_chat(messages) -> str: payload = { "model": MODEL_NAME, "stream": False, "messages": messages, } r = requests.post( f"{OLLAMA_URL}/api/chat", json=payload, timeout=LLM_TIMEOUT, ) r.raise_for_status() data = r.json() content = data.get("message", {}).get("content", "") return content def _call_openwebui_chat(messages) -> str: payload = { "model": MODEL_NAME, "stream": False, "temperature": 0.1, "messages": messages, } headers = {} if OPENWEBUI_API_KEY: headers["Authorization"] = f"Bearer {OPENWEBUI_API_KEY}" r = requests.post( f"{OPENWEBUI_URL}/api/chat/completions", json=payload, headers=headers, timeout=LLM_TIMEOUT, ) r.raise_for_status() data = r.json() choices = data.get("choices", []) if not choices: raise RuntimeError("OpenWebUI returned no choices") message = choices[0].get("message", {}) content = message.get("content", "") if not content: raise RuntimeError("OpenWebUI choice[0].message.content is empty") return content # ---------- LLM call (common) ---------- def classify_email(prompt): email_input = build_llm_input(prompt) messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": email_input}, ] last_exc = None for attempt in range(1, LLM_MAX_RETRIES + 1): logger.info( f"Calling model {MODEL_NAME} via backend {LLM_BACKEND} " f"(attempt {attempt}/{LLM_MAX_RETRIES})" ) try: if LLM_BACKEND == "ollama": content = _call_ollama_chat(messages) elif LLM_BACKEND == "openwebui": content = _call_openwebui_chat(messages) else: raise RuntimeError(f"Unsupported LLM_BACKEND: {LLM_BACKEND}") logger.info( "Model returned content (first 300 chars): " f"{content[:300].replace(chr(10), ' ')}" ) result = parse_model_output(content) logger.info(f"Parsed model result: {result}") return result except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: last_exc = e logger.warning(f"LLM request failed with {type(e).__name__}: {e}") if attempt < LLM_MAX_RETRIES: backoff = 5 * attempt logger.info(f"Retrying in {backoff} seconds...") time.sleep(backoff) except Exception as e: last_exc = e logger.error(f"LLM request failed (non-retryable): {e}") logger.debug(traceback.format_exc()) raise logger.error(f"LLM request failed after {LLM_MAX_RETRIES} attempts: {last_exc}") raise last_exc or RuntimeError("LLM request failed") def normalize_folder(result, msg): """ Vrátí cílovou složku čistě podle modelu: - pokud složka není v ALLOWED_FOLDERS -> INBOX - pokud confidence pod prahem -> INBOX """ folder = result.get("folder", "INBOX") confidence = float(result.get("confidence", 0.0) or 0.0) logger.info(f"Model suggested folder={folder}, confidence={confidence}") if folder not in ALLOWED_FOLDERS: logger.warning(f"Folder {folder} not in ALLOWED_FOLDERS, using INBOX") return "INBOX" # pro spam/newsletter buďme benevolentnější if folder in ("INBOX.Nepodstatne", "INBOX.Zpravodaje"): threshold = MIN_CONFIDENCE_RELAXED else: threshold = MIN_CONFIDENCE_DEFAULT if confidence < threshold: logger.info( f"Low confidence ({confidence}) for folder {folder} " f"(threshold {threshold}), using INBOX as fallback" ) return "INBOX" return folder # ---------- IMAP folder operations ---------- def ensure_folder(imap_conn, folder): logger.debug(f"Ensuring folder exists: {folder}") typ, mailboxes = imap_conn.list('""', f'"{folder}"') if typ == "OK" and mailboxes and mailboxes[0] is not None: logger.debug(f"Folder {folder} already exists") return logger.info(f"Folder {folder} does not exist, creating...") typ, data = imap_conn.create(folder) if typ != "OK": logger.error(f"Failed to create folder {folder}: {data}") else: logger.info(f"Folder {folder} created successfully") try: st, dat = imap_conn.subscribe(folder) if st == "OK": logger.info(f"Folder {folder} subscribed successfully") else: logger.warning(f"Failed to subscribe folder {folder}: {dat}") except Exception as e: logger.warning(f"IMAP server does not support SUBSCRIBE or it failed: {e}") def mark_processed(imap_conn, msg_id): """ Přidá vlastní flag, aby se zpráva už nikdy znovu neklasifikovala. """ typ, data = imap_conn.store(msg_id, "+FLAGS", PROCESSED_FLAG) if typ != "OK": logger.warning(f"Failed to set {PROCESSED_FLAG} on message {msg_id}: {data}") def move_message(imap_conn, msg_id, target_folder): msg_id_str = msg_id.decode(errors="ignore") logger.info(f"Moving/marking message {msg_id_str} -> {target_folder}") # Pokud složka je INBOX, necháme zprávu v INBOXu, ale označíme ji jako zpracovanou # (aby se už nikdy neklasifikovala znova) if target_folder == "INBOX": logger.info(f"Target folder is INBOX, not moving message {msg_id_str}, only marking processed") mark_processed(imap_conn, msg_id) return ensure_folder(imap_conn, target_folder) typ, data = imap_conn.copy(msg_id, target_folder) if typ != "OK": logger.error(f"Failed to copy message {msg_id_str} to {target_folder}: {data}") # i tak označíme jako processed, ať ji nezpracováváme dokola – jinak by to žralo tokeny mark_processed(imap_conn, msg_id) return # označíme původní zprávu jako zpracovanou mark_processed(imap_conn, msg_id) # neoznačujeme jako \Seen, jen přidáme \Deleted typ, data = imap_conn.store(msg_id, "+FLAGS", "\\Deleted") if typ != "OK": logger.error(f"Failed to mark message {msg_id_str} as deleted: {data}") return logger.info(f"Message {msg_id_str} marked as deleted in INBOX (will be expunged later)") # ---------- Main processing ---------- def process_once(): logger.info("Starting one processing iteration") imap_conn = connect_imap() try: ids = get_unseen_messages(imap_conn) for msg_id in ids: msg_id_str = msg_id.decode(errors="ignore") logger.info(f"Processing message ID {msg_id_str}") # BODY.PEEK[] – neznačí zprávu jako \Seen typ, data = imap_conn.fetch(msg_id, "(BODY.PEEK[])") if typ != "OK": logger.error(f"Fetch failed for {msg_id_str}: {data}") continue raw_email = data[0][1] msg = email.message_from_bytes(raw_email) prompt = build_prompt_from_email(msg) try: result = classify_email(prompt) except Exception as e: logger.error(f"Error calling model for message {msg_id_str}: {e}") logger.debug(traceback.format_exc()) # neznačíme jako processed – zkusí se to jindy znova continue target_folder = normalize_folder(result, msg) try: move_message(imap_conn, msg_id, target_folder) except Exception as e: logger.error(f"Error moving message {msg_id_str} to {target_folder}: {e}") logger.debug(traceback.format_exc()) # move selhal, ale mark_processed už uvnitř běžel (nebo proběhne příště), # takže se zpráva nebude dál klasifikovat continue # Po zpracování všech zpráv provedeme jedno společné EXPUNGE logger.info("Running single EXPUNGE for all deleted messages in INBOX") try: typ, data = imap_conn.expunge() if typ != "OK": logger.error(f"EXPUNGE failed: {data}") except Exception as e: logger.warning(f"EXPUNGE raised an exception: {e}") finally: logger.info("Logging out from IMAP") try: imap_conn.logout() except Exception as e: logger.warning(f"Error during IMAP logout: {e}") def main(): logger.info("mail-classifier starting up...") log_config() warmup_model() while True: try: process_once() except Exception as e: logger.error(f"Error in main loop: {e}") logger.debug(traceback.format_exc()) logger.info(f"Sleeping for {CHECK_INTERVAL} seconds") time.sleep(CHECK_INTERVAL) if __name__ == "__main__": main()