initial commit
This commit is contained in:
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -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"]
|
||||||
|
|
||||||
198
main.py
Executable file
198
main.py
Executable file
@@ -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()
|
||||||
|
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
requests==2.32.3
|
||||||
|
|
||||||
Reference in New Issue
Block a user