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__))
|
||||
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(),
|
||||
}
|
||||
|
|
|
|||
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": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"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
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue