""" MCP Email Server - MCP Streamable HTTP compatible with Open WebUI Features: - IMAP (login) + SMTP (login) - List/search all folders (including shared/public) - New-message tracking and flagging - Triage guidance (business description + personnel/chain-of-command) - Extended tools: reply, forward, drafts, move, copy, labels, attachments, schedule_send, export_conversation - Conflict check search - CalDAV / CardDAV integration (e.g., Nextcloud) for calendar and contacts - Exposes MCP tools via Streamable HTTP (JSON-RPC 2.0) Run: - uvicorn server:app --host 0.0.0.0 --port 8000 """ import json import os import time import base64 import email import email.policy import imaplib import smtplib import logging import threading import uuid import re import xml.etree.ElementTree as ET from dataclasses import dataclass from typing import Any, Dict, List, Optional import requests from fastapi import FastAPI, Request, HTTPException from fastapi.responses import JSONResponse logging.basicConfig( level=os.getenv("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(name)s %(message)s", ) logger = logging.getLogger("mcp-email-server") # -------------------- CONFIG -------------------- @dataclass class EmailConfig: # IMAP IMAP_HOST: str = os.getenv("IMAP_HOST", "imap.example.com") IMAP_PORT: int = int(os.getenv("IMAP_PORT", "993")) IMAP_USE_SSL: bool = os.getenv("IMAP_USE_SSL", "true").lower() in ("true", "1", "yes") IMAP_USERNAME: str = os.getenv("IMAP_USERNAME", "") IMAP_PASSWORD: str = os.getenv("IMAP_PASSWORD", "") # SMTP SMTP_HOST: str = os.getenv("SMTP_HOST", "smtp.example.com") SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587")) SMTP_USE_TLS: bool = os.getenv("SMTP_USE_TLS", "true").lower() in ("true", "1", "yes") SMTP_USE_SSL: bool = os.getenv("SMTP_USE_SSL", "false").lower() in ("true", "1", "yes") SMTP_USERNAME: str = os.getenv("SMTP_USERNAME", "") SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "") SMTP_FROM: str = os.getenv("SMTP_FROM", "") # Optional cc/bcc defaults DEFAULT_CC: str = os.getenv("DEFAULT_CC", "") DEFAULT_BCC: str = os.getenv("DEFAULT_BCC", "") # Business/person description BUSINESS_DESCRIPTION: str = os.getenv("BUSINESS_DESCRIPTION", "") # Personnel / chain-of-command (JSON) PERSONNEL_JSON: str = os.getenv("PERSONNEL_JSON", "[]") # Scheduling SCHEDULED_SEND_INTERVAL: int = int(os.getenv("SCHEDULED_SEND_INTERVAL", "10")) # CardDAV / CalDAV (e.g., Nextcloud) DAV_BASE_URL: str = os.getenv("DAV_BASE_URL", "") DAV_USERNAME: str = os.getenv("DAV_USERNAME", "") DAV_PASSWORD: str = os.getenv("DAV_PASSWORD", "") DAV_VERIFY_TLS: bool = os.getenv("DAV_VERIFY_TLS", "true").lower() in ("true", "1", "yes") @property def personnel(self) -> List[Dict[str, Any]]: try: return json.loads(self.PERSONNEL_JSON) except Exception: return [] @property def default_cc_list(self) -> List[str]: return [e.strip() for e in self.DEFAULT_CC.split(",") if e.strip()] @property def default_bcc_list(self) -> List[str]: return [e.strip() for e in self.DEFAULT_BCC.split(",") if e.strip()] config = EmailConfig() # -------------------- IMAP HELPERS (UID-based) -------------------- def create_imap(): if config.IMAP_USE_SSL: return imaplib.IMAP4_SSL(config.IMAP_HOST, config.IMAP_PORT) else: m = imaplib.IMAP4(config.IMAP_HOST, config.IMAP_PORT) m.starttls() return m def login_imap(imap): imap.login(config.IMAP_USERNAME, config.IMAP_PASSWORD) def list_all_folders(imap): _, data = imap.list() folders = [] for line in data or []: if isinstance(line, bytes): line = line.decode("utf-8", errors="replace") parts = line.split('"') if len(parts) >= 4: name = parts[3] folders.append({"path": name}) return folders def ensure_selected(imap, folder: str): imap.select(folder, readonly=False) def parse_message(imap, uid: int): status, msg_data = imap.uid("FETCH", str(uid), "(RFC822)") if status != "OK" or not msg_data or msg_data[0] is None: raise RuntimeError(f"Failed to fetch message UID {uid}") msg = email.message_from_bytes(msg_data[0][1], policy=email.policy.default) subject = msg.get("Subject", "") or "" from_header = msg.get("From", "") or "" to_header = msg.get("To", "") or "" cc_header = msg.get("Cc", "") or "" date_header = msg.get("Date", "") or "" message_id = msg.get("Message-ID", "") or "" in_reply_to = msg.get("In-Reply-To", "") or "" references = msg.get("References", "") or "" body_plain = "" body_html = "" if msg.is_multipart(): for part in msg.walk(): ptype = part.get_content_type() pdisp = (part.get("Content-Disposition") or "").lower() if "attachment" in pdisp: continue if ptype == "text/plain": body_plain += part.get_payload(decode=True).decode( part.get_content_charset() or "utf-8", errors="replace" ) elif ptype == "text/html": body_html += part.get_payload(decode=True).decode( part.get_content_charset() or "utf-8", errors="replace" ) else: ptype = msg.get_content_type() if ptype in ("text/plain", "text/html"): payload = msg.get_payload(decode=True).decode( msg.get_content_charset() or "utf-8", errors="replace" ) if ptype == "text/plain": body_plain = payload else: body_html = payload _, flags_data = imap.uid("FETCH", str(uid), "(FLAGS)") flags = [] if flags_data: line = flags_data[0].decode("utf-8", errors="replace") start = line.find("(") end = line.find(")") if start != -1 and end != -1: flags = [f.strip() for f in line[start+1:end].split() if f.strip()] return { "uid": uid, "subject": subject, "from": from_header, "to": to_header, "cc": cc_header, "date": date_header, "body_plain": body_plain, "body_html": body_html, "flags": flags, "message_id": message_id, "in_reply_to": in_reply_to, "references": references, } def search_messages(imap, folder: str, query: str, since: Optional[str], max_results: int): ensure_selected(imap, folder) criteria = [] if query: criteria.append(f'SUBJECT "{query}"') else: criteria.append("ALL") if since: criteria.append(f"SINCE {since}") search_str = " ".join(criteria) status, data = imap.uid("SEARCH", None, search_str) if status != "OK" or not data or not data[0]: return [] ids = (data[0].split() if data[0] else []) ids = ids[-max_results:] results = [] for uid_bytes in ids: uid = int(uid_bytes) try: m = parse_message(imap, uid) results.append(m) except Exception as e: logger.error(f"Error parsing message UID {uid}: {e}") return results def mark_as_read(imap, folder: str, uid: int): ensure_selected(imap, folder) imap.uid("STORE", str(uid), "+FLAGS", "\\Seen") def flag_message(imap, folder: str, uid: int): ensure_selected(imap, folder) imap.uid("STORE", str(uid), "+FLAGS", "\\Flagged") def move_message(imap, folder: str, uid: int, dest_folder: str): ensure_selected(imap, folder) imap.uid("COPY", str(uid), dest_folder) imap.uid("STORE", str(uid), "+FLAGS", "\\Deleted") imap.expunge() def copy_message(imap, folder: str, uid: int, dest_folder: str): ensure_selected(imap, folder) imap.uid("COPY", str(uid), dest_folder) def add_flags(imap, folder: str, uid: int, flags: List[str]): ensure_selected(imap, folder) imap.uid("STORE", str(uid), "+FLAGS", tuple(flags)) def remove_flags(imap, folder: str, uid: int, flags: List[str]): ensure_selected(imap, folder) imap.uid("STORE", str(uid), "-FLAGS", tuple(flags)) def get_labels(imap, folder: str, uid: int): _, flags_data = imap.uid("FETCH", str(uid), "(FLAGS)") if not flags_data: return [] line = flags_data[0].decode("utf-8", errors="replace") start = line.find("(") end = line.find(")") if start == -1 or end == -1: return [] flags = [f.strip() for f in line[start+1:end].split() if f.strip()] return [f for f in flags if f.startswith("$Label_")] def list_available_labels(imap): labels = set() try: folders = list_all_folders(imap) for f in folders[:20]: path = f["path"] try: ensure_selected(imap, path) status, data = imap.uid("SEARCH", None, "ALL") if status != "OK" or not data or not data[0]: continue ids = data[0].split() for uid_bytes in ids[:20]: uid = int(uid_bytes) _, flags_data = imap.uid("FETCH", str(uid), "(FLAGS)") if not flags_data: continue line = flags_data[0].decode("utf-8", errors="replace") start = line.find("(") end = line.find(")") if start == -1 or end == -1: continue flags = [f.strip() for f in line[start+1:end].split() if f.strip()] for fl in flags: if fl.startswith("$Label_"): labels.add(fl.replace("$Label_", "", 1)) except Exception: continue except Exception: pass return sorted(labels) # -------------------- SMTP HELPERS -------------------- def send_email( to: List[str], subject: str, body: str, cc: Optional[List[str]] = None, bcc: Optional[List[str]] = None, html: bool = False, in_reply_to: Optional[str] = None, references: Optional[str] = None, ): if not config.SMTP_FROM: raise ValueError("SMTP_FROM not configured") if not cc: cc = [] if not bcc: bcc = [] cc = list(set(cc + config.default_cc_list)) bcc = list(set(bcc + config.default_bcc_list)) all_recipients = to + cc + bcc from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart if html: m = MIMEMultipart("alternative") m.attach(MIMEText(body, "html")) else: m = MIMEText(body, "plain") m["From"] = config.SMTP_FROM m["To"] = ", ".join(to) if cc: m["Cc"] = ", ".join(cc) m["Subject"] = subject m["Message-ID"] = f"<{uuid.uuid4().hex}@{config.SMTP_FROM.split('@')[1]}>" if in_reply_to: m["In-Reply-To"] = in_reply_to if references: m["References"] = references if config.SMTP_USE_SSL: s = smtplib.SMTP_SSL(config.SMTP_HOST, config.SMTP_PORT) else: s = smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT) if config.SMTP_USE_TLS: s.starttls() s.login(config.SMTP_USERNAME, config.SMTP_PASSWORD) s.sendmail(config.SMTP_FROM, all_recipients, m.as_string()) s.quit() # -------------------- NEW-MESSAGE TRACKING -------------------- last_seen_uids: Dict[str, set] = {} def get_new_messages(imap, folder: str): ensure_selected(imap, folder) status, data = imap.uid("SEARCH", None, "ALL") if status != "OK" or not data or not data[0]: return [] ids = data[0].split() uids = {int(u) for u in ids} prev = last_seen_uids.setdefault(folder, set()) new_uids = sorted(uids - prev) last_seen_uids[folder] = uids results = [] for uid in new_uids: m = parse_message(imap, uid) results.append(m) return results def sync_seen(imap, folder: str): ensure_selected(imap, folder) status, data = imap.uid("SEARCH", None, "ALL") if status != "OK" or not data or not data[0]: last_seen_uids[folder] = set() return ids = data[0].split() last_seen_uids[folder] = {int(u) for u in ids} # -------------------- TRIAGE HELPERS -------------------- def build_triage_hint(msg: Dict[str, Any]) -> str: if not config.BUSINESS_DESCRIPTION and not config.personnel: return "" hint_parts = [] if config.BUSINESS_DESCRIPTION: hint_parts.append(f"Context: {config.BUSINESS_DESCRIPTION}") from email.utils import parseaddr sender_email = parseaddr(msg.get("from", ""))[1].lower() personnel_by_email = { p["email"].lower(): p for p in config.personnel if p.get("email") } sender_info = personnel_by_email.get(sender_email) if sender_info: hint_parts.append( f"Sender is internal: {sender_info['name']} ({sender_info['role']})." ) if sender_info.get("escalates_to"): hint_parts.append( f"If escalation needed, escalate to: {sender_info['escalates_to']}." ) subject = (msg.get("subject") or "").lower() if any(w in subject for w in ["urgent", "asap", "escalat", "priority"]): hint_parts.append("Message appears urgent; consider escalation.") return " ".join(hint_parts) # -------------------- CONFLICT CHECK SEARCH -------------------- def conflict_check_search(imap, terms: List[str], folders: Optional[List[str]] = None, max_results: int = 100): if not folders: folders = ["INBOX"] all_results = [] for folder in folders: try: ensure_selected(imap, folder) except Exception: continue for term in terms: try: status, data = imap.uid("SEARCH", None, f'SUBJECT "{term}"') if status != "OK" or not data or not data[0]: continue ids = data[0].split() for uid_bytes in ids[-max_results:]: uid = int(uid_bytes) msg = parse_message(imap, uid) all_results.append({ "folder": folder, "uid": uid, "subject": msg["subject"], "from": msg["from"], "to": msg["to"], "cc": msg["cc"], "date": msg["date"], "matched_term": term, "snippet": (msg["body_plain"] or "")[:400], }) except Exception as e: logger.error(f"Conflict search error for term '{term}' in {folder}: {e}") seen = set() unique = [] for r in all_results: key = (r["folder"], r["uid"]) if key not in seen: seen.add(key) unique.append(r) return unique # -------------------- ATTACHMENTS -------------------- def list_attachments(imap, folder: str, uid: int) -> List[Dict[str, Any]]: ensure_selected(imap, folder) status, msg_data = imap.uid("FETCH", str(uid), "(RFC822)") if status != "OK" or not msg_data or msg_data[0] is None: return [] msg = email.message_from_bytes(msg_data[0][1], policy=email.policy.default) attachments = [] for part in msg.walk(): pdisp = (part.get("Content-Disposition") or "").lower() if "attachment" not in pdisp: continue filename = part.get_filename() or "attachment" size = len(part.get_payload(decode=True) or b"") attachments.append({ "filename": filename, "content_type": part.get_content_type(), "size": size, }) return attachments def download_attachment(imap, folder: str, uid: int, filename: str) -> Dict[str, Any]: ensure_selected(imap, folder) status, msg_data = imap.uid("FETCH", str(uid), "(RFC822)") if status != "OK" or not msg_data or msg_data[0] is None: raise ValueError("Failed to fetch message for attachment") msg = email.message_from_bytes(msg_data[0][1], policy=email.policy.default) for part in msg.walk(): pdisp = (part.get("Content-Disposition") or "").lower() if "attachment" not in pdisp: continue part_filename = part.get_filename() or "attachment" if part_filename != filename: continue payload = part.get_payload(decode=True) or b"" b64 = base64.b64encode(payload).decode("ascii") return { "filename": part_filename, "content_type": part.get_content_type(), "size": len(payload), "content_base64": b64, } raise ValueError(f"Attachment not found: {filename}") def search_attachments(imap, folder: str, file_pattern: str, max_results: int): ensure_selected(imap, folder) status, data = imap.uid("SEARCH", None, "ALL") if status != "OK" or not data or not data[0]: return [] ids = data[0].split() ids = ids[-max_results * 5:] pattern = file_pattern.lower() results = [] for uid_bytes in ids: if len(results) >= max_results: break uid = int(uid_bytes) try: status, msg_data = imap.uid("FETCH", str(uid), "(RFC822)") if status != "OK" or not msg_data or msg_data[0] is None: continue msg = email.message_from_bytes(msg_data[0][1], policy=email.policy.default) for part in msg.walk(): pdisp = (part.get("Content-Disposition") or "").lower() if "attachment" not in pdisp: continue filename = (part.get_filename() or "").lower() if pattern and pattern not in filename: continue results.append({ "uid": uid, "subject": msg.get("Subject", ""), "from": msg.get("From", ""), "date": msg.get("Date", ""), "filename": part.get_filename() or "attachment", }) break except Exception as e: logger.error(f"Error scanning message UID {uid}: {e}") return results # -------------------- DRAFTS (IMAP Drafts folder) -------------------- def save_draft_to_imap(imap, folder: str, to: List[str], subject: str, body: str, html: bool = False): from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart m = MIMEMultipart() m["From"] = config.SMTP_FROM m["To"] = ", ".join(to) m["Subject"] = subject m["Message-ID"] = f"<{uuid.uuid4().hex}@{config.SMTP_FROM.split('@')[1]}>" m.attach(MIMEText(body, "html" if html else "plain")) ensure_selected(imap, folder) imap.append(folder, None, None, m.as_string().encode("utf-8")) return {"status": "draft_saved", "folder": folder} # -------------------- SCHEDULED SEND (in-memory) -------------------- scheduled_sends: Dict[str, Dict[str, Any]] = {} schedule_lock = threading.Lock() def add_scheduled_send(to, subject, body, cc, bcc, html, send_at, in_reply_to, references): task_id = uuid.uuid4().hex with schedule_lock: scheduled_sends[task_id] = { "to": to, "subject": subject, "body": body, "cc": cc, "bcc": bcc, "html": html, "send_at": float(send_at), "in_reply_to": in_reply_to, "references": references, } return task_id def scheduled_send_loop(): while True: now = time.time() with schedule_lock: ready = [tid for tid, t in scheduled_sends.items() if t["send_at"] <= now] for tid in ready: with schedule_lock: t = scheduled_sends.pop(tid, None) if not t: continue try: send_email( to=t["to"], subject=t["subject"], body=t["body"], cc=t["cc"], bcc=t["bcc"], html=t["html"], in_reply_to=t.get("in_reply_to"), references=t.get("references"), ) except Exception as e: logger.error(f"Scheduled send error for {tid}: {e}") time.sleep(config.SCHEDULED_SEND_INTERVAL) scheduler_thread = threading.Thread(target=scheduled_send_loop, daemon=True) scheduler_thread.start() # -------------------- EXPORT CONVERSATION -------------------- def export_conversation(imap, folder: str, message_uid: int, max_messages: int): ensure_selected(imap, folder) status, msg_data = imap.uid("FETCH", str(message_uid), "(RFC822)") if status != "OK" or not msg_data or msg_data[0] is None: return "Failed to fetch root message." root = email.message_from_bytes(msg_data[0][1], policy=email.policy.default) subject = (root.get("Subject", "") or "").strip() msg_id = (root.get("Message-ID", "") or "").strip() refs = set((root.get("References", "") or "").split()) in_reply = (root.get("In-Reply-To", "") or "").strip() if in_reply: refs.add(in_reply) status, data = imap.uid("SEARCH", None, f'SUBJECT "{subject}"') if status != "OK" or not data or not data[0]: ids = [] else: ids = data[0].split() thread_uids = [] for uid_bytes in ids: uid = int(uid_bytes) if len(thread_uids) >= max_messages: break status, mdata = imap.uid("FETCH", str(uid), "(RFC822)") if status != "OK" or not mdata or mdata[0] is None: continue m = email.message_from_bytes(mdata[0][1], policy=email.policy.default) mid = (m.get("Message-ID", "") or "").strip() mrefs = set((m.get("References", "") or "").split()) mirt = (m.get("In-Reply-To", "") or "").strip() if uid == message_uid or mid == msg_id: thread_uids.append(uid) elif refs and mrefs & refs: thread_uids.append(uid) elif mirt and (mirt == msg_id or mirt in refs): thread_uids.append(uid) elif mid and (mid in refs or mid == in_reply): thread_uids.append(uid) lines = [] for uid in thread_uids: try: msg = parse_message(imap, uid) except Exception: continue lines.append(f"From: {msg['from']}") lines.append(f"To: {msg['to']}") lines.append(f"Date: {msg['date']}") lines.append(f"Subject: {msg['subject']}") lines.append("") lines.append(msg["body_plain"].strip()) lines.append("\n---\n") return "\n".join(lines) # -------------------- CARDdav / CALdav HELPERS -------------------- DAV_NS = {"D": "DAV:"} def dav_session(): if not config.DAV_BASE_URL or not config.DAV_USERNAME or not config.DAV_PASSWORD: raise ValueError("DAV_BASE_URL, DAV_USERNAME, or DAV_PASSWORD not configured") s = requests.Session() s.auth = (config.DAV_USERNAME, config.DAV_PASSWORD) s.verify = config.DAV_VERIFY_TLS return s def dav_request(s, method, url, data=None, headers=None, timeout=20): try: r = s.request(method, url, data=data, headers=headers or {}, timeout=timeout) return r except Exception as e: logger.error(f"DAV request error: {method} {url}: {e}") raise def parse_dav_propfind(xml_text): try: root = ET.fromstring(xml_text) except ET.ParseError: return [] results = [] for response in root.findall(".//D:response", DAV_NS): href_el = response.find("D:href", DAV_NS) href = (href_el.text or "").strip() if href_el is not None else "" props = {} propfind = response.find("D:propstat/D:prop", DAV_NS) if propfind is not None: for child in propfind: tag = re.sub(r".*\{.*\}", "", child.tag) text = (child.text or "").strip() props[tag] = text if href: results.append({"href": href, "props": props}) return results # CardDAV helpers def carddav_base_url(): return config.DAV_BASE_URL.rstrip("/") + "/cards/" def list_carddav_addressbooks(): s = dav_session() r = dav_request(s, "PROPFIND", carddav_base_url(), headers={"Depth": "1", "Content-Type": "application/xml"}, data=""" """) r.raise_for_status() entries = parse_dav_propfind(r.text) addressbooks = [] for e in entries: href = e["href"] props = e.get("props", {}) rt = props.get("resourcetype", "") if "/cards/" in href and href.endswith("/") and ("addressbook" in rt.lower() or "addressbook" in href.lower()): addressbooks.append({ "href": href, "name": props.get("displayname", href), }) return addressbooks def _xml_escape(s: str) -> str: s = s.replace("&", "&") s = s.replace("<", "<") s = s.replace(">", ">") s = s.replace('"', """) s = s.replace("'", "'") return s def search_carddav_contacts(addressbook_href: str, query: str): s = dav_session() safe_query = _xml_escape(query) req_body = f""" {safe_query} """ r = dav_request(s, "REPORT", addressbook_href, headers={"Content-Type": "application/xml"}, data=req_body) r.raise_for_status() hrefs = re.findall(r'(.*?)', r.text, re.DOTALL) contacts = [] for href in hrefs: href = href.strip() if not href: continue try: vr = dav_request(s, "GET", href) vr.raise_for_status() contacts.append({ "href": href, "vcard": vr.text, }) except Exception as e: logger.error(f"Error fetching vCard {href}: {e}") return contacts def get_carddav_contact(href: str): s = dav_session() r = dav_request(s, "GET", href) r.raise_for_status() return {"href": href, "vcard": r.text} def create_carddav_contact(addressbook_href: str, vcard: str): s = dav_session() filename = f"{uuid.uuid4().hex}.vcf" url = addressbook_href.rstrip("/") + "/" + filename r = dav_request(s, "PUT", url, data=vcard.encode("utf-8"), headers={"Content-Type": "text/vcard"}) r.raise_for_status() return {"href": url} def update_carddav_contact(href: str, vcard: str): s = dav_session() r = dav_request(s, "PUT", href, data=vcard.encode("utf-8"), headers={"Content-Type": "text/vcard"}) r.raise_for_status() return {"href": href} def delete_carddav_contact(href: str): s = dav_session() r = dav_request(s, "DELETE", href) r.raise_for_status() return {"href": href} # CalDAV helpers def caldav_base_url(): return config.DAV_BASE_URL.rstrip("/") + "/calendars/" def list_caldav_calendars(): s = dav_session() r = dav_request(s, "PROPFIND", caldav_base_url(), headers={"Depth": "1", "Content-Type": "application/xml"}, data=""" """) r.raise_for_status() entries = parse_dav_propfind(r.text) calendars = [] for e in entries: href = e["href"] props = e.get("props", {}) rt = props.get("resourcetype", "") if "/calendars/" in href and href.endswith("/") and ("calendar" in rt.lower() or "calendar" in href.lower()): calendars.append({ "href": href, "name": props.get("displayname", href.split("/")[-2]), }) return calendars def search_caldav_events(calendar_href: str, start: str, end: str): s = dav_session() req_body = f""" """ r = dav_request(s, "REPORT", calendar_href, headers={"Content-Type": "application/xml"}, data=req_body) r.raise_for_status() hrefs = re.findall(r'(.*?)', r.text, re.DOTALL) events = [] for href in hrefs: href = href.strip() if not href: continue try: vr = dav_request(s, "GET", href) vr.raise_for_status() events.append({ "href": href, "ical": vr.text, }) except Exception as e: logger.error(f"Error fetching event {href}: {e}") return events def create_caldav_event(calendar_href: str, ical: str): s = dav_session() filename = f"{uuid.uuid4().hex}.ics" url = calendar_href.rstrip("/") + "/" + filename r = dav_request(s, "PUT", url, data=ical.encode("utf-8"), headers={"Content-Type": "text/calendar"}) r.raise_for_status() return {"href": url} def update_caldav_event(href: str, ical: str): s = dav_session() r = dav_request(s, "PUT", href, data=ical.encode("utf-8"), headers={"Content-Type": "text/calendar"}) r.raise_for_status() return {"href": href} def delete_caldav_event(href: str): s = dav_session() r = dav_request(s, "DELETE", href) r.raise_for_status() return {"href": href} # -------------------- MCP TOOL DEFINITIONS -------------------- TOOLS = [ { "name": "list_folders", "description": "List all IMAP folders.", "inputSchema": { "type": "object", "properties": {}, "required": [] } }, { "name": "search_messages", "description": "Search messages in a folder by subject and optional date.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"}, "query": {"type": "string", "description": "Search query (subject)"}, "since": {"type": "string", "description": "Optional SINCE date, e.g. '01-Jan-2024'"}, "max_results": {"type": "integer", "description": "Max results (default: 50)"} }, "required": [] } }, { "name": "get_new_messages", "description": "Get new messages in a folder since last check.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"} }, "required": [] } }, { "name": "sync_seen", "description": "Sync seen UIDs for a folder to reset new-message tracking.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"} }, "required": [] } }, { "name": "mark_as_read", "description": "Mark a message as read.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string"}, "uid": {"type": "integer"} }, "required": ["folder", "uid"] } }, { "name": "flag_message", "description": "Flag a message.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string"}, "uid": {"type": "integer"} }, "required": ["folder", "uid"] } }, { "name": "move_message", "description": "Move a message to another folder.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string"}, "uid": {"type": "integer"}, "dest_folder": {"type": "string"} }, "required": ["folder", "uid", "dest_folder"] } }, { "name": "copy_message", "description": "Copy a message to another folder.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string"}, "uid": {"type": "integer"}, "dest_folder": {"type": "string"} }, "required": ["folder", "uid", "dest_folder"] } }, { "name": "apply_label", "description": "Apply a label (custom flag) to a message.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string"}, "uid": {"type": "integer"}, "label": {"type": "string", "description": "Label name (without prefix)"} }, "required": ["folder", "uid", "label"] } }, { "name": "remove_label", "description": "Remove a label from a message.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string"}, "uid": {"type": "integer"}, "label": {"type": "string", "description": "Label name (without prefix)"} }, "required": ["folder", "uid", "label"] } }, { "name": "list_labels", "description": "List labels for a message or available labels in a folder.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"}, "uid": {"type": "integer", "description": "If set, list labels for that message; else list used labels in folder."} }, "required": [] } }, { "name": "get_unread_summary", "description": "Get unread message summary for a folder.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"} }, "required": [] } }, { "name": "send_email", "description": "Send an email.", "inputSchema": { "type": "object", "properties": { "to": {"type": "array", "items": {"type": "string"}}, "subject": {"type": "string"}, "body": {"type": "string"}, "cc": {"type": "array", "items": {"type": "string"}}, "bcc": {"type": "array", "items": {"type": "string"}}, "html": {"type": "boolean", "description": "Treat body as HTML"}, "in_reply_to": {"type": "string"}, "references": {"type": "string"} }, "required": ["to", "subject", "body"] } }, { "name": "reply_to_message", "description": "Reply to a specific message.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string"}, "uid": {"type": "integer"}, "body": {"type": "string"}, "html": {"type": "boolean"}, "to": {"type": "array", "items": {"type": "string"}, "description": "Override reply recipients"}, "cc": {"type": "array", "items": {"type": "string"}}, "bcc": {"type": "array", "items": {"type": "string"}} }, "required": ["folder", "uid", "body"] } }, { "name": "forward_message", "description": "Forward a message.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string"}, "uid": {"type": "integer"}, "to": {"type": "array", "items": {"type": "string"}}, "cc": {"type": "array", "items": {"type": "string"}}, "bcc": {"type": "array", "items": {"type": "string"}}, "note": {"type": "string", "description": "Optional note to prepend"}, "html": {"type": "boolean"} }, "required": ["folder", "uid", "to"] } }, { "name": "save_draft", "description": "Save a draft to an IMAP folder.", "inputSchema": { "type": "object", "properties": { "to": {"type": "array", "items": {"type": "string"}}, "subject": {"type": "string"}, "body": {"type": "string"}, "html": {"type": "boolean"}, "folder": {"type": "string", "description": "IMAP folder (default: Drafts)"} }, "required": ["to", "subject", "body"] } }, { "name": "list_attachments", "description": "List attachments for a message.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string"}, "uid": {"type": "integer"} }, "required": ["folder", "uid"] } }, { "name": "download_attachment", "description": "Download an attachment (returns base64 content).", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string"}, "uid": {"type": "integer"}, "filename": {"type": "string"} }, "required": ["folder", "uid", "filename"] } }, { "name": "search_attachments", "description": "Search for attachments by filename pattern.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"}, "file_pattern": {"type": "string", "description": "Filename substring to match (case-insensitive)"}, "max_results": {"type": "integer", "description": "Max results (default: 50)"} }, "required": [] } }, { "name": "schedule_send", "description": "Schedule an email to be sent at a future time.", "inputSchema": { "type": "object", "properties": { "to": {"type": "array", "items": {"type": "string"}}, "subject": {"type": "string"}, "body": {"type": "string"}, "cc": {"type": "array", "items": {"type": "string"}}, "bcc": {"type": "array", "items": {"type": "string"}}, "html": {"type": "boolean"}, "send_at": {"type": "number", "description": "Unix timestamp when to send"}, "in_reply_to": {"type": "string"}, "references": {"type": "string"} }, "required": ["to", "subject", "body", "send_at"] } }, { "name": "export_conversation", "description": "Export a conversation thread as plain text.", "inputSchema": { "type": "object", "properties": { "folder": {"type": "string"}, "message_uid": {"type": "integer"}, "max_messages": {"type": "integer", "description": "Max messages (default: 50)"} }, "required": ["folder", "message_uid"] } }, { "name": "conflict_check_search", "description": "Search for potential conflicts by terms (names, firms, counsel).", "inputSchema": { "type": "object", "properties": { "terms": {"type": "array", "items": {"type": "string"}}, "folders": {"type": "array", "items": {"type": "string"}}, "max_results": {"type": "integer", "description": "Max results (default: 100)"} }, "required": ["terms"] } }, { "name": "get_triage_config", "description": "Get triage configuration (business description and personnel).", "inputSchema": { "type": "object", "properties": {}, "required": [] } }, # CardDAV tools { "name": "list_carddav_addressbooks", "description": "List CardDAV addressbooks.", "inputSchema": {"type": "object", "properties": {}, "required": []} }, { "name": "search_carddav_contacts", "description": "Search CardDAV contacts by name.", "inputSchema": { "type": "object", "properties": { "addressbook_href": {"type": "string"}, "query": {"type": "string"} }, "required": ["addressbook_href", "query"] } }, { "name": "get_carddav_contact", "description": "Get a CardDAV contact by href.", "inputSchema": { "type": "object", "properties": {"href": {"type": "string"}}, "required": ["href"] } }, { "name": "create_carddav_contact", "description": "Create a CardDAV contact.", "inputSchema": { "type": "object", "properties": { "addressbook_href": {"type": "string"}, "vcard": {"type": "string"} }, "required": ["addressbook_href", "vcard"] } }, { "name": "update_carddav_contact", "description": "Update a CardDAV contact.", "inputSchema": { "type": "object", "properties": { "href": {"type": "string"}, "vcard": {"type": "string"} }, "required": ["href", "vcard"] } }, { "name": "delete_carddav_contact", "description": "Delete a CardDAV contact.", "inputSchema": { "type": "object", "properties": {"href": {"type": "string"}}, "required": ["href"] } }, # CalDAV tools { "name": "list_caldav_calendars", "description": "List CalDAV calendars.", "inputSchema": {"type": "object", "properties": {}, "required": []} }, { "name": "search_caldav_events", "description": "Search CalDAV events by time range (ISO8601).", "inputSchema": { "type": "object", "properties": { "calendar_href": {"type": "string"}, "start": {"type": "string", "description": "ISO8601 start, e.g. 20251201T000000Z"}, "end": {"type": "string", "description": "ISO8601 end, e.g. 20251231T235959Z"} }, "required": ["calendar_href", "start", "end"] } }, { "name": "create_caldav_event", "description": "Create a CalDAV event from iCal.", "inputSchema": { "type": "object", "properties": { "calendar_href": {"type": "string"}, "ical": {"type": "string"} }, "required": ["calendar_href", "ical"] } }, { "name": "update_caldav_event", "description": "Update a CalDAV event from iCal.", "inputSchema": { "type": "object", "properties": { "href": {"type": "string"}, "ical": {"type": "string"} }, "required": ["href", "ical"] } }, { "name": "delete_caldav_event", "description": "Delete a CalDAV event.", "inputSchema": { "type": "object", "properties": {"href": {"type": "string"}}, "required": ["href"] } }, ] # -------------------- MCP JSON-RPC HANDLER -------------------- initialized = False def mcp_error(rid, code, message): return { "jsonrpc": "2.0", "id": rid, "error": { "code": code, "message": message } } def handle_rpc(msg): jsonrpc = msg.get("jsonrpc") method = msg.get("method") params = msg.get("params", {}) rid = msg.get("id") if jsonrpc != "2.0": return mcp_error(rid, -32600, "Invalid Request") try: if method == "initialize": global initialized initialized = True return { "jsonrpc": "2.0", "id": rid, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, "serverInfo": { "name": "mcp-email-server", "version": "1.0.0" } } } if method == "tools/list": return { "jsonrpc": "2.0", "id": rid, "result": { "tools": TOOLS } } if method == "tools/call": tool_name = params.get("name") tool_args = params.get("arguments", {}) result = call_tool(tool_name, tool_args) return { "jsonrpc": "2.0", "id": rid, "result": { "content": [ {"type": "text", "text": json.dumps(result)} ] } } return mcp_error(rid, -32601, "Method not found") except Exception as e: logger.exception("MCP tool error") return mcp_error(rid, -32603, f"Internal error: {str(e)}") def call_tool(name, args): # IMAP tools (run in thread pool) def imap_op(fn): return executor.submit(fn, **args).result() if name == "list_folders": return imap_op(list_folders_impl) if name == "search_messages": return imap_op(search_messages_impl) if name == "get_new_messages": return imap_op(get_new_messages_impl) if name == "sync_seen": return imap_op(sync_seen_impl) if name == "mark_as_read": return imap_op(mark_as_read_impl) if name == "flag_message": return imap_op(flag_message_impl) if name == "move_message": return imap_op(move_message_impl) if name == "copy_message": return imap_op(copy_message_impl) if name == "apply_label": return imap_op(apply_label_impl) if name == "remove_label": return imap_op(remove_label_impl) if name == "list_labels": return imap_op(list_labels_impl) if name == "get_unread_summary": return imap_op(get_unread_summary_impl) if name == "reply_to_message": return imap_op(reply_to_message_impl) if name == "forward_message": return imap_op(forward_message_impl) if name == "save_draft": return imap_op(save_draft_impl) if name == "list_attachments": return imap_op(list_attachments_impl) if name == "download_attachment": return imap_op(download_attachment_impl) if name == "search_attachments": return imap_op(search_attachments_impl) if name == "export_conversation": return imap_op(export_conversation_impl) if name == "conflict_check_search": return imap_op(conflict_check_search_impl) # Non-IMAP tools if name == "send_email": return send_email_impl(args) if name == "schedule_send": return schedule_send_impl(args) if name == "get_triage_config": return get_triage_config_impl(args) # CardDAV if name == "list_carddav_addressbooks": return list_carddav_addressbooks_impl(args) if name == "search_carddav_contacts": return search_carddav_contacts_impl(args) if name == "get_carddav_contact": return get_carddav_contact_impl(args) if name == "create_carddav_contact": return create_carddav_contact_impl(args) if name == "update_carddav_contact": return update_carddav_contact_impl(args) if name == "delete_carddav_contact": return delete_carddav_contact_impl(args) # CalDAV if name == "list_caldav_calendars": return list_caldav_calendars_impl(args) if name == "search_caldav_events": return search_caldav_events_impl(args) if name == "create_caldav_event": return create_caldav_event_impl(args) if name == "update_caldav_event": return update_caldav_event_impl(args) if name == "delete_caldav_event": return delete_caldav_event_impl(args) raise ValueError(f"Unknown tool: {name}") # -------------------- TOOL IMPLEMENTATIONS -------------------- import concurrent.futures executor = concurrent.futures.ThreadPoolExecutor(max_workers=4) def list_folders_impl(): imap = create_imap() try: login_imap(imap) folders = list_all_folders(imap) return {"folders": folders} finally: imap.logout() def search_messages_impl(folder="INBOX", query="", since=None, max_results=50): imap = create_imap() try: login_imap(imap) results = search_messages(imap, folder=folder, query=query, since=since, max_results=max_results) return {"messages": results} finally: imap.logout() def get_new_messages_impl(folder="INBOX"): imap = create_imap() try: login_imap(imap) new = get_new_messages(imap, folder) out = [] for m in new: m["triage_hint"] = build_triage_hint(m) out.append(m) return {"messages": out} finally: imap.logout() def sync_seen_impl(folder="INBOX"): imap = create_imap() try: login_imap(imap) sync_seen(imap, folder) return {"status": "ok", "folder": folder} finally: imap.logout() def mark_as_read_impl(folder, uid): imap = create_imap() try: login_imap(imap) mark_as_read(imap, folder, uid) return {"status": "ok"} finally: imap.logout() def flag_message_impl(folder, uid): imap = create_imap() try: login_imap(imap) flag_message(imap, folder, uid) return {"status": "ok"} finally: imap.logout() def move_message_impl(folder, uid, dest_folder): imap = create_imap() try: login_imap(imap) move_message(imap, folder, uid, dest_folder) return {"status": "moved", "dest_folder": dest_folder} finally: imap.logout() def copy_message_impl(folder, uid, dest_folder): imap = create_imap() try: login_imap(imap) copy_message(imap, folder, uid, dest_folder) return {"status": "copied", "dest_folder": dest_folder} finally: imap.logout() def apply_label_impl(folder, uid, label): imap = create_imap() try: login_imap(imap) add_flags(imap, folder, uid, [f"$Label_{label}"]) return {"status": "applied", "label": label} finally: imap.logout() def remove_label_impl(folder, uid, label): imap = create_imap() try: login_imap(imap) remove_flags(imap, folder, uid, [f"$Label_{label}"]) return {"status": "removed", "label": label} finally: imap.logout() def list_labels_impl(folder="INBOX", uid=None): imap = create_imap() try: login_imap(imap) if uid: labels = get_labels(imap, folder, uid) return {"labels": [l.replace("$Label_", "", 1) for l in labels]} else: labels = list_available_labels(imap) return {"labels": labels} finally: imap.logout() def get_unread_summary_impl(folder="INBOX"): imap = create_imap() try: login_imap(imap) ensure_selected(imap, folder) status, data = imap.uid("SEARCH", None, "UNSEEN") if status != "OK" or not data or not data[0]: ids = [] else: ids = data[0].split() uids = [int(u) for u in ids] count = len(uids) previews = [] for uid in uids[:20]: msg = parse_message(imap, uid) previews.append({ "uid": uid, "subject": msg["subject"], "from": msg["from"], "date": msg["date"], "snippet": (msg["body_plain"] or "")[:300], }) return { "folder": folder, "unread_count": count, "previews": previews, } finally: imap.logout() def send_email_impl(args): try: send_email( to=args["to"], subject=args["subject"], body=args["body"], cc=args.get("cc"), bcc=args.get("bcc"), html=bool(args.get("html", False)), in_reply_to=args.get("in_reply_to"), references=args.get("references"), ) return {"status": "sent"} except Exception as e: raise RuntimeError(str(e)) def schedule_send_impl(args): task_id = add_scheduled_send( to=args["to"], subject=args["subject"], body=args["body"], cc=args.get("cc"), bcc=args.get("bcc"), html=bool(args.get("html", False)), send_at=float(args["send_at"]), in_reply_to=args.get("in_reply_to"), references=args.get("references"), ) return {"status": "scheduled", "task_id": task_id} def reply_to_message_impl(folder, uid, body, html=False, to=None, cc=None, bcc=None): imap = create_imap() try: login_imap(imap) msg = parse_message(imap, folder, uid) from email.utils import getaddresses if to: reply_to_list = to else: addrs = [] for _, addr in getaddresses([msg["to"], msg["from"]]): if addr: addrs.append(addr) reply_to_list = addrs subject = msg["subject"] if not subject.lower().startswith("re:"): subject = f"Re: {subject}" in_reply_to = msg.get("message_id") or None refs = (msg.get("references") or "").strip() if in_reply_to and refs: refs = f"{refs} {in_reply_to}" elif in_reply_to: refs = in_reply_to send_email( to=reply_to_list, subject=subject, body=body, cc=cc, bcc=bcc, html=html, in_reply_to=in_reply_to, references=refs, ) return {"status": "replied"} finally: imap.logout() def forward_message_impl(folder, uid, to, cc=None, bcc=None, note="", html=False, body=None): imap = create_imap() try: login_imap(imap) msg = parse_message(imap, folder, uid) subject = msg["subject"] if not subject.lower().startswith("fwd:"): subject = f"Fwd: {subject}" body = body or "" if note: body = f"{note}\n\n" + body original = msg["body_plain"] or (msg["body_html"] or "") if original: quoted = "\n".join("> " + line for line in original.splitlines()) body = (body + "\n\n" + quoted).strip() send_email( to=to, subject=subject, body=body, cc=cc, bcc=bcc, html=html, ) return {"status": "forwarded"} finally: imap.logout() def save_draft_impl(to, subject, body, html=False, folder="Drafts"): imap = create_imap() try: login_imap(imap) return save_draft_to_imap(imap, folder, to, subject, body, html) finally: imap.logout() def list_attachments_impl(folder, uid): imap = create_imap() try: login_imap(imap) atts = list_attachments(imap, folder, uid) return {"attachments": atts} finally: imap.logout() def download_attachment_impl(folder, uid, filename): imap = create_imap() try: login_imap(imap) info = download_attachment(imap, folder, uid, filename) return info except ValueError as e: raise RuntimeError(str(e)) finally: imap.logout() def search_attachments_impl(folder="INBOX", file_pattern="", max_results=50): imap = create_imap() try: login_imap(imap) results = search_attachments(imap, folder, file_pattern, max_results) return {"results": results} finally: imap.logout() def export_conversation_impl(folder, message_uid, max_messages=50): imap = create_imap() try: login_imap(imap) text = export_conversation(imap, folder, message_uid, max_messages) return {"export": text} finally: imap.logout() def conflict_check_search_impl(terms, folders=None, max_results=100): imap = create_imap() try: login_imap(imap) results = conflict_check_search(imap, terms, folders, max_results) return {"results": results} finally: imap.logout() def get_triage_config_impl(args): return { "business_description": config.BUSINESS_DESCRIPTION, "personnel": config.personnel, } # CardDAV tool impls def list_carddav_addressbooks_impl(args): abs_list = list_carddav_addressbooks() return {"addressbooks": abs_list} def search_carddav_contacts_impl(args): contacts = search_carddav_contacts(args["addressbook_href"], args["query"]) return {"contacts": contacts} def get_carddav_contact_impl(args): c = get_carddav_contact(args["href"]) return c def create_carddav_contact_impl(args): r = create_carddav_contact(args["addressbook_href"], args["vcard"]) return r def update_carddav_contact_impl(args): r = update_carddav_contact(args["href"], args["vcard"]) return r def delete_carddav_contact_impl(args): r = delete_carddav_contact(args["href"]) return r # CalDAV tool impls def list_caldav_calendars_impl(args): cal_list = list_caldav_calendars() return {"calendars": cal_list} def search_caldav_events_impl(args): events = search_caldav_events(args["calendar_href"], args["start"], args["end"]) return {"events": events} def create_caldav_event_impl(args): r = create_caldav_event(args["calendar_href"], args["ical"]) return r def update_caldav_event_impl(args): r = update_caldav_event(args["href"], args["ical"]) return r def delete_caldav_event_impl(args): r = delete_caldav_event(args["href"]) return r # -------------------- FASTAPI / MCP ENDPOINT -------------------- app = FastAPI( title="MCP Email Server", description="Email assistant MCP server exposing IMAP/SMTP, CalDAV/CardDAV, and conflict-check operations via MCP Streamable HTTP.", version="1.0.0", ) @app.exception_handler(Exception) async def global_exception_handler(request, exc): logger.exception("Unhandled exception") return JSONResponse( status_code=500, content={"detail": "Internal server error"} ) @app.post("/mcp") async def mcp_endpoint(request: Request): if request.method != "POST": raise HTTPException(status_code=405, detail="Method Not Allowed") try: body = await request.json() except Exception: return JSONResponse( status_code=400, content=mcp_error(None, -32700, "Parse error") ) if isinstance(body, list): results = [handle_rpc(msg) for msg in body] return JSONResponse(content=results) else: result = handle_rpc(body) return JSONResponse(content=result) # -------------------- ENTRYPOINT -------------------- if __name__ == "__main__": import uvicorn uvicorn.run("server:app", host="0.0.0.0", port=8000, reload=False)