feat: E-Mail IMAP Client — Zusammenfassung, Suche, Benachrichtigung
This commit is contained in:
parent
625980fd13
commit
fdf2bc095a
10 changed files with 341 additions and 2 deletions
|
|
@ -7,7 +7,7 @@ import re
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
from core import config, loki_client, proxmox_client, wordpress_client, prometheus_client
|
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():
|
def _load_config():
|
||||||
|
|
@ -184,6 +184,43 @@ def _tool_get_backup_status() -> str:
|
||||||
return pbs_client.format_overview()
|
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:
|
def _tool_get_feed_stats() -> str:
|
||||||
cfg = _load_config()
|
cfg = _load_config()
|
||||||
ct_109 = config.get_container(cfg, vmid=109)
|
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),
|
"close_issue": lambda number: _tool_close_issue(number),
|
||||||
"get_seafile_status": lambda: _tool_get_seafile_status(),
|
"get_seafile_status": lambda: _tool_get_seafile_status(),
|
||||||
"get_backup_status": lambda: _tool_get_backup_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(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
homelab-ai-bot/core/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
homelab-ai-bot/core/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
homelab-ai-bot/core/__pycache__/config.cpython-313.pyc
Normal file
BIN
homelab-ai-bot/core/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
homelab-ai-bot/core/__pycache__/loki_client.cpython-313.pyc
Normal file
BIN
homelab-ai-bot/core/__pycache__/loki_client.cpython-313.pyc
Normal file
Binary file not shown.
BIN
homelab-ai-bot/core/__pycache__/mail_client.cpython-313.pyc
Normal file
BIN
homelab-ai-bot/core/__pycache__/mail_client.cpython-313.pyc
Normal file
Binary file not shown.
BIN
homelab-ai-bot/core/__pycache__/proxmox_client.cpython-313.pyc
Normal file
BIN
homelab-ai-bot/core/__pycache__/proxmox_client.cpython-313.pyc
Normal file
Binary file not shown.
244
homelab-ai-bot/core/mail_client.py
Normal file
244
homelab-ai-bot/core/mail_client.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -175,6 +175,45 @@ TOOLS = [
|
||||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
"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": []},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import os
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
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):
|
def _get_tokens(cfg):
|
||||||
|
|
@ -77,6 +77,15 @@ def check_all() -> list[str]:
|
||||||
if names:
|
if names:
|
||||||
alerts.append(f"⚠️ Keine Logs seit 35+ Min: {', '.join(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
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,12 @@ MCP_PATH="/root/homelab-mcp"
|
||||||
MCP_VENV="/root/homelab-mcp/.venv"
|
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"
|
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 ---
|
||||||
LOKI_URL="http://100.109.206.43:3100"
|
LOKI_URL="http://100.109.206.43:3100"
|
||||||
LOKI_CT="110"
|
LOKI_CT="110"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue