Files
mail-clasifier/main.py
2025-11-26 13:14:53 +00:00

798 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:
<text e-mailu, prvních N znaků>
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.81.0 = jsi si jistý, typický případ (newsletter, výpis, jasný úkol, ZTJ…)
* 0.40.7 = váháš mezi dvěma složkami, ale jednu vybereš
* 0.00.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 (13 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: <jedna ze složek výše>
CONFIDENCE: <číslo 0.0 až 1.0>
REASON: <stručné vysvětlení v jedné větě>
RULES:
- <stručné pravidlo 1>
- <stručné pravidlo 2>
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()