diff --git a/homelab-ai-bot/context.py b/homelab-ai-bot/context.py index f7269bed..dd45234d 100644 --- a/homelab-ai-bot/context.py +++ b/homelab-ai-bot/context.py @@ -7,7 +7,7 @@ import re sys.path.insert(0, os.path.dirname(__file__)) from core import config, loki_client, proxmox_client, wordpress_client, prometheus_client -from core import forgejo_client, seafile_client, pbs_client +from core import forgejo_client, seafile_client, pbs_client, mail_client def _load_config(): @@ -184,6 +184,43 @@ def _tool_get_backup_status() -> str: return pbs_client.format_overview() +def _tool_get_mail_summary() -> str: + cfg = _load_config() + mail_client.init(cfg) + return mail_client.format_summary() + + +def _tool_get_mail_count() -> str: + cfg = _load_config() + mail_client.init(cfg) + counts = mail_client.get_mail_count() + if "error" in counts: + return f"Mail-Fehler: {counts['error']}" + return f"E-Mails: {counts['total']} gesamt, {counts['unread']} ungelesen ({counts['account']})" + + +def _tool_search_mail(query: str, days: int = 30) -> str: + cfg = _load_config() + mail_client.init(cfg) + results = mail_client.search_mail(query, days=days) + return mail_client.format_search_results(results) + + +def _tool_get_todays_mails() -> str: + cfg = _load_config() + mail_client.init(cfg) + mails = mail_client.get_todays_mails() + if not mails: + return "Heute keine Mails eingegangen." + if "error" in mails[0]: + return f"Mail-Fehler: {mails[0]['error']}" + lines = [f"{len(mails)} Mail(s) heute:\n"] + for r in mails: + lines.append(f" {r['date_str']} | {r['from'][:35]}") + lines.append(f" → {r['subject'][:70]}") + return "\n".join(lines) + + def _tool_get_feed_stats() -> str: cfg = _load_config() ct_109 = config.get_container(cfg, vmid=109) @@ -221,4 +258,8 @@ def get_tool_handlers() -> dict: "close_issue": lambda number: _tool_close_issue(number), "get_seafile_status": lambda: _tool_get_seafile_status(), "get_backup_status": lambda: _tool_get_backup_status(), + "get_mail_summary": lambda: _tool_get_mail_summary(), + "get_mail_count": lambda: _tool_get_mail_count(), + "search_mail": lambda query, days=30: _tool_search_mail(query, days=days), + "get_todays_mails": lambda: _tool_get_todays_mails(), } diff --git a/homelab-ai-bot/core/__pycache__/__init__.cpython-313.pyc b/homelab-ai-bot/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..fd239739 Binary files /dev/null and b/homelab-ai-bot/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/homelab-ai-bot/core/__pycache__/config.cpython-313.pyc b/homelab-ai-bot/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 00000000..88f39297 Binary files /dev/null and b/homelab-ai-bot/core/__pycache__/config.cpython-313.pyc differ diff --git a/homelab-ai-bot/core/__pycache__/loki_client.cpython-313.pyc b/homelab-ai-bot/core/__pycache__/loki_client.cpython-313.pyc new file mode 100644 index 00000000..056e5ba7 Binary files /dev/null and b/homelab-ai-bot/core/__pycache__/loki_client.cpython-313.pyc differ diff --git a/homelab-ai-bot/core/__pycache__/mail_client.cpython-313.pyc b/homelab-ai-bot/core/__pycache__/mail_client.cpython-313.pyc new file mode 100644 index 00000000..e6fd8722 Binary files /dev/null and b/homelab-ai-bot/core/__pycache__/mail_client.cpython-313.pyc differ diff --git a/homelab-ai-bot/core/__pycache__/proxmox_client.cpython-313.pyc b/homelab-ai-bot/core/__pycache__/proxmox_client.cpython-313.pyc new file mode 100644 index 00000000..74663dd0 Binary files /dev/null and b/homelab-ai-bot/core/__pycache__/proxmox_client.cpython-313.pyc differ diff --git a/homelab-ai-bot/core/mail_client.py b/homelab-ai-bot/core/mail_client.py new file mode 100644 index 00000000..db9daf28 --- /dev/null +++ b/homelab-ai-bot/core/mail_client.py @@ -0,0 +1,244 @@ +"""IMAP Mail Client — Liest E-Mails vom All-Inkl Spiegel-Postfach (Read-Only).""" + +import imaplib +import email +from email.header import decode_header +from email.utils import parsedate_to_datetime +from datetime import datetime, timedelta, timezone +from typing import Optional + +IMAP_SERVER = "" +IMAP_PORT = 993 +MAIL_USER = "" +MAIL_PASS = "" + +IMPORTANT_SENDERS = [ + "paypal", "bank", "sparkasse", "postbank", "dkb", + "hetzner", "all-inkl", "kasserver", "cloudflare", + "proxmox", "synology", "tailscale", + "finanzamt", "elster", "bundesnetzagentur", +] + + +def init(cfg): + global IMAP_SERVER, IMAP_PORT, MAIL_USER, MAIL_PASS + IMAP_SERVER = cfg.raw.get("MAIL_IMAP_SERVER", "") + IMAP_PORT = int(cfg.raw.get("MAIL_IMAP_PORT", "993")) + MAIL_USER = cfg.raw.get("MAIL_USER", "") + MAIL_PASS = cfg.raw.get("MAIL_PASS", "") + + +def _connect() -> Optional[imaplib.IMAP4_SSL]: + if not IMAP_SERVER or not MAIL_USER or not MAIL_PASS: + return None + try: + m = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT) + m.login(MAIL_USER, MAIL_PASS) + return m + except Exception: + return None + + +def _decode_header_value(raw: str) -> str: + if not raw: + return "" + parts = decode_header(raw) + decoded = "" + for part, enc in parts: + if isinstance(part, bytes): + decoded += part.decode(enc or "utf-8", errors="replace") + else: + decoded += part + return decoded.strip() + + +def _parse_mail(msg_data) -> Optional[dict]: + try: + raw = msg_data[0][1] + msg = email.message_from_bytes(raw) + subj = _decode_header_value(msg.get("Subject", "")) + frm = _decode_header_value(msg.get("From", "")) + date_str = msg.get("Date", "") + try: + dt = parsedate_to_datetime(date_str) + except Exception: + dt = None + return { + "subject": subj[:120], + "from": frm[:80], + "date": dt, + "date_str": dt.strftime("%d.%m.%Y %H:%M") if dt else date_str[:20], + } + except Exception: + return None + + +def get_mail_count() -> dict: + """Anzahl Mails total und ungelesen.""" + m = _connect() + if not m: + return {"error": "IMAP-Verbindung fehlgeschlagen"} + try: + m.select("INBOX", readonly=True) + _, data = m.search(None, "ALL") + total = len(data[0].split()) if data[0] else 0 + _, data = m.search(None, "UNSEEN") + unread = len(data[0].split()) if data[0] else 0 + return {"total": total, "unread": unread, "account": MAIL_USER} + except Exception as e: + return {"error": str(e)} + finally: + m.logout() + + +def get_recent_mails(count: int = 10) -> list[dict]: + """Letzte N Mails (neueste zuerst).""" + m = _connect() + if not m: + return [{"error": "IMAP-Verbindung fehlgeschlagen"}] + try: + m.select("INBOX", readonly=True) + _, data = m.search(None, "ALL") + ids = data[0].split() if data[0] else [] + if not ids: + return [] + recent_ids = ids[-count:] + recent_ids.reverse() + results = [] + for mid in recent_ids: + _, msg_data = m.fetch(mid, "(BODY.PEEK[HEADER])") + parsed = _parse_mail(msg_data) + if parsed: + results.append(parsed) + return results + except Exception as e: + return [{"error": str(e)}] + finally: + m.logout() + + +def get_todays_mails() -> list[dict]: + """Alle Mails von heute.""" + m = _connect() + if not m: + return [{"error": "IMAP-Verbindung fehlgeschlagen"}] + try: + m.select("INBOX", readonly=True) + today = datetime.now().strftime("%d-%b-%Y") + _, data = m.search(None, f'(SINCE "{today}")') + ids = data[0].split() if data[0] else [] + results = [] + for mid in ids: + _, msg_data = m.fetch(mid, "(BODY.PEEK[HEADER])") + parsed = _parse_mail(msg_data) + if parsed: + results.append(parsed) + results.reverse() + return results + except Exception as e: + return [{"error": str(e)}] + finally: + m.logout() + + +def search_mail(query: str, days: int = 30, limit: int = 15) -> list[dict]: + """Suche nach Mails per Absender oder Betreff.""" + m = _connect() + if not m: + return [{"error": "IMAP-Verbindung fehlgeschlagen"}] + try: + m.select("INBOX", readonly=True) + since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y") + + all_results = [] + for criteria in [f'FROM "{query}"', f'SUBJECT "{query}"']: + try: + _, data = m.search(None, f'(SINCE "{since}" {criteria})') + ids = data[0].split() if data[0] else [] + for mid in ids: + _, msg_data = m.fetch(mid, "(BODY.PEEK[HEADER])") + parsed = _parse_mail(msg_data) + if parsed: + all_results.append(parsed) + except Exception: + continue + + seen = set() + unique = [] + for r in all_results: + key = f"{r['date_str']}|{r['subject'][:40]}" + if key not in seen: + seen.add(key) + unique.append(r) + unique.sort(key=lambda x: x.get("date") or datetime.min.replace(tzinfo=timezone.utc), reverse=True) + return unique[:limit] + except Exception as e: + return [{"error": str(e)}] + finally: + m.logout() + + +def get_important_mails(hours: int = 24) -> list[dict]: + """Mails von wichtigen Absendern (Bank, Hoster, etc.).""" + m = _connect() + if not m: + return [{"error": "IMAP-Verbindung fehlgeschlagen"}] + try: + m.select("INBOX", readonly=True) + since = (datetime.now() - timedelta(hours=hours)).strftime("%d-%b-%Y") + _, data = m.search(None, f'(SINCE "{since}")') + ids = data[0].split() if data[0] else [] + results = [] + for mid in ids: + _, msg_data = m.fetch(mid, "(BODY.PEEK[HEADER])") + parsed = _parse_mail(msg_data) + if parsed: + frm_lower = parsed["from"].lower() + if any(s in frm_lower for s in IMPORTANT_SENDERS): + results.append(parsed) + results.reverse() + return results + except Exception as e: + return [{"error": str(e)}] + finally: + m.logout() + + +def format_summary() -> str: + """Komplett-Übersicht: Counts + letzte Mails + wichtige.""" + counts = get_mail_count() + if "error" in counts: + return f"Mail-Fehler: {counts['error']}" + + lines = [f"E-Mail ({counts['account']})"] + lines.append(f" Gesamt: {counts['total']}, Ungelesen: {counts['unread']}\n") + + recent = get_recent_mails(5) + if recent and "error" not in recent[0]: + lines.append("Letzte 5 Mails:") + for r in recent: + lines.append(f" {r['date_str']} | {r['from'][:30]}") + lines.append(f" → {r['subject'][:70]}") + + important = get_important_mails(48) + if important and "error" not in important[0]: + lines.append(f"\nWichtige Mails (48h): {len(important)}") + for r in important: + lines.append(f" {r['date_str']} | {r['from'][:30]}") + lines.append(f" → {r['subject'][:70]}") + else: + lines.append("\nKeine wichtigen Mails in den letzten 48h.") + + return "\n".join(lines) + + +def format_search_results(results: list[dict]) -> str: + if not results: + return "Keine Mails gefunden." + if "error" in results[0]: + return f"Suche fehlgeschlagen: {results[0]['error']}" + lines = [f"{len(results)} Mail(s) gefunden:\n"] + for r in results: + lines.append(f" {r['date_str']} | {r['from'][:35]}") + lines.append(f" → {r['subject'][:70]}") + return "\n".join(lines) diff --git a/homelab-ai-bot/llm.py b/homelab-ai-bot/llm.py index 3efe0a03..80d23153 100644 --- a/homelab-ai-bot/llm.py +++ b/homelab-ai-bot/llm.py @@ -175,6 +175,45 @@ TOOLS = [ "parameters": {"type": "object", "properties": {}, "required": []}, }, }, + { + "type": "function", + "function": { + "name": "get_mail_summary", + "description": "E-Mail Übersicht: Anzahl Mails, ungelesene, letzte Mails, wichtige Absender (Bank, Hoster, etc.)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "get_mail_count", + "description": "Anzahl E-Mails (gesamt und ungelesen)", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "search_mail", + "description": "E-Mails durchsuchen nach Absender oder Betreff", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Suchbegriff (Absender oder Betreff, z.B. 'PayPal', 'Rechnung', 'Hetzner')"}, + "days": {"type": "integer", "description": "Zeitraum in Tagen (default: 30)", "default": 30}, + }, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_todays_mails", + "description": "Alle E-Mails von heute", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, ] diff --git a/homelab-ai-bot/monitor.py b/homelab-ai-bot/monitor.py index aaa2c982..bc02a4cd 100644 --- a/homelab-ai-bot/monitor.py +++ b/homelab-ai-bot/monitor.py @@ -5,7 +5,7 @@ import os import requests sys.path.insert(0, os.path.dirname(__file__)) -from core import config, loki_client, proxmox_client +from core import config, loki_client, proxmox_client, mail_client def _get_tokens(cfg): @@ -77,6 +77,15 @@ def check_all() -> list[str]: if names: alerts.append(f"⚠️ Keine Logs seit 35+ Min: {', '.join(names)}") + try: + mail_client.init(cfg) + important = mail_client.get_important_mails(hours=1) + if important and "error" not in important[0]: + senders = [m["from"][:30] for m in important] + alerts.append(f"📧 {len(important)} wichtige Mail(s) (letzte Stunde): {', '.join(senders)}") + except Exception: + pass + return alerts diff --git a/homelab.conf b/homelab.conf index 35530207..97f1fb0d 100644 --- a/homelab.conf +++ b/homelab.conf @@ -163,6 +163,12 @@ MCP_PATH="/root/homelab-mcp" MCP_VENV="/root/homelab-mcp/.venv" MCP_TOOLS="homelab_overview,homelab_all_containers,homelab_container_status,homelab_query_logs,homelab_get_errors,homelab_check_silence,homelab_host_health,homelab_metrics,homelab_get_config,homelab_loki_labels,homelab_prometheus_targets" +# --- E-MAIL (All-Inkl IMAP-Spiegel von GMX) --- +MAIL_IMAP_SERVER="w0206aa8.kasserver.com" +MAIL_IMAP_PORT="993" +MAIL_USER="info@orbitalo.info" +MAIL_PASS="Astral-66" + # --- LOKI --- LOKI_URL="http://100.109.206.43:3100" LOKI_CT="110"