2087 lines
64 KiB
Python
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("&", "&")
|
|
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"""<?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)
|