From 39c798b326a4a4ff52d86ea25c86ad3aa4671f1a Mon Sep 17 00:00:00 2001 From: root Date: Tue, 25 Nov 2025 08:42:37 +0000 Subject: [PATCH] initial commit --- Dockerfile | 16 ++++ main.py | 198 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 3 files changed, 216 insertions(+) create mode 100644 Dockerfile create mode 100755 main.py create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5a1bea6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# systémové balíčky – jen minimum +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +CMD ["python", "main.py"] + diff --git a/main.py b/main.py new file mode 100755 index 0000000..48a5dac --- /dev/null +++ b/main.py @@ -0,0 +1,198 @@ +import os +import time +import imaplib +import email +import json +import requests + + +IMAP_HOST = os.environ.get("IMAP_HOST", "imap.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") + +OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://ollama.open-webui.svc:11434") +MODEL_NAME = os.environ.get("MODEL_NAME", "mail-router") + +MAX_BODY_CHARS = int(os.environ.get("MAX_BODY_CHARS", "8000")) +CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", "30")) # v sekundách + +# povolené složky (bezpečnost proti blbosti modelu) +ALLOWED_FOLDERS = { + "INBOX", + "INBOX.Work", + "INBOX.Family", + "INBOX.Finance", + "INBOX.Notifications", + "INBOX.Newsletters", + "INBOX.Social", + "INBOX.Todo", + "INBOX.TrashCandidates", +} + + +def connect_imap(): + print(f"Connecting to IMAP {IMAP_HOST}:{IMAP_PORT} as {IMAP_USER}") + m = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) + m.login(IMAP_USER, IMAP_PASS) + return m + + +def get_unseen_messages(imap_conn): + # vždycky jako zdrojový mailbox zvolíme INBOX + typ, _ = imap_conn.select("INBOX") + if typ != "OK": + print("Cannot select INBOX") + return [] + + status, data = imap_conn.search(None, 'UNSEEN') + if status != "OK": + print("UNSEEN search failed") + return [] + + ids = data[0].split() + return ids + + +def build_prompt_from_email(msg): + headers = [] + for h in ["From", "To", "Cc", "Subject", "Date"]: + headers.append(f"{h}: {msg.get(h, '')}") + headers_text = "\n".join(headers) + + body_text = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + try: + body_text += part.get_payload(decode=True).decode( + part.get_content_charset() or "utf-8", + errors="ignore", + ) + except Exception: + continue + else: + try: + body_text = msg.get_payload(decode=True).decode( + msg.get_content_charset() or "utf-8", + errors="ignore", + ) + except Exception: + body_text = "" + + body_text = body_text[:MAX_BODY_CHARS] + + return f"HEADERS:\n{headers_text}\n\nBODY:\n{body_text}" + + +def classify_email(prompt): + payload = { + "model": MODEL_NAME, + "stream": False, + "format": "json", + "messages": [ + {"role": "user", "content": prompt} + ] + } + r = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=60) + r.raise_for_status() + data = r.json() + content = data["message"]["content"] + + # debug + print("Model raw content:", content[:200].replace("\n", " "), "...") + return json.loads(content) + + +def normalize_folder(result): + folder = result.get("folder", "INBOX") + confidence = float(result.get("confidence", 0.0)) + + # threshold – pod 0.5 necháme v INBOX + if confidence < 0.5: + print(f"Low confidence ({confidence}), forcing INBOX") + return "INBOX" + + # pokud model vrátí něco mimo seznam – fallback na INBOX + if folder not in ALLOWED_FOLDERS: + print(f"Folder {folder} not in allowed list, forcing INBOX") + return "INBOX" + + return folder + + +def ensure_folder(imap_conn, folder): + """ + Zkontroluje existenci složky pomocí LIST a případně ji vytvoří. + Nemění aktuálně zvolený mailbox (na rozdíl od SELECT/EXAMINE). + """ + # LIST "" "INBOX.Foo" + typ, mailboxes = imap_conn.list('""', f'"{folder}"') + # mailboxes může být None nebo prázdné, pokud složka neexistuje + if typ == "OK" and mailboxes and mailboxes[0] is not None: + # složka existuje + return + + print(f"Folder {folder} does not exist, creating...") + typ, data = imap_conn.create(folder) + if typ != "OK": + print(f"WARNING: failed to create folder {folder}: {data}") + + +def move_message(imap_conn, msg_id, target_folder): + # před přesunem zajistíme, že složka existuje + ensure_folder(imap_conn, target_folder) + + # COPY z aktuálního mailboxu (INBOX) do target + typ, data = imap_conn.copy(msg_id, target_folder) + if typ != "OK": + print(f"Failed to copy message {msg_id} to {target_folder}: {data}") + return + + # označíme zprávu v INBOX jako smazanou a expunge + imap_conn.store(msg_id, "+FLAGS", "\\Deleted") + imap_conn.expunge() + print(f"Moved message {msg_id.decode()} -> {target_folder}") + + +def process_once(): + imap_conn = connect_imap() + try: + ids = get_unseen_messages(imap_conn) + print(f"Found {len(ids)} unseen messages in INBOX") + + for msg_id in ids: + typ, data = imap_conn.fetch(msg_id, "(RFC822)") + if typ != "OK": + print(f"Fetch failed for {msg_id}") + 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: + print(f"Error calling model for {msg_id}: {e}") + continue + + target_folder = normalize_folder(result) + move_message(imap_conn, msg_id, target_folder) + + finally: + imap_conn.logout() + + +def main(): + while True: + try: + process_once() + except Exception as e: + print(f"Error in main loop: {e}") + time.sleep(CHECK_INTERVAL) + + +if __name__ == "__main__": + main() + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a9f9e01 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests==2.32.3 +