diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6cf5f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.env diff --git a/README.md b/README.md index e6821e7..3f80ac6 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,15 @@ A Dockerized MCP (Model Context Protocol) email assistant for law firms and legal teams. Integrates: -- IMAP/SMTP email operations +- Multiple IMAP/SMTP email accounts - Shared/public folder support - New-message tracking and flagging - Conflict check search -- CalDAV/CardDAV (e.g., Nextcloud) for calendar and contacts - Triage guidance using personnel/chain-of-command Designed to be consumed by LLMs via MCP (Streamable HTTP) in Open WebUI. -## Quick start +## Quick start (single account) 1. Build: @@ -59,6 +58,90 @@ Designed to be consumed by LLMs via MCP (Streamable HTTP) in Open WebUI. - Key: your_api_key_here - Save, then enable tools in a chat. +## Multi-account configuration + +The server can manage multiple IMAP/SMTP accounts. Tools accept an optional "account" parameter; if omitted, the default account is used. + +You have two main options: + +1) Inline JSON (ACCOUNTS_JSON) + +Set ACCOUNTS_JSON with a JSON array of accounts. + +Copy-paste template (single line, for docker run): + +-e ACCOUNTS_JSON='[{"id":"account1","name":"Primary Firm Inbox","imap_host":"imap.primary.com","imap_port":993,"imap_use_ssl":true,"imap_username":"user@primary.com","imap_password":"CHANGE_ME_1","smtp_host":"smtp.primary.com","smtp_port":587,"smtp_use_tls":true,"smtp_use_ssl":false,"smtp_username":"user@primary.com","smtp_password":"CHANGE_ME_1","smtp_from":"user@primary.com"},{"id":"account2","name":"Secondary Inbox","imap_host":"imap.secondary.com","imap_port":993,"imap_use_ssl":true,"imap_username":"user@secondary.com","imap_password":"CHANGE_ME_2","smtp_host":"smtp.secondary.com","smtp_port":587,"smtp_use_tls":true,"smtp_use_ssl":false,"smtp_username":"user@secondary.com","smtp_password":"CHANGE_ME_2","smtp_from":"user@secondary.com"}]' + +Human-readable equivalent (for reference): + +ACCOUNTS_JSON='[ + { + "id": "account1", + "name": "Primary Firm Inbox", + "imap_host": "imap.primary.com", + "imap_port": 993, + "imap_use_ssl": true, + "imap_username": "user@primary.com", + "imap_password": "CHANGE_ME_1", + "smtp_host": "smtp.primary.com", + "smtp_port": 587, + "smtp_use_tls": true, + "smtp_use_ssl": false, + "smtp_username": "user@primary.com", + "smtp_password": "CHANGE_ME_1", + "smtp_from": "user@primary.com" + }, + { + "id": "account2", + "name": "Secondary Inbox", + "imap_host": "imap.secondary.com", + "imap_port": 993, + "imap_use_ssl": true, + "imap_username": "user@secondary.com", + "imap_password": "CHANGE_ME_2", + "smtp_host": "smtp.secondary.com", + "smtp_port": 587, + "smtp_use_tls": true, + "smtp_use_ssl": false, + "smtp_username": "user@secondary.com", + "smtp_password": "CHANGE_ME_2", + "smtp_from": "user@secondary.com" + } +]' + +2) JSON file (ACCOUNTS_CONFIG_PATH) + +Mount a JSON file and set ACCOUNTS_CONFIG_PATH. Example: + +docker run -d \ + -p 8000:8000 \ + -v /path/to/accounts.json:/etc/mcp-email/accounts.json:ro \ + -e API_KEY="your_api_key_here" \ + -e ACCOUNTS_CONFIG_PATH="/etc/mcp-email/accounts.json" \ + mcp-email-server + +Use the same structure as ACCOUNTS_JSON. + +Default account: + +- Use DEFAULT_ACCOUNT="firm_main" to set the default. +- If not set: + - If only one account exists, it is default. + - Otherwise, an account with id "default" is preferred, or the first one is used. + +Tool usage with multiple accounts: + +- Most tools accept an "account" field. Examples: + - list_folders: { "account": "firm_main" } + - search_messages: { "account": "secondary", "folder": "INBOX", "query": "conflict" } + - send_email: { "account": "firm_main", "to": ["client@example.com"], "subject": "...", "body": "..." } +- If "account" is omitted, the default account is used. + +A new tool is available: + +- list_accounts: + - Lists all configured accounts and shows which is default. + ## Environment variables See env.example for a full list. @@ -73,7 +156,7 @@ Core: - LOG_LEVEL: - e.g., INFO, DEBUG, ERROR (default: INFO) -IMAP (incoming mail): +Single-account IMAP (only if not using ACCOUNTS_JSON/ACCOUNTS_CONFIG_PATH): - IMAP_HOST - IMAP_PORT @@ -81,7 +164,7 @@ IMAP (incoming mail): - IMAP_USERNAME - IMAP_PASSWORD -SMTP (outgoing mail): +Single-account SMTP: - SMTP_HOST - SMTP_PORT @@ -114,17 +197,27 @@ Optional email: ] - SCHEDULED_SEND_INTERVAL: Seconds between checks for scheduled emails (default: 10). -CalDAV/CardDAV (e.g., Nextcloud): +HTML and signature: -- DAV_BASE_URL: Base DAV URL (e.g., https://cloud.example.com/remote.php/dav) -- DAV_USERNAME -- DAV_PASSWORD -- DAV_VERIFY_TLS: true/false (default: true) +- Emails (send_email, reply_to_message, forward_message, schedule_send) default to HTML (html=true) unless explicitly set to false. +- EMAIL_HTML_SIGNATURE: Optional HTML signature appended to HTML emails. + - Inline HTML example: + -e EMAIL_HTML_SIGNATURE='
Best regards,
Jane Doe
Associate
' + - Template file example: + - Mount the file: + -v /path/to/signature.html:/etc/mcp-email/signature.html:ro + - Set env: + -e EMAIL_HTML_SIGNATURE='/etc/mcp-email/signature.html' + If the value is an existing file path, its contents are used as the signature. ## MCP tools All tools are exposed via MCP (Streamable HTTP) at POST /mcp. +General: + +- list_accounts + Email: - list_folders @@ -151,24 +244,7 @@ Email: - conflict_check_search - get_triage_config -Contacts (CardDAV): - -- list_carddav_addressbooks -- search_carddav_contacts -- get_carddav_contact -- create_carddav_contact -- update_carddav_contact -- delete_carddav_contact - -Calendar (CalDAV): - -- list_caldav_calendars -- search_caldav_events -- create_caldav_event -- update_caldav_event -- delete_caldav_event - -## Notes +Notes: - IMAP operations use UIDs for stability. - Conflict check search scans subject lines across specified folders for given terms. diff --git a/server.py b/server.py index 4f0e9e4..879e540 100644 --- a/server.py +++ b/server.py @@ -2,21 +2,25 @@ MCP Email Server - MCP Streamable HTTP compatible with Open WebUI Features: -- IMAP (login) + SMTP (login) +- IMAP (login) + SMTP (login) for multiple accounts - 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 + +Logging: +- All logs are written to stdout (and stderr) so Docker captures them. +- Set LOG_LEVEL=DEBUG for full trace logs. """ import json import os +import sys import time import base64 import email @@ -26,54 +30,111 @@ import smtplib import logging import threading import uuid -import re -import xml.etree.ElementTree as ET -from dataclasses import dataclass +from contextvars import ContextVar +from dataclasses import dataclass, field 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", -) +# Correlation ID support +request_id_var: ContextVar[str] = ContextVar("request_id", default="") + + +def get_request_id() -> str: + return request_id_var.get() or "" + + +def log_context_prefix() -> str: + rid = get_request_id() + if rid: + return f"[req:{rid}] " + return "" + + +# -------------------- LOGGING (Docker-friendly) -------------------- + +log_level = os.getenv("LOG_LEVEL", "INFO").upper() + +root_logger = logging.getLogger() +root_logger.handlers.clear() +root_logger.setLevel(logging.DEBUG) + logger = logging.getLogger("mcp-email-server") +logger.setLevel(getattr(logging, log_level, logging.INFO)) + +handler = logging.StreamHandler(sys.stdout) +handler.setLevel(logging.DEBUG) +formatter = logging.Formatter( + "%(asctime)s %(levelname)s %(name)s [%(threadName)s] %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S%z", +) +handler.setFormatter(formatter) +logger.addHandler(handler) -# -------------------- CONFIG -------------------- +# -------------------- CONFIG (multi-account) -------------------- @dataclass -class EmailConfig: +class AccountConfig: + id: str + name: str # 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", "") + imap_host: str + imap_port: int + imap_use_ssl: bool + imap_username: str + imap_password: str # 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 + smtp_host: str + smtp_port: int + smtp_use_tls: bool + smtp_use_ssl: bool + smtp_username: str + smtp_password: str + smtp_from: str + + +@dataclass +class GlobalConfig: + # Accounts + accounts: Dict[str, AccountConfig] = field(default_factory=dict) + default_account_id: str = "" + + # Optional cc/bcc defaults (global) 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") + + # HTML signature (optional) + # Can be: + # - Raw HTML string + # - File path: if the value is an existing file path, its content is used as the signature. + EMAIL_HTML_SIGNATURE_RAW: str = os.getenv("EMAIL_HTML_SIGNATURE", "") + + EMAIL_HTML_SIGNATURE: str = "" + + @classmethod + def resolve_html_signature(cls, raw: str) -> str: + if not raw: + return "" + # If it looks like a path and file exists, treat as template path + try: + if os.path.isfile(raw): + with open(raw, "r", encoding="utf-8") as f: + return f.read().strip() + except Exception: + pass + # Otherwise, treat as inline HTML + return raw.strip() @property def personnel(self) -> List[Dict[str, Any]]: @@ -91,22 +152,151 @@ class EmailConfig: return [e.strip() for e in self.DEFAULT_BCC.split(",") if e.strip()] -config = EmailConfig() +def load_accounts_config() -> Dict[str, AccountConfig]: + """ + Load accounts from: + - ACCOUNTS_CONFIG_PATH (JSON file), or + - ACCOUNTS_JSON (inline JSON), or + - fallback to single account from existing env vars as id "default". + """ + path = os.getenv("ACCOUNTS_CONFIG_PATH", "").strip() + inline = os.getenv("ACCOUNTS_JSON", "").strip() + + raw = None + + if path: + try: + with open(path, "r", encoding="utf-8") as f: + raw = json.loads(f.read()) + except Exception as e: + logger.error(f"Error loading ACCOUNTS_CONFIG_PATH={path}: {e}") + elif inline: + try: + raw = json.loads(inline) + except Exception as e: + logger.error(f"Error parsing ACCOUNTS_JSON: {e}") + + if isinstance(raw, list): + accounts: Dict[str, AccountConfig] = {} + for a in raw: + acc = parse_account(a) + accounts[acc.id] = acc + return accounts + + if isinstance(raw, dict) and "accounts" in raw: + accounts: Dict[str, AccountConfig] = {} + for a in raw["accounts"]: + acc = parse_account(a) + accounts[acc.id] = acc + return accounts + + # Fallback: single account from environment variables + return { + "default": build_fallback_account() + } -# -------------------- IMAP HELPERS (UID-based) -------------------- +def parse_account(a: Dict[str, Any]) -> AccountConfig: + # Allow flexible fields; fall back to sensible defaults + def boolify(v): + return str(v).lower() in ("true", "1", "yes") -def create_imap(): - if config.IMAP_USE_SSL: - return imaplib.IMAP4_SSL(config.IMAP_HOST, config.IMAP_PORT) + return AccountConfig( + id=str(a.get("id") or a.get("account_id") or "default"), + name=str(a.get("name") or a.get("id") or a.get("account_id") or ""), + imap_host=str(a.get("imap_host") or a.get("IMAP_HOST") or "imap.example.com"), + imap_port=int(a.get("imap_port") or a.get("IMAP_PORT") or 993), + imap_use_ssl=boolify(a.get("imap_use_ssl") or a.get("IMAP_USE_SSL") or True), + imap_username=str(a.get("imap_username") or a.get("IMAP_USERNAME") or ""), + imap_password=str(a.get("imap_password") or a.get("IMAP_PASSWORD") or ""), + smtp_host=str(a.get("smtp_host") or a.get("SMTP_HOST") or "smtp.example.com"), + smtp_port=int(a.get("smtp_port") or a.get("SMTP_PORT") or 587), + smtp_use_tls=boolify(a.get("smtp_use_tls") or a.get("SMTP_USE_TLS") or True), + smtp_use_ssl=boolify(a.get("smtp_use_ssl") or a.get("SMTP_USE_SSL") or False), + smtp_username=str(a.get("smtp_username") or a.get("SMTP_USERNAME") or ""), + smtp_password=str(a.get("smtp_password") or a.get("SMTP_PASSWORD") or ""), + smtp_from=str(a.get("smtp_from") or a.get("SMTP_FROM") or ""), + ) + + +def build_fallback_account() -> AccountConfig: + return AccountConfig( + id="default", + name="default", + imap_host=os.getenv("IMAP_HOST", "imap.example.com"), + imap_port=int(os.getenv("IMAP_PORT", "993")), + imap_use_ssl=os.getenv("IMAP_USE_SSL", "true").lower() in ("true", "1", "yes"), + imap_username=os.getenv("IMAP_USERNAME", ""), + imap_password=os.getenv("IMAP_PASSWORD", ""), + smtp_host=os.getenv("SMTP_HOST", "smtp.example.com"), + smtp_port=int(os.getenv("SMTP_PORT", "587")), + smtp_use_tls=os.getenv("SMTP_USE_TLS", "true").lower() in ("true", "1", "yes"), + smtp_use_ssl=os.getenv("SMTP_USE_SSL", "false").lower() in ("true", "1", "yes"), + smtp_username=os.getenv("SMTP_USERNAME", ""), + smtp_password=os.getenv("SMTP_PASSWORD", ""), + smtp_from=os.getenv("SMTP_FROM", ""), + ) + + +def resolve_default_account_id(accounts: Dict[str, AccountConfig]) -> str: + explicit = os.getenv("DEFAULT_ACCOUNT", "").strip() + if explicit and explicit in accounts: + return explicit + if len(accounts) == 1: + return next(iter(accounts)) + # Prefer "default" if present + if "default" in accounts: + return "default" + # Fallback: first + return next(iter(accounts)) + + +# Build global config +accounts_map = load_accounts_config() +default_account_id = resolve_default_account_id(accounts_map) + +config = GlobalConfig( + accounts=accounts_map, + default_account_id=default_account_id, +) + +# Resolve EMAIL_HTML_SIGNATURE (inline or file path) +config.EMAIL_HTML_SIGNATURE = GlobalConfig.resolve_html_signature(config.EMAIL_HTML_SIGNATURE_RAW) + +logger.info(f"Loaded {len(accounts_map)} account(s): {list(accounts_map.keys())}. Default: {default_account_id}") + + +def get_account(account_id: Optional[str] = None) -> AccountConfig: + aid = (account_id or config.default_account_id).strip() + if not aid: + aid = config.default_account_id + if aid not in config.accounts: + raise ValueError(f"Account not found: {aid}. Available: {list(config.accounts.keys())}") + return config.accounts[aid] + + +# -------------------- IMAP HELPERS (UID-based, per-account) -------------------- + +def create_imap(acc: AccountConfig): + prefix = log_context_prefix() + logger.info(f"{prefix}IMAP create connection: {acc.imap_host}:{acc.imap_port} ssl={acc.imap_use_ssl} account={acc.id}") + if acc.imap_use_ssl: + m = imaplib.IMAP4_SSL(acc.imap_host, acc.imap_port) else: - m = imaplib.IMAP4(config.IMAP_HOST, config.IMAP_PORT) + m = imaplib.IMAP4(acc.imap_host, acc.imap_port) m.starttls() - return m + return m -def login_imap(imap): - imap.login(config.IMAP_USERNAME, config.IMAP_PASSWORD) +def login_imap(imap, acc: AccountConfig): + prefix = log_context_prefix() + logger.info(f"{prefix}IMAP login user={acc.imap_username} account={acc.id}") + try: + imap.login(acc.imap_username, acc.imap_password) + logger.debug(f"{prefix}IMAP login success user={acc.imap_username} account={acc.id}") + except Exception as e: + logger.error(f"{prefix}IMAP login failed user={acc.imap_username} account={acc.id}: {e}") + raise def list_all_folders(imap): @@ -123,12 +313,17 @@ def list_all_folders(imap): def ensure_selected(imap, folder: str): + prefix = log_context_prefix() + logger.debug(f"{prefix}IMAP select folder={folder}") imap.select(folder, readonly=False) def parse_message(imap, uid: int): + prefix = log_context_prefix() + logger.debug(f"{prefix}IMAP fetch message uid={uid}") status, msg_data = imap.uid("FETCH", str(uid), "(RFC822)") if status != "OK" or not msg_data or msg_data[0] is None: + logger.error(f"{prefix}IMAP fetch failed for uid={uid}") raise RuntimeError(f"Failed to fetch message UID {uid}") msg = email.message_from_bytes(msg_data[0][1], policy=email.policy.default) @@ -195,6 +390,8 @@ def parse_message(imap, uid: int): def search_messages(imap, folder: str, query: str, since: Optional[str], max_results: int): + prefix = log_context_prefix() + logger.info(f"{prefix}IMAP search folder={folder} query={query} since={since} max_results={max_results}") ensure_selected(imap, folder) criteria = [] @@ -207,8 +404,10 @@ def search_messages(imap, folder: str, query: str, since: Optional[str], max_res criteria.append(f"SINCE {since}") search_str = " ".join(criteria) + logger.debug(f"{prefix}IMAP search criteria: {search_str}") status, data = imap.uid("SEARCH", None, search_str) if status != "OK" or not data or not data[0]: + logger.debug(f"{prefix}IMAP search returned no results for folder={folder}") return [] ids = (data[0].split() if data[0] else []) @@ -306,6 +505,7 @@ def list_available_labels(imap): # -------------------- SMTP HELPERS -------------------- def send_email( + acc: AccountConfig, to: List[str], subject: str, body: str, @@ -315,8 +515,9 @@ def send_email( in_reply_to: Optional[str] = None, references: Optional[str] = None, ): - if not config.SMTP_FROM: - raise ValueError("SMTP_FROM not configured") + prefix = log_context_prefix() + if not acc.smtp_from: + raise ValueError("SMTP_FROM not configured for account") if not cc: cc = [] @@ -327,6 +528,7 @@ def send_email( bcc = list(set(bcc + config.default_bcc_list)) all_recipients = to + cc + bcc + logger.info(f"{prefix}SMTP send_email from={acc.smtp_from} to={to} cc={cc} bcc={bcc} account={acc.id}") from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart @@ -337,28 +539,36 @@ def send_email( else: m = MIMEText(body, "plain") - m["From"] = config.SMTP_FROM + m["From"] = acc.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]}>" + domain = acc.smtp_from.split("@")[1] if "@" in acc.smtp_from else "example.com" + m["Message-ID"] = f"<{uuid.uuid4().hex}@{domain}>" 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() + try: + if acc.smtp_use_ssl: + s = smtplib.SMTP_SSL(acc.smtp_host, acc.smtp_port) + else: + s = smtplib.SMTP(acc.smtp_host, acc.smtp_port) + if acc.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() + logger.info(f"{prefix}SMTP connect host={acc.smtp_host} port={acc.smtp_port} account={acc.id}") + s.login(acc.smtp_username, acc.smtp_password) + logger.info(f"{prefix}SMTP login success user={acc.smtp_username} account={acc.id}") + s.sendmail(acc.smtp_from, all_recipients, m.as_string()) + logger.info(f"{prefix}SMTP send success to={all_recipients} account={acc.id}") + s.quit() + except Exception as e: + logger.error(f"{prefix}SMTP send failed: {e}") + raise # -------------------- NEW-MESSAGE TRACKING -------------------- @@ -432,6 +642,8 @@ def build_triage_hint(msg: Dict[str, Any]) -> str: # -------------------- CONFLICT CHECK SEARCH -------------------- def conflict_check_search(imap, terms: List[str], folders: Optional[List[str]] = None, max_results: int = 100): + prefix = log_context_prefix() + logger.info(f"{prefix}conflict_check_search terms={terms} folders={folders} max_results={max_results}") if not folders: folders = ["INBOX"] @@ -440,6 +652,7 @@ def conflict_check_search(imap, terms: List[str], folders: Optional[List[str]] = try: ensure_selected(imap, folder) except Exception: + logger.warning(f"{prefix}conflict_check_search cannot select folder={folder}") continue for term in terms: @@ -463,7 +676,7 @@ def conflict_check_search(imap, terms: List[str], folders: Optional[List[str]] = "snippet": (msg["body_plain"] or "")[:400], }) except Exception as e: - logger.error(f"Conflict search error for term '{term}' in {folder}: {e}") + logger.error(f"{prefix}Conflict search error for term '{term}' in {folder}: {e}") seen = set() unique = [] @@ -566,15 +779,16 @@ def search_attachments(imap, folder: str, file_pattern: str, max_results: int): # -------------------- DRAFTS (IMAP Drafts folder) -------------------- -def save_draft_to_imap(imap, folder: str, to: List[str], subject: str, body: str, html: bool = False): +def save_draft_to_imap(imap, acc: AccountConfig, 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["From"] = acc.smtp_from m["To"] = ", ".join(to) m["Subject"] = subject - m["Message-ID"] = f"<{uuid.uuid4().hex}@{config.SMTP_FROM.split('@')[1]}>" + domain = acc.smtp_from.split("@")[1] if "@" in acc.smtp_from else "example.com" + m["Message-ID"] = f"<{uuid.uuid4().hex}@{domain}>" m.attach(MIMEText(body, "html" if html else "plain")) ensure_selected(imap, folder) @@ -588,10 +802,11 @@ 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): +def add_scheduled_send(account_id, to, subject, body, cc, bcc, html, send_at, in_reply_to, references): task_id = uuid.uuid4().hex with schedule_lock: scheduled_sends[task_id] = { + "account_id": account_id, "to": to, "subject": subject, "body": body, @@ -606,17 +821,23 @@ def add_scheduled_send(to, subject, body, cc, bcc, html, send_at, in_reply_to, r def scheduled_send_loop(): + logger.info("Scheduled send loop started") while True: now = time.time() with schedule_lock: ready = [tid for tid, t in scheduled_sends.items() if t["send_at"] <= now] + if ready: + logger.info(f"Scheduled send loop: {len(ready)} messages ready to send") for tid in ready: with schedule_lock: t = scheduled_sends.pop(tid, None) if not t: continue + logger.info(f"Scheduled send: task_id={tid} to={t['to']} subject={t['subject'][:100]} account={t['account_id']}") try: + acc = get_account(t["account_id"]) send_email( + acc=acc, to=t["to"], subject=t["subject"], body=t["body"], @@ -626,6 +847,7 @@ def scheduled_send_loop(): in_reply_to=t.get("in_reply_to"), references=t.get("references"), ) + logger.info(f"Scheduled send: task_id={tid} sent successfully") except Exception as e: logger.error(f"Scheduled send error for {tid}: {e}") time.sleep(config.SCHEDULED_SEND_INTERVAL) @@ -694,392 +916,36 @@ def export_conversation(imap, folder: str, message_uid: int, max_messages: int): 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 - - -# CalDAV/CardDAV helpers - -DAV_NS = {"D": "DAV:"} -CAL_NS = {"CAL": "urn:ietf:params:xml:ns:caldav"} -CARD_NS = {"CARD": "urn:ietf:params:xml:ns:carddav"} - - -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 - - -def get_user_principal(service="caldav"): - """ - Discover the user's principal URL using /.well-known/{service} redirect - and then PROPFIND for current-user-principal. - """ - base = config.DAV_BASE_URL.rstrip("/") - well_known = base + f"/.well-known/{service}" - s = dav_session() - - # First: GET /.well-known/caldav (or carddav) to get the redirect URL - r = dav_request(s, "GET", well_known) - r.raise_for_status() - dave_url = r.url # after redirects - - # Then: PROPFIND on that URL to find current-user-principal - r = dav_request(s, "PROPFIND", dave_url, - headers={"Depth": "0", "Content-Type": "application/xml"}, - data=""" - - - """) - r.raise_for_status() - entries = parse_dav_propfind(r.text) - for e in entries: - principal = e.get("props", {}).get("current-user-principal", "") - if principal: - return principal - raise RuntimeError("Could not find user principal") - - -# CardDAV helpers - -def list_carddav_addressbooks(): - """ - Discover addressbooks via the user principal and addressbook-home-set. - """ - s = dav_session() - principal = get_user_principal("carddav") - - # PROPFIND on principal to get addressbook-home-set - r = dav_request(s, "PROPFIND", principal, - headers={"Depth": "0", "Content-Type": "application/xml"}, - data=""" - - - - - """) - r.raise_for_status() - entries = parse_dav_propfind(r.text) - home_set = None - for e in entries: - val = e.get("props", {}).get("addressbook-home-set", "") - if val: - home_set = val - break - if not home_set: - return [] - - # PROPFIND on addressbook-home-set to list addressbooks - r = dav_request(s, "PROPFIND", home_set, - headers={"Depth": "1", "Content-Type": "application/xml"}, - data=""" - - - - - - """) - r.raise_for_status() - entries = parse_dav_propfind(r.text) - addressbooks = [] - for e in entries: - href = e["href"] - props = e.get("props", {}) - rt = props.get("resourcetype", "") - if href.endswith("/") and ("addressbook" in rt.lower() or "addressbook" in href.lower()): - addressbooks.append({ - "href": href, - "name": props.get("displayname", href), - }) - return addressbooks - - -def _xml_escape(s: str) -> str: - s = s.replace("&", "&") - s = s.replace("<", "<") - s = s.replace(">", ">") - s = s.replace('"', """) - s = s.replace("'", "'") - return s - - -def search_carddav_contacts(addressbook_href: str, query: str): - s = dav_session() - safe_query = _xml_escape(query) - req_body = f""" - - - - {safe_query} - - - """ - r = dav_request(s, "REPORT", addressbook_href, - headers={"Content-Type": "application/xml"}, - data=req_body) - r.raise_for_status() - hrefs = re.findall(r'(.*?)', r.text, re.DOTALL) - contacts = [] - for href in hrefs: - href = href.strip() - if not href: - continue - try: - vr = dav_request(s, "GET", href) - vr.raise_for_status() - contacts.append({ - "href": href, - "vcard": vr.text, - }) - except Exception as e: - logger.error(f"Error fetching vCard {href}: {e}") - return contacts - - -def get_carddav_contact(href: str): - s = dav_session() - r = dav_request(s, "GET", href) - r.raise_for_status() - return {"href": href, "vcard": r.text} - - -def create_carddav_contact(addressbook_href: str, vcard: str): - s = dav_session() - filename = f"{uuid.uuid4().hex}.vcf" - url = addressbook_href.rstrip("/") + "/" + filename - r = dav_request(s, "PUT", url, data=vcard.encode("utf-8"), - headers={"Content-Type": "text/vcard"}) - r.raise_for_status() - return {"href": url} - - -def update_carddav_contact(href: str, vcard: str): - s = dav_session() - r = dav_request(s, "PUT", href, data=vcard.encode("utf-8"), - headers={"Content-Type": "text/vcard"}) - r.raise_for_status() - return {"href": href} - - -def delete_carddav_contact(href: str): - s = dav_session() - r = dav_request(s, "DELETE", href) - r.raise_for_status() - return {"href": href} - - -# CalDAV helpers - -def list_caldav_calendars(): - """ - Discover calendars via the user principal and calendar-home-set. - """ - s = dav_session() - principal = get_user_principal("caldav") - - # PROPFIND on principal to get calendar-home-set - r = dav_request(s, "PROPFIND", principal, - headers={"Depth": "0", "Content-Type": "application/xml"}, - data=""" - - - - - """) - r.raise_for_status() - entries = parse_dav_propfind(r.text) - home_set = None - for e in entries: - val = e.get("props", {}).get("calendar-home-set", "") - if val: - home_set = val - break - if not home_set: - return [] - - # PROPFIND on calendar-home-set to list calendars - r = dav_request(s, "PROPFIND", home_set, - headers={"Depth": "1", "Content-Type": "application/xml"}, - data=""" - - - - - - """) - r.raise_for_status() - entries = parse_dav_propfind(r.text) - calendars = [] - for e in entries: - href = e["href"] - props = e.get("props", {}) - rt = props.get("resourcetype", "") - if href.endswith("/") and ("calendar" in rt.lower() or "calendar" in href.lower()): - calendars.append({ - "href": href, - "name": props.get("displayname", href.split("/")[-2]), - }) - return calendars - - -def search_caldav_events(calendar_href: str, start: str, end: str): - s = dav_session() - req_body = f""" - - - - - - - - - - - - """ - r = dav_request(s, "REPORT", calendar_href, - headers={"Content-Type": "application/xml"}, - data=req_body) - r.raise_for_status() - hrefs = re.findall(r'(.*?)', r.text, re.DOTALL) - events = [] - for href in hrefs: - href = href.strip() - if not href: - continue - try: - vr = dav_request(s, "GET", href) - vr.raise_for_status() - events.append({ - "href": href, - "ical": vr.text, - }) - except Exception as e: - logger.error(f"Error fetching event {href}: {e}") - return events - - -def create_caldav_event(calendar_href: str, ical: str): - s = dav_session() - filename = f"{uuid.uuid4().hex}.ics" - url = calendar_href.rstrip("/") + "/" + filename - r = dav_request(s, "PUT", url, data=ical.encode("utf-8"), - headers={"Content-Type": "text/calendar"}) - r.raise_for_status() - return {"href": url} - - -def update_caldav_event(href: str, ical: str): - s = dav_session() - r = dav_request(s, "PUT", href, data=ical.encode("utf-8"), - headers={"Content-Type": "text/calendar"}) - r.raise_for_status() - return {"href": href} - - -def delete_caldav_event(href: str): - s = dav_session() - r = dav_request(s, "DELETE", href) - r.raise_for_status() - return {"href": href} - - # -------------------- MCP TOOL DEFINITIONS -------------------- TOOLS = [ { - "name": "list_folders", - "description": "List all IMAP folders.", + "name": "list_accounts", + "description": "List all configured email accounts.", "inputSchema": { "type": "object", "properties": {}, "required": [] } }, + { + "name": "list_folders", + "description": "List all IMAP folders for an account.", + "inputSchema": { + "type": "object", + "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"} + }, + "required": [] + } + }, { "name": "search_messages", "description": "Search messages in a folder by subject and optional date.", "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "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'"}, @@ -1094,6 +960,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"} }, "required": [] @@ -1105,6 +972,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"} }, "required": [] @@ -1116,6 +984,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string"}, "uid": {"type": "integer"} }, @@ -1128,6 +997,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string"}, "uid": {"type": "integer"} }, @@ -1140,6 +1010,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string"}, "uid": {"type": "integer"}, "dest_folder": {"type": "string"} @@ -1153,6 +1024,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string"}, "uid": {"type": "integer"}, "dest_folder": {"type": "string"} @@ -1166,6 +1038,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string"}, "uid": {"type": "integer"}, "label": {"type": "string", "description": "Label name (without prefix)"} @@ -1179,6 +1052,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string"}, "uid": {"type": "integer"}, "label": {"type": "string", "description": "Label name (without prefix)"} @@ -1192,6 +1066,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "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."} }, @@ -1204,6 +1079,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string", "description": "IMAP folder (default: INBOX)"} }, "required": [] @@ -1211,10 +1087,11 @@ TOOLS = [ }, { "name": "send_email", - "description": "Send an email.", + "description": "Send an email using a specific account (or default).", "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID to send from (optional, uses default if omitted)"}, "to": {"type": "array", "items": {"type": "string"}}, "subject": {"type": "string"}, "body": {"type": "string"}, @@ -1229,10 +1106,11 @@ TOOLS = [ }, { "name": "reply_to_message", - "description": "Reply to a specific message.", + "description": "Reply to a specific message using the same account.", "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string"}, "uid": {"type": "integer"}, "body": {"type": "string"}, @@ -1250,6 +1128,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string"}, "uid": {"type": "integer"}, "to": {"type": "array", "items": {"type": "string"}}, @@ -1267,6 +1146,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "to": {"type": "array", "items": {"type": "string"}}, "subject": {"type": "string"}, "body": {"type": "string"}, @@ -1282,6 +1162,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string"}, "uid": {"type": "integer"} }, @@ -1294,6 +1175,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string"}, "uid": {"type": "integer"}, "filename": {"type": "string"} @@ -1307,6 +1189,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "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)"} @@ -1316,10 +1199,11 @@ TOOLS = [ }, { "name": "schedule_send", - "description": "Schedule an email to be sent at a future time.", + "description": "Schedule an email to be sent at a future time using a specific account (or default).", "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "to": {"type": "array", "items": {"type": "string"}}, "subject": {"type": "string"}, "body": {"type": "string"}, @@ -1339,6 +1223,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "folder": {"type": "string"}, "message_uid": {"type": "integer"}, "max_messages": {"type": "integer", "description": "Max messages (default: 50)"} @@ -1352,6 +1237,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { + "account": {"type": "string", "description": "Account ID (optional, uses default if omitted)"}, "terms": {"type": "array", "items": {"type": "string"}}, "folders": {"type": "array", "items": {"type": "string"}}, "max_results": {"type": "integer", "description": "Max results (default: 100)"} @@ -1368,118 +1254,6 @@ TOOLS = [ "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"] - } - }, ] @@ -1504,14 +1278,19 @@ def handle_rpc(msg): method = msg.get("method") params = msg.get("params", {}) rid = msg.get("id") + prefix = log_context_prefix() if jsonrpc != "2.0": + logger.info(f"{prefix}MCP invalid jsonrpc: {jsonrpc}") return mcp_error(rid, -32600, "Invalid Request") + logger.debug(f"{prefix}MCP RPC method={method} params_keys={list(params.keys())}") + try: if method == "initialize": global initialized initialized = True + logger.info(f"{prefix}MCP: initialize") return { "jsonrpc": "2.0", "id": rid, @@ -1528,6 +1307,7 @@ def handle_rpc(msg): } if method == "tools/list": + logger.info(f"{prefix}MCP: tools/list") return { "jsonrpc": "2.0", "id": rid, @@ -1539,6 +1319,10 @@ def handle_rpc(msg): if method == "tools/call": tool_name = params.get("name") tool_args = params.get("arguments", {}) + logger.info(f"{prefix}MCP: tools/call name={tool_name} args_keys={list(tool_args.keys())}") + if logger.isEnabledFor(logging.DEBUG): + safe_args = {k: (str(v)[:400] if isinstance(v, str) else v) for k, v in tool_args.items()} + logger.debug(f"{prefix}MCP: tools/call args={json.dumps(safe_args, default=str)}") result = call_tool(tool_name, tool_args) return { "jsonrpc": "2.0", @@ -1550,17 +1334,32 @@ def handle_rpc(msg): } } + logger.warning(f"{prefix}MCP: unknown method={method}") return mcp_error(rid, -32601, "Method not found") except Exception as e: - logger.exception("MCP tool error") + logger.exception(f"{prefix}MCP tool error for method={method}") return mcp_error(rid, -32603, f"Internal error: {str(e)}") def call_tool(name, args): + prefix = log_context_prefix() + logger.info(f"{prefix}call_tool name={name}") + # IMAP tools (run in thread pool) def imap_op(fn): - return executor.submit(fn, **args).result() + logger.info(f"{prefix}IMAP operation: {fn.__name__}") + try: + result = executor.submit(fn, **args).result() + logger.debug(f"{prefix}IMAP operation {fn.__name__} completed successfully") + return result + except Exception as e: + logger.error(f"{prefix}IMAP operation {fn.__name__} failed: {e}") + raise + + if name == "list_accounts": + logger.info(f"{prefix}call_tool list_accounts") + return list_accounts_impl(args) if name == "list_folders": return imap_op(list_folders_impl) @@ -1624,49 +1423,17 @@ def call_tool(name, args): # Non-IMAP tools if name == "send_email": + logger.info(f"{prefix}call_tool send_email") return send_email_impl(args) if name == "schedule_send": + logger.info(f"{prefix}call_tool schedule_send") return schedule_send_impl(args) if name == "get_triage_config": + logger.info(f"{prefix}call_tool 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}") @@ -1677,30 +1444,49 @@ import concurrent.futures executor = concurrent.futures.ThreadPoolExecutor(max_workers=4) -def list_folders_impl(): - imap = create_imap() +def list_accounts_impl(args): + accounts = [] + for aid, acc in config.accounts.items(): + accounts.append({ + "id": acc.id, + "name": acc.name or acc.id, + "imap_username": acc.imap_username, + "smtp_from": acc.smtp_from, + "is_default": aid == config.default_account_id, + }) + return { + "accounts": accounts, + "default_account": config.default_account_id, + } + + +def list_folders_impl(account=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) 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() +def search_messages_impl(account=None, folder="INBOX", query="", since=None, max_results=50): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) 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() +def get_new_messages_impl(account=None, folder="INBOX"): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) new = get_new_messages(imap, folder) out = [] for m in new: @@ -1711,80 +1497,88 @@ def get_new_messages_impl(folder="INBOX"): imap.logout() -def sync_seen_impl(folder="INBOX"): - imap = create_imap() +def sync_seen_impl(account=None, folder="INBOX"): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) sync_seen(imap, folder) return {"status": "ok", "folder": folder} finally: imap.logout() -def mark_as_read_impl(folder, uid): - imap = create_imap() +def mark_as_read_impl(account=None, folder=None, uid=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) mark_as_read(imap, folder, uid) return {"status": "ok"} finally: imap.logout() -def flag_message_impl(folder, uid): - imap = create_imap() +def flag_message_impl(account=None, folder=None, uid=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) flag_message(imap, folder, uid) return {"status": "ok"} finally: imap.logout() -def move_message_impl(folder, uid, dest_folder): - imap = create_imap() +def move_message_impl(account=None, folder=None, uid=None, dest_folder=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) 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() +def copy_message_impl(account=None, folder=None, uid=None, dest_folder=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) 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() +def apply_label_impl(account=None, folder=None, uid=None, label=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) 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() +def remove_label_impl(account=None, folder=None, uid=None, label=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) 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() +def list_labels_impl(account=None, folder="INBOX", uid=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) if uid: labels = get_labels(imap, folder, uid) return {"labels": [l.replace("$Label_", "", 1) for l in labels]} @@ -1795,10 +1589,11 @@ def list_labels_impl(folder="INBOX", uid=None): imap.logout() -def get_unread_summary_impl(folder="INBOX"): - imap = create_imap() +def get_unread_summary_impl(account=None, folder="INBOX"): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) ensure_selected(imap, folder) status, data = imap.uid("SEARCH", None, "UNSEEN") if status != "OK" or not data or not data[0]: @@ -1827,41 +1622,67 @@ def get_unread_summary_impl(folder="INBOX"): def send_email_impl(args): + account_id = args.get("account") or config.default_account_id + acc = get_account(account_id) + + # Default to HTML unless explicitly set to false + html = args.get("html") + if html is None: + html = True + + body = args["body"] + if html and config.EMAIL_HTML_SIGNATURE: + body = body.rstrip("\n") + "\n" + config.EMAIL_HTML_SIGNATURE + try: send_email( + acc=acc, to=args["to"], subject=args["subject"], - body=args["body"], + body=body, cc=args.get("cc"), bcc=args.get("bcc"), - html=bool(args.get("html", False)), + html=bool(html), in_reply_to=args.get("in_reply_to"), references=args.get("references"), ) - return {"status": "sent"} + return {"status": "sent", "account": acc.id} except Exception as e: raise RuntimeError(str(e)) def schedule_send_impl(args): + account_id = args.get("account") or config.default_account_id + + # Default to HTML unless explicitly set to false + html = args.get("html") + if html is None: + html = True + + body = args["body"] + if html and config.EMAIL_HTML_SIGNATURE: + body = body.rstrip("\n") + "\n" + config.EMAIL_HTML_SIGNATURE + task_id = add_scheduled_send( + account_id=account_id, to=args["to"], subject=args["subject"], - body=args["body"], + body=body, cc=args.get("cc"), bcc=args.get("bcc"), - html=bool(args.get("html", False)), + html=bool(html), 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} + return {"status": "scheduled", "task_id": task_id, "account": account_id} -def reply_to_message_impl(folder, uid, body, html=False, to=None, cc=None, bcc=None): - imap = create_imap() +def reply_to_message_impl(account=None, folder=None, uid=None, body=None, html=None, to=None, cc=None, bcc=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) msg = parse_message(imap, folder, uid) from email.utils import getaddresses @@ -1874,6 +1695,10 @@ def reply_to_message_impl(folder, uid, body, html=False, to=None, cc=None, bcc=N addrs.append(addr) reply_to_list = addrs + # Default to HTML unless explicitly set to false + if html is None: + html = True + subject = msg["subject"] if not subject.lower().startswith("re:"): subject = f"Re: {subject}" @@ -1885,7 +1710,12 @@ def reply_to_message_impl(folder, uid, body, html=False, to=None, cc=None, bcc=N elif in_reply_to: refs = in_reply_to + # Append HTML signature if sending as HTML + if html and config.EMAIL_HTML_SIGNATURE: + body = (body or "").rstrip("\n") + "\n" + config.EMAIL_HTML_SIGNATURE + send_email( + acc=acc, to=reply_to_list, subject=subject, body=body, @@ -1895,17 +1725,22 @@ def reply_to_message_impl(folder, uid, body, html=False, to=None, cc=None, bcc=N in_reply_to=in_reply_to, references=refs, ) - return {"status": "replied"} + return {"status": "replied", "account": acc.id} finally: imap.logout() -def forward_message_impl(folder, uid, to, cc=None, bcc=None, note="", html=False, body=None): - imap = create_imap() +def forward_message_impl(account=None, folder=None, uid=None, to=None, cc=None, bcc=None, note="", html=None, body=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) msg = parse_message(imap, folder, uid) + # Default to HTML unless explicitly set to false + if html is None: + html = True + subject = msg["subject"] if not subject.lower().startswith("fwd:"): subject = f"Fwd: {subject}" @@ -1919,7 +1754,12 @@ def forward_message_impl(folder, uid, to, cc=None, bcc=None, note="", html=False quoted = "\n".join("> " + line for line in original.splitlines()) body = (body + "\n\n" + quoted).strip() + # Append HTML signature if sending as HTML + if html and config.EMAIL_HTML_SIGNATURE: + body = body.rstrip("\n") + "\n" + config.EMAIL_HTML_SIGNATURE + send_email( + acc=acc, to=to, subject=subject, body=body, @@ -1927,34 +1767,37 @@ def forward_message_impl(folder, uid, to, cc=None, bcc=None, note="", html=False bcc=bcc, html=html, ) - return {"status": "forwarded"} + return {"status": "forwarded", "account": acc.id} finally: imap.logout() -def save_draft_impl(to, subject, body, html=False, folder="Drafts"): - imap = create_imap() +def save_draft_impl(account=None, to=None, subject=None, body=None, html=False, folder="Drafts"): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) - return save_draft_to_imap(imap, folder, to, subject, body, html) + login_imap(imap, acc) + return save_draft_to_imap(imap, acc, folder, to, subject, body, html) finally: imap.logout() -def list_attachments_impl(folder, uid): - imap = create_imap() +def list_attachments_impl(account=None, folder=None, uid=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) atts = list_attachments(imap, folder, uid) return {"attachments": atts} finally: imap.logout() -def download_attachment_impl(folder, uid, filename): - imap = create_imap() +def download_attachment_impl(account=None, folder=None, uid=None, filename=None): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) info = download_attachment(imap, folder, uid, filename) return info except ValueError as e: @@ -1963,30 +1806,33 @@ def download_attachment_impl(folder, uid, filename): imap.logout() -def search_attachments_impl(folder="INBOX", file_pattern="", max_results=50): - imap = create_imap() +def search_attachments_impl(account=None, folder="INBOX", file_pattern="", max_results=50): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) 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() +def export_conversation_impl(account=None, folder=None, message_uid=None, max_messages=50): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) 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() +def conflict_check_search_impl(account=None, terms=None, folders=None, max_results=100): + acc = get_account(account) + imap = create_imap(acc) try: - login_imap(imap) + login_imap(imap, acc) results = conflict_check_search(imap, terms, folders, max_results) return {"results": results} finally: @@ -2000,70 +1846,11 @@ def get_triage_config_impl(args): } -# 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.", + description="Email assistant MCP server exposing IMAP/SMTP and conflict-check operations via MCP Streamable HTTP.", version="1.0.0", ) @@ -2102,9 +1889,6 @@ async def health_check(): @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": { @@ -2180,9 +1964,18 @@ async def mcp_endpoint(request: Request): if request.method != "POST": raise HTTPException(status_code=405, detail="Method Not Allowed") + req_id = str(uuid.uuid4()) + request_id_var.set(req_id) + prefix = f"[req:{req_id}]" + + logger.info(f"{prefix} POST /mcp") + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f"{prefix} POST /mcp headers={dict(request.headers)}") + try: body = await request.json() except Exception: + logger.warning(f"{prefix} POST /mcp invalid JSON") return JSONResponse( status_code=400, content=mcp_error(None, -32700, "Parse error") @@ -2190,9 +1983,11 @@ async def mcp_endpoint(request: Request): if isinstance(body, list): results = [handle_rpc(msg) for msg in body] + logger.debug(f"{prefix} POST /mcp batch response count={len(results)}") return JSONResponse(content=results) else: result = handle_rpc(body) + logger.debug(f"{prefix} POST /mcp response keys={list(result.keys())}") return JSONResponse(content=result) diff --git a/templates/email_signature.html b/templates/email_signature.html new file mode 100644 index 0000000..17f7e32 --- /dev/null +++ b/templates/email_signature.html @@ -0,0 +1,68 @@ + + + + + + + + + + +
+
+

+ + Adam P. Strömbergsson-DeNora + +
+ + Barrister & Solicitor
+ LSO, LSY, LSN +
+

+
+ + + + + + + + + + + + + + + +
+ 7th floor - 1 Rideau St. Ottawa, ON. K1N 8S7 +
+ + T: 1 613 699 2127 + +
+ + E:  + + adam@apstrom.ca + + +
+ + A.P. Ström & Associates + +
+ + + apstrom.ca + + +
+
+
+ + This e-mail message may contain PRIVILEGED and CONFIDENTIAL information and thus may be intended only for the use of the specific individual or individuals to which it is addressed. If you are not an intended recipient of this e-mail, you know that any unauthorized use, dissemination or copying of this e-mail or the information contained herein or attached hereto is strictly prohibited. If you receive this e-mail in error, notify the person named above by reply e-mail and please delete this message and any attachments. Thank you. + +
diff --git a/templates/email_signature_variables.html b/templates/email_signature_variables.html new file mode 100644 index 0000000..ceba2bc --- /dev/null +++ b/templates/email_signature_variables.html @@ -0,0 +1,51 @@ + + + + + + + + + + +
+
+

+ + {{NAME}} + +
+ + {{TITLE}} + +

+
+ + + + + + + + + +
+ {{ADDRESS}} +
+ + T: {{PHONE}} + +
+ + E:  + + {{EMAIL}} + + +
+
+
+ + This e-mail message may contain PRIVILEGED and CONFIDENTIAL information and thus may be intended only for the use of the specific individual or individuals to which it is addressed. If you are not an intended recipient of this e-mail, you know that any unauthorized use, dissemination or copying of this e-mail or the information contained herein or attached hereto is strictly prohibited. If you receive this e-mail in error, notify the person named above by reply e-mail and please delete this message and any attachments. Thank you. + +