harden: per-item robustness + deadlines/caps across connectors; layered tests

Mail: _safe_decode + _iter_messages (per-mail try/except) -> eine kaputte Mail
(charset x-unknown) bricht die Suche nicht mehr ab, findet so auch uralte Mails.
Vollscan + Datum-Sort + MAX_SCAN/DEADLINE_S (Selbstheilung, kein 24h-Haenger mehr).

Files: search_files mit SEARCH_DEADLINE_S + SEARCH_MAX_DIRS begrenzt (war
unbegrenzte rekursive PROPFIND -> real 35s/449 Calls).

Calendar/Contacts: PARSE_DEADLINE_S fuer client-seitiges vobject-Parsen;
MAX_RESULTS-Output-Cap in get_events/get_tasks (war 10939 Zeilen bei 6000 Events);
search Top-N + Hinweise.

Tests: in Schichten getrennt -> Smoke (nightly), Edge + Stress (on-demand).
Neu: test_smoke/test_edge/test_stress + conftest-Marker + run_full_tests.sh,
nightly laeuft nur Smoke. Kontakt-Foto-Roundtrip-Test, Mail-Edge-Mails
(x-unknown, kaputter Header, 2009er Alt-Mail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Lohmaier
2026-06-24 21:17:26 +02:00
co-authored by Claude Opus 4.8
parent 936ebc2f56
commit c9ccb4392b
26 changed files with 719 additions and 65 deletions
+66 -16
View File
@@ -8,10 +8,11 @@ import contextlib
import imaplib
import mailbox
import gc
import time
import ctypes
from email.header import decode_header
from email.mime.text import MIMEText
from email.utils import formatdate
from email.utils import formatdate, parsedate_to_datetime
from pathlib import Path
from typing import Annotated
@@ -170,6 +171,32 @@ def _open_folder(acct_path, folder_name):
return mailbox.Maildir(path, create=False) if os.path.isdir(path) else None
MAX_SCAN = 2000 # Obergrenze gesammelter Treffer (Speicher-Schutz bei breiten Queries)
DEADLINE_S = 120 # Wall-Clock-Limit: Server heilt sich selbst, nie wieder endloser Haenger
def _iter_messages(md):
"""Yield (key, msg); ueberspringt unparsbare Mails statt die Suche zu killen."""
try:
keys = list(md.keys())
except Exception:
return
for key in keys:
try:
msg = md.get_message(key)
except Exception:
continue
yield key, msg
def _date_sortkey(date_str):
try:
dt = parsedate_to_datetime(date_str)
return dt.timestamp() if dt else 0.0
except Exception:
return 0.0
@mcp.tool()
def list_accounts() -> str:
"""List all email accounts with their folder count. Call this first to see available accounts."""
@@ -209,7 +236,9 @@ def search_mail(
if not user:
return "Error: not authenticated"
query_lower = query.lower()
results = []
matches = []
truncated = False
t0 = time.monotonic()
for acct_name, acct_path in _discover_accounts(user).items():
if account and account not in acct_name:
continue
@@ -219,21 +248,42 @@ def search_mail(
md = _open_folder(acct_path, fld)
if not md:
continue
for key, msg in md.items():
subj = _decode_hdr(msg.get("Subject", ""))
frm = _decode_hdr(msg.get("From", ""))
to = _decode_hdr(msg.get("To", ""))
date_str = msg.get("Date", "")
if query_lower not in f"{subj} {frm} {to}".lower():
body = _get_body(msg)
if query_lower not in body.lower():
continue
results.append(f"[{date_str}] {frm} -> {to}\n Subject: {subj}\n Account: {acct_name}, Folder: {fld}, Key: {key}")
if len(results) >= limit:
_trim_memory()
return "\n\n".join(results)
for key, msg in _iter_messages(md):
try:
subj = _decode_hdr(msg.get("Subject", ""))
frm = _decode_hdr(msg.get("From", ""))
to = _decode_hdr(msg.get("To", ""))
date_str = msg.get("Date", "")
if query_lower not in f"{subj} {frm} {to}".lower():
if query_lower not in _get_body(msg).lower():
continue
matches.append((date_str, frm, to, subj, acct_name, fld, key))
except Exception:
continue
if len(matches) >= MAX_SCAN:
truncated = True
break
if time.monotonic() - t0 > DEADLINE_S:
truncated = True
break
if truncated:
break
if truncated:
break
matches.sort(key=lambda m: _date_sortkey(m[0]), reverse=True)
shown = matches[:limit]
_trim_memory()
return "\n\n".join(results) if results else "No results found"
if not shown:
return "No results found"
lines = [f"[{d}] {f} -> {t}\n Subject: {s}\n Account: {a}, Folder: {fl}, Key: {k}"
for (d, f, t, s, a, fl, k) in shown]
more = len(matches) - len(shown)
if more > 0:
if truncated:
lines.append(f"... mindestens {more} weitere Treffer (Scan-Limit erreicht; Suche eingrenzen).")
else:
lines.append(f"... {more} weitere Treffer (Suche eingrenzen oder limit erhoehen).")
return "\n\n".join(lines)
@mcp.tool()