Files
mcp-home/mail/server.py
T
root bced937a24 mail: PDF-Anhaenge als extrahierten Text zurueckgeben
read_attachment extrahiert PDF-Text serverseitig via pdfplumber und liefert
ihn als TextContent (statt EmbeddedResource-Blob, den claude.ai nicht lesen
kann). Bei Scan-/Bild-PDFs ohne Text: Hinweis + Blob-Fallback.
Loest 'Connector kann PDF nicht lesen' bei Buchungsbestaetigungen etc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:39:01 +02:00

359 lines
14 KiB
Python

"""MCP Mail Server — searches and reads IMAP backup emails."""
import os
import sys
import base64
import io
import contextlib
import imaplib
import mailbox
from email.header import decode_header
from email.mime.text import MIMEText
from email.utils import formatdate
from pathlib import Path
from typing import Annotated
from pydantic import Field
from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, ImageContent, EmbeddedResource, BlobResourceContents
from starlette.applications import Starlette
from starlette.routing import Mount
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from common import get_current_user, OAUTH_ROUTES, BearerAuthMiddleware
from common import load_config as _lc
_cfg = _lc()
MAIL_ROOTS = _cfg["mail_roots"]
IMAP_ACCOUNTS = {
"stefan": {
"host": "imap.1blu.de",
"user": "d370128_0-slohmaier",
"password": _cfg["imap_passwords"]["stefan"],
"drafts_folder": "Drafts",
"from_addr": "stefan@slohmaier.de",
},
"kati": {
"host": "imap.gmail.com",
"user": "akpolke@gmail.com",
"password": _cfg["imap_passwords"]["kati"],
"drafts_folder": "[Google Mail]/Entw&APw-rfe",
"from_addr": "akpolke@gmail.com",
},
}
mcp = FastMCP("Mail", stateless_http=True,
transport_security={"enable_dns_rebinding_protection": False})
def _safe_decode(payload, charset):
"""Decode bytes robust gegen unbekannte/kaputte Charsets (z.B. 'x-unknown')."""
if not isinstance(payload, bytes):
return str(payload)
for cs in (charset, "utf-8", "latin-1"):
if not cs:
continue
try:
return payload.decode(cs, errors="replace")
except (LookupError, TypeError):
continue
# Letzter Fallback: latin-1 akzeptiert jedes Byte
return payload.decode("latin-1", errors="replace")
def _decode_hdr(raw):
if not raw:
return ""
parts = decode_header(str(raw))
decoded = []
for data, charset in parts:
if isinstance(data, bytes):
decoded.append(_safe_decode(data, charset))
else:
decoded.append(str(data))
return " ".join(decoded)
def _get_body(msg):
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True)
if payload:
return _safe_decode(payload, part.get_content_charset())
for part in msg.walk():
if part.get_content_type() == "text/html":
payload = part.get_payload(decode=True)
if payload:
return "[HTML] " + _safe_decode(payload, part.get_content_charset())
else:
payload = msg.get_payload(decode=True)
if payload:
return _safe_decode(payload, msg.get_content_charset())
return ""
def _get_attachments(msg):
"""Return list of (index, filename, mimetype, size, part) for attachments."""
attachments = []
if not msg.is_multipart():
return attachments
idx = 0
for part in msg.walk():
if part.get_content_maintype() == "multipart":
continue
disp = str(part.get("Content-Disposition", ""))
filename = part.get_filename()
ctype = part.get_content_type()
# Attachment = has filename, or explicit attachment disposition, or non-text inline
is_attachment = (
"attachment" in disp.lower()
or (filename is not None)
or (ctype not in ("text/plain", "text/html") and part.get_content_maintype() != "multipart")
)
if not is_attachment:
continue
payload = part.get_payload(decode=True)
if payload is None:
continue
idx += 1
name = _decode_hdr(filename) if filename else f"attachment_{idx}.{ctype.split('/')[-1]}"
attachments.append({"index": idx, "filename": name, "mime": ctype, "size": len(payload), "payload": payload})
return attachments
def _discover_accounts(user):
root = MAIL_ROOTS.get(user)
if not root or not os.path.isdir(root):
return {}
return {a: os.path.join(root, a) for a in sorted(os.listdir(root)) if os.path.isdir(os.path.join(root, a))}
def _discover_folders(acct_path):
folders = []
for entry in sorted(Path(acct_path).rglob("cur")):
rel = str(entry.parent.relative_to(acct_path))
if rel != "." and rel not in folders:
folders.append(rel)
if "INBOX" in folders:
folders.remove("INBOX")
folders.insert(0, "INBOX")
return folders
def _open_folder(acct_path, folder_name):
path = os.path.join(acct_path, folder_name)
return mailbox.Maildir(path, create=False) if os.path.isdir(path) else None
@mcp.tool()
def list_accounts() -> str:
"""List all email accounts with their folder count. Call this first to see available accounts."""
user = get_current_user()
if not user:
return "Error: not authenticated"
accounts = _discover_accounts(user)
if not accounts:
return "No mail accounts found"
return "\n".join(f"{name}: {len(_discover_folders(path))} folders" for name, path in accounts.items())
@mcp.tool()
def list_folders(
account: Annotated[str, Field(description="Account name from list_accounts, e.g. 'd370128_0-slohmaier' or 'gmail'")]
) -> str:
"""List all IMAP folders in a mail account (INBOX, Sent, Archive, etc.)."""
user = get_current_user()
if not user:
return "Error: not authenticated"
acct_path = _discover_accounts(user).get(account)
if not acct_path:
return f"Account not found: {account}. Use list_accounts to see available accounts."
folders = _discover_folders(acct_path)
return "\n".join(folders) if folders else "No folders"
@mcp.tool()
def search_mail(
query: Annotated[str, Field(description="Search term — matches subject, from, to, and body. Example: 'Rechnung', 'Amazon', 'meeting'")],
account: Annotated[str, Field(description="Filter by account name. Leave empty to search all accounts.")] = "",
folder: Annotated[str, Field(description="Filter by folder name, e.g. 'INBOX', 'Sent'. Leave empty for all folders.")] = "",
limit: Annotated[int, Field(description="Maximum number of results to return")] = 20,
) -> str:
"""Search emails by keyword. Returns date, sender, recipient, subject, and a location key for reading the full email with read_mail."""
user = get_current_user()
if not user:
return "Error: not authenticated"
query_lower = query.lower()
results = []
for acct_name, acct_path in _discover_accounts(user).items():
if account and account not in acct_name:
continue
for fld in _discover_folders(acct_path):
if folder and folder.lower() not in fld.lower():
continue
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:
return "\n\n".join(results)
return "\n\n".join(results) if results else "No results found"
@mcp.tool()
def read_mail(
account: Annotated[str, Field(description="Account name from search results, e.g. 'd370128_0-slohmaier'")],
folder: Annotated[str, Field(description="Folder name from search results, e.g. 'INBOX'")],
key: Annotated[str, Field(description="Message key from search results")],
) -> str:
"""Read the full content of a single email. Use the account, folder, and key values from search_mail results."""
user = get_current_user()
if not user:
return "Error: not authenticated"
acct_path = _discover_accounts(user).get(account)
if not acct_path:
return f"Account not found: {account}"
md = _open_folder(acct_path, folder)
if not md:
return f"Folder not found: {folder}"
msg = md.get(key)
if not msg:
return f"Message not found: {key}"
out = (
f"From: {_decode_hdr(msg.get('From', ''))}\n"
f"To: {_decode_hdr(msg.get('To', ''))}\n"
f"Cc: {_decode_hdr(msg.get('Cc', ''))}\n"
f"Date: {msg.get('Date', '')}\n"
f"Subject: {_decode_hdr(msg.get('Subject', ''))}\n\n"
f"{_get_body(msg)[:50000]}"
)
attachments = _get_attachments(msg)
if attachments:
out += "\n\nAnhaenge (nutze read_attachment mit dem Index):\n"
for a in attachments:
out += f" {a['index']}. {a['filename']} ({a['mime']}, {a['size']:,} bytes)\n"
return out
@mcp.tool()
def read_attachment(
account: Annotated[str, Field(description="Account name from search results")],
folder: Annotated[str, Field(description="Folder name from search results")],
key: Annotated[str, Field(description="Message key from search results")],
attachment_index: Annotated[int, Field(description="Attachment number from the read_mail attachment list (1-based)")],
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Read an email attachment. Images shown inline, PDFs as extracted text, text directly, other documents as binary. Get the index from read_mail."""
user = get_current_user()
if not user:
return [TextContent(type="text", text="Error: not authenticated")]
acct_path = _discover_accounts(user).get(account)
if not acct_path:
return [TextContent(type="text", text=f"Account not found: {account}")]
md = _open_folder(acct_path, folder)
if not md:
return [TextContent(type="text", text=f"Folder not found: {folder}")]
msg = md.get(key)
if not msg:
return [TextContent(type="text", text=f"Message not found: {key}")]
attachments = _get_attachments(msg)
att = next((a for a in attachments if a["index"] == attachment_index), None)
if not att:
return [TextContent(type="text", text=f"Anhang {attachment_index} nicht gefunden. {len(attachments)} Anhaenge vorhanden.")]
mime = att["mime"]
payload = att["payload"]
if mime in ("image/jpeg", "image/png", "image/gif", "image/webp") and len(payload) < 10_000_000:
return [ImageContent(type="image", data=base64.b64encode(payload).decode(), mimeType=mime)]
if mime.startswith("text/"):
return [TextContent(type="text", text=payload.decode("utf-8", errors="replace")[:100000])]
if mime == "application/pdf" or att["filename"].lower().endswith(".pdf"):
try:
import pdfplumber
with pdfplumber.open(io.BytesIO(payload)) as pdf:
pages = []
for i, page in enumerate(pdf.pages, 1):
t = page.extract_text() or ""
if t.strip():
pages.append(f"--- Seite {i} ---\n{t}")
text = "\n\n".join(pages)
if text.strip():
return [TextContent(type="text", text=f"[PDF: {att['filename']}]\n\n{text[:200000]}")]
# Kein extrahierbarer Text -> vermutlich Scan/Bild-PDF: als Blob zurueck
return [
TextContent(type="text", text=f"[PDF '{att['filename']}' enthaelt keinen extrahierbaren Text (vermutlich Scan). Rohdaten folgen.]"),
EmbeddedResource(type="resource", resource=BlobResourceContents(
uri=f"mail://attachment/{att['filename']}",
blob=base64.b64encode(payload).decode(), mimeType=mime)),
]
except Exception as e:
return [TextContent(type="text", text=f"PDF '{att['filename']}' konnte nicht gelesen werden: {e}")]
return [EmbeddedResource(type="resource", resource=BlobResourceContents(
uri=f"mail://attachment/{att['filename']}",
blob=base64.b64encode(payload).decode(), mimeType=mime))]
@mcp.tool()
def create_draft(
to: Annotated[str, Field(description="Recipient email address, e.g. 'max@example.com'")],
subject: Annotated[str, Field(description="Email subject line")],
body: Annotated[str, Field(description="Email body text (plain text)")],
cc: Annotated[str, Field(description="CC recipients, comma-separated")] = "",
account: Annotated[str, Field(description="Which account to create the draft in. Leave empty for default account.")] = "",
) -> str:
"""Create an email draft in the Drafts folder. The draft can then be reviewed and sent from Roundcube or phone. Does NOT send the email."""
user = get_current_user()
if not user: return "Error: not authenticated"
acct = IMAP_ACCOUNTS.get(user)
if not acct: return f"Kein IMAP-Account fuer {user} konfiguriert"
msg = MIMEText(body, "plain", "utf-8")
msg["From"] = acct["from_addr"]
msg["To"] = to
if cc:
msg["Cc"] = cc
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
msg["X-Mailer"] = "MCP Mail Server"
try:
imap = imaplib.IMAP4_SSL(acct["host"])
imap.login(acct["user"], acct["password"])
status, _ = imap.append(acct["drafts_folder"], "\\Draft", None, msg.as_bytes())
imap.logout()
if status == "OK":
return f"Entwurf erstellt: An {to}, Betreff \"{subject}\". Oeffne Roundcube oder die Mail-App um ihn zu senden."
return f"Fehler beim Erstellen des Entwurfs: {status}"
except Exception as e:
return f"IMAP-Fehler: {e}"
def create_app():
from contextlib import asynccontextmanager
mcp_app = mcp.streamable_http_app()
@asynccontextmanager
async def lifespan(app):
async with contextlib.AsyncExitStack() as stack:
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
yield
routes = list(OAUTH_ROUTES) + [Mount("/", app=mcp_app)]
app = Starlette(routes=routes, lifespan=lifespan)
app.add_middleware(BearerAuthMiddleware)
return app
if __name__ == "__main__":
import uvicorn
uvicorn.run(create_app(), host="127.0.0.1", port=5100)