feat: E-Mail IMAP Client — Zusammenfassung, Suche, Benachrichtigung

This commit is contained in:
root 2026-03-09 15:25:29 +07:00
parent 625980fd13
commit fdf2bc095a
10 changed files with 341 additions and 2 deletions

View file

@ -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(),
}

Binary file not shown.

View 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)

View file

@ -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": []},
},
},
]

View file

@ -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

View file

@ -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"