"""MCP Mail Server — searches and reads IMAP backup emails.""" import os import sys import contextlib import mailbox from email.header import decode_header from pathlib import Path from typing import Annotated from pydantic import Field from mcp.server.fastmcp import FastMCP 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 MAIL_ROOTS = _lc()["mail_roots"] mcp = FastMCP("Mail", stateless_http=True, transport_security={"enable_dns_rebinding_protection": False}) 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(data.decode(charset or "utf-8", errors="replace")) 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 payload.decode(part.get_content_charset() or "utf-8", errors="replace") for part in msg.walk(): if part.get_content_type() == "text/html": payload = part.get_payload(decode=True) if payload: return "[HTML] " + payload.decode(part.get_content_charset() or "utf-8", errors="replace") else: payload = msg.get_payload(decode=True) if payload: return payload.decode(msg.get_content_charset() or "utf-8", errors="replace") return "" 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 == ".": folders.insert(0, "INBOX") elif rel not in folders: folders.append(rel) return folders def _open_folder(acct_path, folder_name): path = acct_path if folder_name == "INBOX" else 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}" return ( 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]}" ) 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)