Files
mcp-email/server.py
T

2087 lines
64 KiB
Python

"""
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="""<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:resourcetype/>
<D:displayname/>
</D:prop>
</D:propfind>""")
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("&", "&amp;")
s = s.replace("<", "&lt;")
s = s.replace(">", "&gt;")
s = s.replace('"', "&quot;")
s = s.replace("'", "&apos;")
return s
def search_carddav_contacts(addressbook_href: str, query: str):
s = dav_session()
safe_query = _xml_escape(query)
req_body = f"""<?xml version="1.0"?>
<v:carddav xmlns:v="urn:ietf:params:xml:ns:carddav"
xmlns:D="DAV:">
<v:addressbook-query>
<v:filter>
<v:prop-filter name="FN">
<v:text-match>{safe_query}</v:text-match>
</v:prop-filter>
</v:filter>
</v:addressbook-query>
</v:carddav>"""
r = dav_request(s, "REPORT", addressbook_href,
headers={"Content-Type": "application/xml"},
data=req_body)
r.raise_for_status()
hrefs = re.findall(r'<D:href>(.*?)</D:href>', 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="""<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:resourcetype/>
<D:displayname/>
</D:prop>
</D:propfind>""")
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"""<?xml version="1.0"?>
<D:calendar-query xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="{start}" end="{end}"/>
</C:comp-filter>
</C:filter>
</C:filter>
</D:calendar-query>"""
r = dav_request(s, "REPORT", calendar_href,
headers={"Content-Type": "application/xml"},
data=req_body)
r.raise_for_status()
hrefs = re.findall(r'<D:href>(.*?)</D:href>', 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",
)
API_KEY = os.getenv("API_KEY", "").strip()
@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"}
)
def require_bearer(request: Request):
if not API_KEY:
return
auth = (request.headers.get("authorization") or "").strip()
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
token = auth[len("Bearer "):].strip()
if token != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
@app.get("/health")
async def health_check():
return {
"status": "ok",
"service": "mcp-email-server",
"version": "1.0.0"
}
@app.get("/mcp/openapi.json")
async def mcp_openapi_spec(request: Request):
require_bearer(request)
# Minimal OpenAPI 3.1 document for compatibility with clients
# that expect an OpenAPI spec at /mcp/openapi.json.
# Actual tool definitions are provided via MCP tools/list.
return {
"openapi": "3.1.0",
"info": {
"title": "MCP Email Server",
"version": "1.0.0",
"description": "Email assistant MCP server. Tools are exposed via MCP Streamable HTTP at POST /mcp."
},
"paths": {
"/mcp": {
"post": {
"summary": "MCP JSON-RPC endpoint",
"description": "Primary MCP endpoint (Streamable HTTP). Use JSON-RPC 2.0 with methods: initialize, tools/list, tools/call.",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"description": "JSON-RPC 2.0 request or batch of requests"
}
}
}
},
"responses": {
"200": {
"description": "JSON-RPC 2.0 response",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"400": {
"description": "Invalid JSON or JSON-RPC request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/health": {
"get": {
"summary": "Health check",
"responses": {
"200": {
"description": "Service is running",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {"type": "string"},
"service": {"type": "string"},
"version": {"type": "string"}
}
}
}
}
}
}
}
}
}
}
@app.post("/mcp")
async def mcp_endpoint(request: Request):
require_bearer(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)