Files
skald-connectors/connectors/email/email_mcp_server.py
T
dguiducci 955b1ba069 feat: friendly tool names per MCP title field or manifest
- Aggiunto campo 'title' a tools/list in tutti gli script MCP locali
  (Gmail, Gcal, Drive, Email, SSH, Weather, Wikipedia, WhatsApp)
- Aggiunto tools[]/display_name nel manifest per connector remoti/package
  esterni (Firecrawl, HTTP Fetch, Exa, Tavily, SerpAPI Flights)
- Documentata convenzione e resolution order in SKALD.md
- Aggiornata manifest_guide.md per tools[] in connectors.json
- Bumped version e sha256 per tutti i 13 connector
2026-07-21 23:43:24 +01:00

1409 lines
54 KiB
Python

#!/usr/bin/env python3
"""Generic Email MCP server — IMAP + SMTP over stdio (JSON-RPC 2.0).
Works with ANY email provider (Gmail, Outlook/Office 365, iCloud, Yahoo,
Fastmail, self-hosted, corporate…). No Google Cloud Console, no OAuth: just
standard IMAP for reading/organising and SMTP for sending, with an
app-password. Standard library only — no pip install required.
Capabilities (callable as `mcp__email__<tool>`):
status — self-check: IMAP + SMTP login and reachability
list_messages — list messages in a folder with a Gmail-like query
get_message — read a single message by UID (body text + attachments)
get_thread — best-effort thread reconstruction (References/Subject)
list_folders — list IMAP folders/mailboxes with total/unread counts
modify_message — flag/unflag, mark read/unread, move, archive, delete
send_message — send an email (in-thread replies + file attachments)
get_profile — configured account + INBOX totals
create_folder — create a new IMAP folder/mailbox
download_attachments — save all attachments from a message to disk
Push notifications: a background watcher emits `event/new_email` the moment a
new message lands in INBOX, using IMAP IDLE when the server advertises it and
falling back to 60s polling otherwise — mirroring the Gmail connector's event.
Configuration is read entirely from environment variables (nothing on disk):
EMAIL_IMAP_HOST (required) e.g. imap.gmail.com
EMAIL_IMAP_PORT (default 993, IMAP over SSL)
EMAIL_SMTP_HOST (required) e.g. smtp.gmail.com
EMAIL_SMTP_PORT (default 465)
EMAIL_SMTP_SECURITY (ssl | starttls | plain; default: ssl if port 465 else starttls)
EMAIL_USERNAME (required) usually the full email address
EMAIL_PASSWORD (required) password or provider app-password
EMAIL_FROM (optional) From address; defaults to EMAIL_USERNAME
"""
from __future__ import annotations
import email
import email.utils
import imaplib
import json
import mimetypes
import os
import re
import select
import smtplib
import ssl
import sys
import threading
import time
from email.header import decode_header, make_header
from email.message import EmailMessage
from html.parser import HTMLParser
from typing import Any, Callable
# Log to stderr so stdout stays clean for JSON-RPC.
def log(msg: str) -> None:
print(f"[email_mcp] {msg}", file=sys.stderr, flush=True)
# Protects all stdout writes (main request thread + push watcher thread).
_stdout_lock = threading.Lock()
# ── Push notifications ──────────────────────────────────────────────────────────
def _emit_notification(method: str, params: dict) -> None:
"""Write a JSON-RPC notification (no id) to stdout."""
msg = json.dumps({"jsonrpc": "2.0", "method": method, "params": params})
with _stdout_lock:
sys.stdout.write(msg + "\n")
sys.stdout.flush()
# Re-issue IDLE well within the 29-minute ceiling recommended by RFC 2177.
_IDLE_REFRESH_SECS = 20 * 60
_POLL_INTERVAL_SECS = 60
_RECONNECT_DELAY_SECS = 60
_watch_thread: threading.Thread | None = None
def _start_watching() -> None:
"""Spin up the background push watcher (its own dedicated IMAP connection)."""
global _watch_thread
cfg = _get_config()
if cfg is None:
log(f"Push watcher disabled: {_init_error}")
return
_watch_thread = threading.Thread(target=_watch_loop, daemon=True, name="email-watch")
_watch_thread.start()
def _watch_loop() -> None:
"""Outer reconnect loop: keep a watcher connection alive forever."""
cfg = _get_config()
if cfg is None:
return
while True:
conn = None
try:
conn = _connect_imap(cfg)
conn.select("INBOX", readonly=True)
last_uid = _inbox_uidnext(conn)
use_idle = _server_has_idle(conn)
log(f"Push watcher started (mode={'IDLE' if use_idle else 'poll'}, "
f"next_uid={last_uid}).")
while True:
if use_idle:
try:
_idle_wait(conn, _IDLE_REFRESH_SECS)
except Exception as e:
log(f"IDLE failed ({e}); falling back to 60s polling.")
use_idle = False
time.sleep(_POLL_INTERVAL_SECS)
else:
time.sleep(_POLL_INTERVAL_SECS)
try:
conn.noop() # refresh the mailbox view before searching
except Exception:
raise # drop to the reconnect loop
last_uid = _emit_new_since(conn, last_uid)
except Exception as e:
log(f"Push watcher connection error: {_format_imap_error(e)}; "
f"reconnecting in {_RECONNECT_DELAY_SECS}s.")
if conn is not None:
try:
conn.logout()
except Exception:
pass
time.sleep(_RECONNECT_DELAY_SECS)
def _server_has_idle(conn: imaplib.IMAP4) -> bool:
try:
return any(c.upper() == "IDLE" for c in conn.capabilities)
except Exception:
return False
def _inbox_uidnext(conn: imaplib.IMAP4) -> int:
"""UID that will be assigned to the next new message (our 'new mail' cursor)."""
try:
typ, data = conn.status("INBOX", "(UIDNEXT)")
if typ == "OK" and data and data[0]:
m = re.search(rb"UIDNEXT\s+(\d+)", data[0])
if m:
return int(m.group(1))
except Exception:
pass
return 1
def _idle_wait(conn: imaplib.IMAP4, timeout: int) -> None:
"""Enter IMAP IDLE and block until the server reports activity or `timeout`.
Implemented by hand because imaplib only grew a native idle() in Python 3.13.
We reuse imaplib's tag counter (_new_tag) so its internal state stays
consistent, and always send DONE + drain the tagged completion before
returning so the connection is usable for a follow-up UID SEARCH.
"""
tag = conn._new_tag() # type: ignore[attr-defined]
conn.send(tag + b" IDLE\r\n")
resp = conn.readline()
if not resp.lstrip().startswith(b"+"):
raise RuntimeError(f"server refused IDLE: {resp!r}")
try:
ready, _, _ = select.select([conn.sock], [], [], timeout)
if ready:
conn.readline() # consume the untagged EXISTS/RECENT push
finally:
conn.send(b"DONE\r\n")
deadline = time.time() + 10
while time.time() < deadline:
line = conn.readline()
if not line or line.startswith(tag):
break
def _emit_new_since(conn: imaplib.IMAP4, last_uid: int) -> int:
"""Emit event/new_email for every INBOX message with UID >= last_uid."""
try:
typ, data = conn.uid("SEARCH", None, f"UID {last_uid}:*")
except Exception as e:
log(f"new-mail search failed: {_format_imap_error(e)}")
return last_uid
if typ != "OK" or not data or not data[0]:
return last_uid
# "UID n:*" always returns the highest message even if its UID < n, so filter.
uids = sorted(u for u in (int(x) for x in data[0].split()) if u >= last_uid)
for uid in uids:
_fetch_and_emit(conn, uid)
last_uid = uid + 1
return last_uid
def _fetch_and_emit(conn: imaplib.IMAP4, uid: int) -> None:
"""Fetch a new message's headers and emit an event/new_email notification."""
try:
typ, data = conn.uid(
"FETCH", str(uid),
"(BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)])",
)
if typ != "OK":
return
headers = _parse_header_bytes(_first_literal(data))
_emit_notification("event/new_email", {
"message_id": str(uid),
"folder": "INBOX",
"thread_id": headers.get("message-id", ""),
"subject": headers.get("subject", "(no subject)"),
"from": headers.get("from", "?"),
"date": headers.get("date", "?"),
"snippet": "",
})
log(f"Notification emitted: new email uid={uid} from {headers.get('from', '?')!r}")
except Exception as e:
log(f"Failed to emit notification for uid {uid}: {_format_imap_error(e)}")
# ── Configuration (environment only) ─────────────────────────────────────────────
_config: dict | None = None
_config_loaded = False
_init_error: str | None = None
def _get_config() -> dict | None:
"""Load and validate config from environment variables (once)."""
global _config, _config_loaded, _init_error
if _config_loaded:
return _config
_config_loaded = True
missing = [k for k in ("EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST", "EMAIL_USERNAME", "EMAIL_PASSWORD")
if not os.environ.get(k)]
if missing:
_init_error = (
"Missing required environment variable(s): " + ", ".join(missing) + ". "
"Set EMAIL_IMAP_HOST, EMAIL_SMTP_HOST, EMAIL_USERNAME and EMAIL_PASSWORD "
"(plus optional EMAIL_IMAP_PORT / EMAIL_SMTP_PORT / EMAIL_SMTP_SECURITY / EMAIL_FROM)."
)
log(_init_error)
return None
def _int(name: str, default: int) -> int:
raw = os.environ.get(name)
if not raw:
return default
try:
return int(raw)
except ValueError:
log(f"Invalid {name}={raw!r}; using default {default}.")
return default
smtp_port = _int("EMAIL_SMTP_PORT", 465)
security = (os.environ.get("EMAIL_SMTP_SECURITY") or "").strip().lower()
if security not in ("ssl", "starttls", "plain"):
security = "ssl" if smtp_port == 465 else "starttls"
username = os.environ["EMAIL_USERNAME"]
_config = {
"imap_host": os.environ["EMAIL_IMAP_HOST"],
"imap_port": _int("EMAIL_IMAP_PORT", 993),
"smtp_host": os.environ["EMAIL_SMTP_HOST"],
"smtp_port": smtp_port,
"smtp_security": security,
"username": username,
"password": os.environ["EMAIL_PASSWORD"],
"from_addr": os.environ.get("EMAIL_FROM") or username,
}
log(f"Config loaded for {username} (imap {_config['imap_host']}:{_config['imap_port']}, "
f"smtp {_config['smtp_host']}:{smtp_port}/{security}).")
return _config
# ── IMAP connection (request thread) ─────────────────────────────────────────────
# Single connection reused by the request-handling thread. The main loop reads
# stdin sequentially and dispatches synchronously, so this is only ever touched
# by one thread; the push watcher keeps its OWN separate connection.
_imap_conn: imaplib.IMAP4 | None = None
def _connect_imap(cfg: dict) -> imaplib.IMAP4_SSL:
"""Open and authenticate a fresh IMAP-over-SSL connection (raises on failure)."""
conn = imaplib.IMAP4_SSL(cfg["imap_host"], cfg["imap_port"],
ssl_context=ssl.create_default_context())
conn.login(cfg["username"], cfg["password"])
return conn
def _imap() -> imaplib.IMAP4 | None:
"""Return a healthy IMAP connection for the request thread, or None (+ _init_error)."""
global _imap_conn, _init_error
cfg = _get_config()
if cfg is None:
return None
if _imap_conn is not None:
try:
_imap_conn.noop()
return _imap_conn
except Exception:
try:
_imap_conn.logout()
except Exception:
pass
_imap_conn = None
try:
_imap_conn = _connect_imap(cfg)
return _imap_conn
except Exception as e:
_init_error = _format_imap_error(e)
log(_init_error)
return None
def _select(conn: imaplib.IMAP4, folder: str, readonly: bool = True) -> tuple[bool, str]:
"""SELECT a folder; return (ok, error_message)."""
typ, data = conn.select(_quote_mailbox(folder), readonly=readonly)
if typ != "OK":
detail = data[0].decode("utf-8", "replace") if data and data[0] else "unknown error"
return False, f"Error: cannot open folder {folder!r}: {detail}"
return True, ""
def _quote_mailbox(name: str) -> str:
"""Quote a mailbox name for IMAP if it contains spaces/specials."""
if name and re.fullmatch(r"[A-Za-z0-9_./\-]+", name):
return name
return '"' + name.replace("\\", "\\\\").replace('"', '\\"') + '"'
# ── Error mapping ────────────────────────────────────────────────────────────────
def _format_imap_error(e: Exception) -> str:
"""Map an IMAP/socket exception into an actionable Error: string."""
text = str(e).strip()
low = text.lower()
if isinstance(e, imaplib.IMAP4.error):
if "authentication" in low or "login" in low or "credentials" in low or "auth" in low:
return ("Error: IMAP login was rejected. Check EMAIL_USERNAME / EMAIL_PASSWORD — "
"most providers require an app-specific password (not your normal login "
"password) and IMAP to be enabled in account settings.")
return f"Error: IMAP error: {text}"
if isinstance(e, (TimeoutError, ConnectionError, OSError)):
return (f"Error: could not reach the IMAP server ({text}). Check EMAIL_IMAP_HOST / "
"EMAIL_IMAP_PORT and your network.")
return f"Error: IMAP call failed: {text}"
def _format_smtp_error(e: Exception) -> str:
text = str(e).strip()
if isinstance(e, smtplib.SMTPAuthenticationError):
return ("Error: SMTP login was rejected. Check EMAIL_USERNAME / EMAIL_PASSWORD — an "
"app-specific password is usually required for SMTP too.")
if isinstance(e, smtplib.SMTPException):
return f"Error: SMTP error: {text}"
if isinstance(e, (TimeoutError, ConnectionError, OSError)):
return (f"Error: could not reach the SMTP server ({text}). Check EMAIL_SMTP_HOST / "
"EMAIL_SMTP_PORT / EMAIL_SMTP_SECURITY.")
return f"Error: SMTP call failed: {text}"
def _unavailable() -> str:
"""Error string for tools when config/connection isn't ready (no double 'Error:')."""
msg = _init_error or "Email connector is not configured."
return msg if msg.startswith("Error:") else f"Error: {msg}"
def _status_report(icon: str, label: str, kind: str, description: str,
steps: list[str] | None = None) -> str:
lines = [f"Status: {label} {icon} ({kind})", description]
if steps:
lines.append("")
lines.append("What to do:")
for i, s in enumerate(steps, 1):
lines.append(f"{i}. {s}")
return "\n".join(lines)
# ── MIME / parsing helpers ───────────────────────────────────────────────────────
def _decode_hdr(value: str | None) -> str:
"""Decode a possibly MIME-encoded header (=?utf-8?...?=) to a plain string."""
if not value:
return ""
try:
return str(make_header(decode_header(value)))
except Exception:
return value
def _first_literal(data: Any) -> bytes:
"""Return the first literal ({N}-prefixed) byte payload from a FETCH response."""
if not data:
return b""
for item in data:
if isinstance(item, tuple) and len(item) >= 2 and item[1] is not None:
return item[1]
return b""
def _parse_header_bytes(raw: bytes) -> dict[str, str]:
"""Parse RFC822 header bytes into a lowercased, MIME-decoded dict."""
if not raw:
return {}
msg = email.message_from_bytes(raw)
out: dict[str, str] = {}
for key in msg.keys():
out[key.lower()] = _decode_hdr(msg.get(key))
return out
def _part_text(part: email.message.Message) -> str:
payload = part.get_payload(decode=True)
if payload is None:
return ""
charset = part.get_content_charset() or "utf-8"
try:
return payload.decode(charset, errors="replace")
except (LookupError, TypeError):
return payload.decode("utf-8", errors="replace")
def _is_attachment(part: email.message.Message) -> bool:
disp = str(part.get("Content-Disposition") or "").lower()
if "attachment" in disp:
return True
return bool(part.get_filename())
def _extract_body(msg: email.message.Message) -> tuple[str, bool]:
"""Return (text, from_html). Prefer text/plain; fall back to text/html→text."""
if msg.is_multipart():
plain = html = ""
for part in msg.walk():
if part.is_multipart() or _is_attachment(part):
continue
ctype = part.get_content_type()
if ctype == "text/plain" and not plain:
plain = _part_text(part)
elif ctype == "text/html" and not html:
html = _part_text(part)
if plain:
return plain, False
if html:
return _html_to_text(html), True
return "", False
text = _part_text(msg)
if msg.get_content_type() == "text/html":
return _html_to_text(text), True
return text, False
def _iter_attachments(msg: email.message.Message):
for part in msg.walk():
if part.is_multipart():
continue
if _is_attachment(part):
yield part
class _HTMLTextExtractor(HTMLParser):
"""Collect readable text from HTML, skipping scripts/styles, block newlines."""
_SKIP = {"script", "style", "head"}
_BLOCK = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6"}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self._chunks: list[str] = []
self._skip_depth = 0
def handle_starttag(self, tag: str, attrs: Any) -> None:
if tag in self._SKIP:
self._skip_depth += 1
elif tag == "br":
self._chunks.append("\n")
def handle_endtag(self, tag: str) -> None:
if tag in self._SKIP and self._skip_depth:
self._skip_depth -= 1
elif tag in self._BLOCK:
self._chunks.append("\n")
def handle_data(self, data: str) -> None:
if not self._skip_depth:
self._chunks.append(data)
def get_text(self) -> str:
return re.sub(r"\n{3,}", "\n\n", "".join(self._chunks)).strip()
def _html_to_text(html_str: str) -> str:
try:
parser = _HTMLTextExtractor()
parser.feed(html_str)
return parser.get_text()
except Exception:
return html_str
# ── Gmail-like search → IMAP SEARCH criteria ─────────────────────────────────────
def _imap_date(value: str) -> str | None:
"""Convert YYYY-MM-DD (or DD-Mon-YYYY) into an IMAP date (01-Jan-2024)."""
value = value.strip()
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d-%b-%Y"):
try:
return time.strftime("%d-%b-%Y", time.strptime(value, fmt))
except ValueError:
continue
return None
def _q(s: str) -> str:
"""IMAP quoted-string. imaplib does NOT quote SEARCH args, so we must — any
value with a space (TEXT/SUBJECT phrases) is otherwise split into atoms."""
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
def _build_search(query: str, unread_only: bool) -> list[str]:
"""Turn a Gmail-ish query into IMAP SEARCH tokens.
Supported: from:x to:x subject:x since:YYYY-MM-DD before:YYYY-MM-DD
unread / is:unread / read / is:read has:attachment and free text (→ TEXT).
"""
criteria: list[str] = []
free: list[str] = []
for tok in (query or "").split():
low = tok.lower()
if low in ("unread", "is:unread"):
criteria += ["UNSEEN"]
elif low in ("read", "is:read"):
criteria += ["SEEN"]
elif low in ("has:attachment", "is:attachment"):
criteria += ["KEYWORD", "attachment"] # best-effort; not all servers honour it
elif low.startswith("from:"):
criteria += ["FROM", _q(tok[5:])]
elif low.startswith("to:"):
criteria += ["TO", _q(tok[3:])]
elif low.startswith("subject:"):
criteria += ["SUBJECT", _q(tok[8:])]
elif low.startswith("since:"):
d = _imap_date(tok[6:])
if d:
criteria += ["SINCE", d]
elif low.startswith("before:"):
d = _imap_date(tok[7:])
if d:
criteria += ["BEFORE", d]
else:
free.append(tok)
if free:
criteria += ["TEXT", _q(" ".join(free))]
if unread_only and "UNSEEN" not in criteria:
criteria += ["UNSEEN"]
return criteria or ["ALL"]
# ── Tool implementations ─────────────────────────────────────────────────────────
def _email_status(args: dict | None = None) -> str:
"""Self-check: IMAP login + SMTP login both succeed."""
cfg = _get_config()
if cfg is None:
return _status_report("❌", "NOT_CONFIGURED", "action needed",
_init_error or "Configuration is incomplete.",
["Set the required environment variables: EMAIL_IMAP_HOST, EMAIL_SMTP_HOST, "
"EMAIL_USERNAME, EMAIL_PASSWORD.",
"Use an app-specific password if your provider requires one (Gmail, iCloud, Yahoo…)."])
# IMAP probe.
conn = _imap()
if conn is None:
return _status_report("❌", "IMAP_ERROR", "action needed",
_init_error or "Could not connect to IMAP.",
["Verify EMAIL_IMAP_HOST / EMAIL_IMAP_PORT and that IMAP is enabled for the account.",
"Verify EMAIL_USERNAME / EMAIL_PASSWORD (app password may be required)."])
try:
conn.select("INBOX", readonly=True)
except Exception as e:
return _status_report("❌", "IMAP_ERROR", "action needed",
_format_imap_error(e), ["Check IMAP settings and credentials."])
# SMTP probe (connect + login, then quit).
try:
smtp = _open_smtp(cfg)
smtp.quit()
except Exception as e:
return _status_report("⚠️", "IMAP_OK_SMTP_ERROR", "partial",
f"IMAP works, but SMTP login failed: {_format_smtp_error(e)}",
["Reading works; sending will not until SMTP is fixed.",
"Check EMAIL_SMTP_HOST / EMAIL_SMTP_PORT / EMAIL_SMTP_SECURITY and the password."])
return _status_report("✅", "READY", "ok",
"Email integration is operational: IMAP and SMTP both authenticate. All tools "
"(list/get/thread/folders/modify/send/download) are usable.\n"
f"Account: {cfg['username']} (IMAP {cfg['imap_host']}, SMTP {cfg['smtp_host']})")
def _fetch_summary(conn: imaplib.IMAP4, uid: int) -> str:
"""One-message summary line for list_messages (headers + read/flag state)."""
typ, data = conn.uid(
"FETCH", str(uid),
"(FLAGS BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)])",
)
if typ != "OK":
return f"- uid {uid} (error fetching)"
flags = imaplib.ParseFlags(data[0][0]) if data and isinstance(data[0], tuple) else ()
flag_names = {f.decode("ascii", "replace").lstrip("\\").lower() for f in flags}
headers = _parse_header_bytes(_first_literal(data))
unread = "seen" not in flag_names
marks = []
if unread:
marks.append("UNREAD")
if "flagged" in flag_names:
marks.append("★")
mark_str = (" [" + ", ".join(marks) + "]") if marks else ""
return (f"- {headers.get('subject', '(no subject)')}{mark_str}\n"
f" From: {headers.get('from', '?')} | Date: {headers.get('date', '?')} | UID: {uid}")
def _email_list_messages(args: dict) -> str:
conn = _imap()
if conn is None:
return _unavailable()
folder = args.get("folder", "INBOX")
query = args.get("query", "")
unread_only = bool(args.get("unread_only", False))
max_results = min(int(args.get("max_results", 20) or 20), 50)
ok, err = _select(conn, folder, readonly=True)
if not ok:
return err
criteria = _build_search(query, unread_only)
try:
typ, data = conn.uid("SEARCH", None, *criteria)
except Exception as e:
return _format_imap_error(e)
if typ != "OK":
return f"Error: IMAP SEARCH failed in {folder!r}."
uids = [int(x) for x in data[0].split()] if data and data[0] else []
if not uids:
return f"No messages found in {folder!r}."
# Newest first, capped.
uids = sorted(uids, reverse=True)[:max_results]
lines = [f"Messages in {folder!r} ({len(uids)} shown, newest first):"]
for uid in uids:
try:
lines.append(_fetch_summary(conn, uid))
except Exception as e:
lines.append(f"- uid {uid} (error: {e})")
lines.append("\nUse get_message with the UID (and folder if not INBOX) to read a message.")
return "\n".join(lines)
def _fetch_full(conn: imaplib.IMAP4, uid: int) -> email.message.Message | None:
typ, data = conn.uid("FETCH", str(uid), "(BODY.PEEK[])")
if typ != "OK":
return None
raw = _first_literal(data)
if not raw:
return None
return email.message_from_bytes(raw)
def _email_get_message(args: dict) -> str:
conn = _imap()
if conn is None:
return _unavailable()
uid = args.get("message_id")
if not uid:
return "Error: Missing required parameter 'message_id' (the message UID)."
folder = args.get("folder", "INBOX")
include_body = args.get("include_body", True)
ok, err = _select(conn, folder, readonly=True)
if not ok:
return err
try:
msg = _fetch_full(conn, int(uid))
except ValueError:
return f"Error: message_id must be a numeric UID, got {uid!r}."
except Exception as e:
return _format_imap_error(e)
if msg is None:
return f"Error: message UID {uid} not found in folder {folder!r}."
lines = [
f"UID: {uid}",
f"Folder: {folder}",
f"From: {_decode_hdr(msg.get('From'))}",
f"To: {_decode_hdr(msg.get('To'))}",
f"Date: {_decode_hdr(msg.get('Date'))}",
f"Subject: {_decode_hdr(msg.get('Subject')) or '(no subject)'}",
f"Message-ID: {msg.get('Message-ID', '?')}",
]
attachments = [_decode_hdr(p.get_filename()) for p in _iter_attachments(msg)]
if attachments:
lines.append(f"Attachments: {', '.join(a for a in attachments if a)}")
if include_body:
body_text, from_html = _extract_body(msg)
if body_text:
label = "--- Body (converted from HTML) ---" if from_html else "--- Body ---"
lines.append("\n" + label)
if len(body_text) > 10000:
lines.append(body_text[:10000] + "\n... [truncated at 10000 chars]")
else:
lines.append(body_text)
else:
lines.append("\n(no text body found)")
return "\n".join(lines)
def _email_get_thread(args: dict) -> str:
"""Best-effort thread: group messages by shared Message-ID references / subject."""
conn = _imap()
if conn is None:
return _unavailable()
uid = args.get("message_id")
if not uid:
return "Error: Missing required parameter 'message_id' (a message UID in the thread)."
folder = args.get("folder", "INBOX")
ok, err = _select(conn, folder, readonly=True)
if not ok:
return err
try:
seed = _fetch_full(conn, int(uid))
except ValueError:
return f"Error: message_id must be a numeric UID, got {uid!r}."
except Exception as e:
return _format_imap_error(e)
if seed is None:
return f"Error: message UID {uid} not found in folder {folder!r}."
msg_id = (seed.get("Message-ID") or "").strip()
subject = _decode_hdr(seed.get("Subject"))
base_subject = re.sub(r"(?i)^\s*(re|fwd|fw|r|aw|antw|sv)\s*:\s*", "", subject).strip()
# Collect candidate UIDs: same base subject, plus anything referencing this Message-ID.
found: set[int] = {int(uid)}
try:
if base_subject:
typ, data = conn.uid("SEARCH", None, "SUBJECT", _q(base_subject))
if typ == "OK" and data and data[0]:
found.update(int(x) for x in data[0].split())
if msg_id:
for field in ("In-Reply-To", "References"):
typ, data = conn.uid("SEARCH", None, "HEADER", field, _q(msg_id))
if typ == "OK" and data and data[0]:
found.update(int(x) for x in data[0].split())
except Exception as e:
log(f"thread search partial failure: {_format_imap_error(e)}")
ordered = sorted(found)
lines = [f"Thread (best-effort) around UID {uid} in {folder!r}{len(ordered)} message(s):"]
if base_subject:
lines.insert(1, f"Subject: {base_subject}")
for muid in ordered:
try:
typ, data = conn.uid(
"FETCH", str(muid),
"(BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)])",
)
h = _parse_header_bytes(_first_literal(data))
lines.append(f"\n[UID {muid}] From: {h.get('from', '?')} | Date: {h.get('date', '?')}")
lines.append(f" Subject: {h.get('subject', '(no subject)')}")
except Exception as e:
lines.append(f"\n[UID {muid}] (error: {e})")
return "\n".join(lines)
def _email_list_folders(args: dict) -> str:
conn = _imap()
if conn is None:
return _unavailable()
try:
typ, data = conn.list()
except Exception as e:
return _format_imap_error(e)
if typ != "OK" or not data:
return "No folders found."
lines = ["Folders:"]
for raw in data:
if raw is None:
continue
name = _parse_list_mailbox(raw)
if not name:
continue
counts = ""
try:
typ2, st = conn.status(_quote_mailbox(name), "(MESSAGES UNSEEN)")
if typ2 == "OK" and st and st[0]:
total = re.search(rb"MESSAGES\s+(\d+)", st[0])
unseen = re.search(rb"UNSEEN\s+(\d+)", st[0])
counts = (f" — {int(total.group(1)) if total else '?'} total, "
f"{int(unseen.group(1)) if unseen else '?'} unread")
except Exception:
pass
lines.append(f"- {name}{counts}")
return "\n".join(lines)
def _parse_list_mailbox(raw: bytes) -> str:
"""Extract the mailbox name from a LIST response line."""
text = raw.decode("utf-8", "replace") if isinstance(raw, bytes) else str(raw)
# Format: (\HasNoChildren) "/" "Folder Name" — name is the last token, maybe quoted.
m = re.search(r'"(?:[^"\\]|\\.)*"\s*$', text)
if m:
return m.group(0)[1:-1].replace('\\"', '"').replace("\\\\", "\\")
parts = text.rsplit(" ", 1)
return parts[-1].strip() if parts else ""
# Friendly flag name → IMAP system flag.
_FLAG_MAP = {
"read": "\\Seen", "seen": "\\Seen",
"flagged": "\\Flagged", "starred": "\\Flagged", "star": "\\Flagged",
"answered": "\\Answered", "draft": "\\Draft", "deleted": "\\Deleted",
}
def _map_flags(names: Any) -> list[str]:
if isinstance(names, str):
names = [names]
out = []
for n in names or []:
out.append(_FLAG_MAP.get(str(n).strip().lower(), n))
return out
def _expunge_uid(conn: imaplib.IMAP4, uid: str) -> None:
"""Expunge one message. Uses UID EXPUNGE (UIDPLUS) so we don't remove other
\\Deleted messages in the folder; falls back to a full EXPUNGE otherwise."""
if any(c.upper() == "UIDPLUS" for c in conn.capabilities):
try:
conn.uid("EXPUNGE", uid)
return
except Exception:
pass
conn.expunge()
def _email_modify_message(args: dict) -> str:
"""Add/remove flags, mark read/unread, move to another folder, or delete."""
conn = _imap()
if conn is None:
return _unavailable()
uid = args.get("message_id")
if not uid:
return "Error: Missing required parameter 'message_id' (the message UID)."
folder = args.get("folder", "INBOX")
add_flags = _map_flags(args.get("add_flags"))
remove_flags = _map_flags(args.get("remove_flags"))
move_to = args.get("move_to_folder")
# Convenience booleans.
if args.get("mark_read"):
add_flags.append("\\Seen")
if args.get("mark_unread"):
remove_flags.append("\\Seen")
ok, err = _select(conn, folder, readonly=False)
if not ok:
return err
changes = []
try:
if add_flags:
conn.uid("STORE", str(uid), "+FLAGS", "(" + " ".join(add_flags) + ")")
changes.append(f"added flags {add_flags}")
if remove_flags:
conn.uid("STORE", str(uid), "-FLAGS", "(" + " ".join(remove_flags) + ")")
changes.append(f"removed flags {remove_flags}")
if move_to:
# Prefer server-side MOVE; fall back to COPY + \Deleted + EXPUNGE.
# Gate MOVE on both the server capability AND imaplib knowing the verb
# (older Python builds lack MOVE in imaplib.Commands).
can_move = ("MOVE" in imaplib.Commands) and any(c.upper() == "MOVE" for c in conn.capabilities)
if can_move:
conn.uid("MOVE", str(uid), _quote_mailbox(move_to))
else:
conn.uid("COPY", str(uid), _quote_mailbox(move_to))
conn.uid("STORE", str(uid), "+FLAGS", "(\\Deleted)")
_expunge_uid(conn, str(uid))
changes.append(f"moved to {move_to!r}")
elif "\\Deleted" in add_flags:
_expunge_uid(conn, str(uid))
changes.append("expunged")
except Exception as e:
return _format_imap_error(e)
if not changes:
return ("Nothing to do: pass add_flags/remove_flags (e.g. 'read', 'flagged', 'deleted'), "
"mark_read/mark_unread, or move_to_folder.")
return f"✅ Message UID {uid} in {folder!r}: {'; '.join(changes)}"
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
def _open_smtp(cfg: dict) -> smtplib.SMTP:
"""Open and authenticate an SMTP connection per the configured security mode."""
ctx = ssl.create_default_context()
if cfg["smtp_security"] == "ssl":
smtp: smtplib.SMTP = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], context=ctx, timeout=30)
else:
smtp = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=30)
smtp.ehlo()
if cfg["smtp_security"] == "starttls":
smtp.starttls(context=ctx)
smtp.ehlo()
smtp.login(cfg["username"], cfg["password"])
return smtp
def _append_to_sent(conn: imaplib.IMAP4, raw: bytes) -> None:
"""Best-effort: save a sent message into the account's Sent folder."""
sent = None
try:
typ, data = conn.list()
if typ == "OK":
for line in data or []:
if line and b"\\Sent" in line:
sent = _parse_list_mailbox(line)
break
except Exception:
pass
candidates = [sent] if sent else []
candidates += ["Sent", "Sent Items", "Sent Mail", "[Gmail]/Sent Mail", "INBOX.Sent"]
for name in candidates:
if not name:
continue
try:
typ, _ = conn.append(_quote_mailbox(name), "(\\Seen)",
imaplib.Time2Internaldate(time.time()), raw)
if typ == "OK":
return
except Exception:
continue
def _email_send_message(args: dict) -> str:
conn_cfg = _get_config()
if conn_cfg is None:
return _unavailable()
to = args.get("to")
if not to:
return "Error: Missing required parameter 'to'."
subject = args.get("subject", "")
body_text = args.get("body", "")
cc = args.get("cc")
bcc = args.get("bcc")
in_reply_to = args.get("in_reply_to")
attachments = args.get("attachments") or []
if isinstance(attachments, str):
attachments = [attachments]
# Resolve attachment paths (absolute or relative to this connector's folder).
root = os.path.dirname(os.path.abspath(__file__))
resolved: list[str] = []
total = 0
for raw_path in attachments:
path = raw_path if os.path.isabs(raw_path) else os.path.join(root, raw_path)
if not os.path.isfile(path):
return f"Error: attachment not found: {raw_path}"
total += os.path.getsize(path)
resolved.append(path)
if total > _MAX_ATTACHMENT_BYTES:
return (f"Error: attachments total ~{total // (1024 * 1024)} MB, over the "
f"{_MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB limit.")
msg = EmailMessage()
msg["From"] = conn_cfg["from_addr"]
msg["To"] = to
if cc:
msg["Cc"] = cc
if bcc:
msg["Bcc"] = bcc
msg["Subject"] = subject
msg["Date"] = email.utils.formatdate(localtime=True)
msg["Message-ID"] = email.utils.make_msgid()
if in_reply_to:
ref = in_reply_to if in_reply_to.startswith("<") else f"<{in_reply_to}>"
msg["In-Reply-To"] = ref
msg["References"] = ref
msg.set_content(body_text)
for path in resolved:
ctype, encoding = mimetypes.guess_type(path)
if ctype is None or encoding is not None:
ctype = "application/octet-stream"
maintype, subtype = ctype.split("/", 1)
try:
with open(path, "rb") as f:
data = f.read()
except Exception as e:
return f"Error: could not read attachment {path}: {e}"
msg.add_attachment(data, maintype=maintype, subtype=subtype,
filename=os.path.basename(path))
# Recipient list includes Cc/Bcc for the envelope.
recipients = [a.strip() for a in re.split(r"[,;]", to) if a.strip()]
for extra in (cc, bcc):
if extra:
recipients += [a.strip() for a in re.split(r"[,;]", extra) if a.strip()]
try:
smtp = _open_smtp(conn_cfg)
except Exception as e:
return _format_smtp_error(e)
try:
smtp.send_message(msg, from_addr=conn_cfg["from_addr"], to_addrs=recipients)
except Exception as e:
return _format_smtp_error(e)
finally:
try:
smtp.quit()
except Exception:
pass
# Best-effort save-to-Sent (many providers don't do this for SMTP sends).
conn = _imap()
if conn is not None:
try:
_append_to_sent(conn, msg.as_bytes())
except Exception:
pass
suffix = (f" ({len(resolved)} attachment{'s' if len(resolved) != 1 else ''})"
if resolved else "")
return f"✅ Message sent to {to}{suffix}."
def _email_get_profile(args: dict) -> str:
cfg = _get_config()
if cfg is None:
return _unavailable()
conn = _imap()
if conn is None:
return _unavailable()
inbox_total = inbox_unread = "?"
folder_count = "?"
try:
typ, st = conn.status("INBOX", "(MESSAGES UNSEEN)")
if typ == "OK" and st and st[0]:
m = re.search(rb"MESSAGES\s+(\d+)", st[0])
u = re.search(rb"UNSEEN\s+(\d+)", st[0])
inbox_total = int(m.group(1)) if m else "?"
inbox_unread = int(u.group(1)) if u else "?"
typ2, data = conn.list()
if typ2 == "OK" and data:
folder_count = sum(1 for x in data if x)
except Exception as e:
return _format_imap_error(e)
return (f"Account: {cfg['username']}\n"
f"From address: {cfg['from_addr']}\n"
f"IMAP: {cfg['imap_host']}:{cfg['imap_port']}\n"
f"SMTP: {cfg['smtp_host']}:{cfg['smtp_port']} ({cfg['smtp_security']})\n"
f"Folders: {folder_count}\n"
f"INBOX: {inbox_total} total, {inbox_unread} unread")
def _email_create_folder(args: dict) -> str:
conn = _imap()
if conn is None:
return _unavailable()
name = args.get("name")
if not name:
return "Error: Missing required parameter 'name'."
try:
typ, data = conn.create(_quote_mailbox(name))
except Exception as e:
return _format_imap_error(e)
if typ != "OK":
detail = data[0].decode("utf-8", "replace") if data and data[0] else "unknown error"
return f"Error: could not create folder {name!r}: {detail}"
return f"✅ Folder {name!r} created."
def _email_download_attachments(args: dict) -> str:
conn = _imap()
if conn is None:
return _unavailable()
uid = args.get("message_id")
if not uid:
return "Error: Missing required parameter 'message_id' (the message UID)."
folder = args.get("folder", "INBOX")
default_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"data", "email_attachments")
dest = args.get("dest_folder") or default_folder
ok, err = _select(conn, folder, readonly=True)
if not ok:
return err
try:
msg = _fetch_full(conn, int(uid))
except ValueError:
return f"Error: message_id must be a numeric UID, got {uid!r}."
except Exception as e:
return _format_imap_error(e)
if msg is None:
return f"Error: message UID {uid} not found in folder {folder!r}."
parts = list(_iter_attachments(msg))
if not parts:
return "No attachments found."
os.makedirs(dest, exist_ok=True)
saved = []
for part in parts:
filename = _decode_hdr(part.get_filename()) or "attachment.bin"
payload = part.get_payload(decode=True)
if payload is None:
saved.append(f"- {filename}: empty payload")
continue
safe_name = os.path.basename(filename)
path = os.path.join(dest, safe_name)
try:
with open(path, "wb") as f:
f.write(payload)
except Exception as e:
saved.append(f"- {safe_name}: ERROR writing file: {e}")
continue
saved.append(f"- {os.path.abspath(path)} ({len(payload)} bytes)")
return "\n".join(["✅ Attachments downloaded:"] + saved)
# ── Tool manifest ────────────────────────────────────────────────────────────────
TOOLS = [
{
"name": "status",
"title": "Status",
"description": (
"Self-check that the email integration is operational: verifies IMAP and SMTP both "
"authenticate with the configured credentials. Call this first whenever another email "
"tool fails, or to give the user a quick yes/no on whether email is usable right now."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "list_messages",
"title": "List Messages",
"description": (
"List messages in a folder (default INBOX), newest first. The optional 'query' supports "
"a Gmail-like mini-syntax: from:x, to:x, subject:x, since:YYYY-MM-DD, before:YYYY-MM-DD, "
"'unread'/'read', 'has:attachment', and free text (matched against headers+body). "
"Returns subject, sender, date, read/flag state and the message UID; pass the UID (and "
"the same folder) to get_message / modify_message."
),
"inputSchema": {
"type": "object",
"properties": {
"folder": {"type": "string", "description": "IMAP folder to list (default 'INBOX'). See list_folders."},
"query": {"type": "string", "description": "Gmail-like query, e.g. 'from:john unread since:2024-01-01'. Empty = all."},
"unread_only": {"type": "boolean", "description": "Shortcut to only return unread messages (default false)."},
"max_results": {"type": "integer", "description": "Max messages to return (default 20, max 50)."},
},
},
},
{
"name": "get_message",
"title": "Get Message",
"description": (
"Get the full content of a message by its UID, including body text (truncated at 10000 "
"chars; HTML-only emails are converted to readable text). Attachment filenames are "
"listed — download them with download_attachments. Pass 'folder' if the message is not "
"in INBOX (a UID is only unique within one folder)."
),
"inputSchema": {
"type": "object",
"properties": {
"message_id": {"type": "string", "description": "The message UID (as returned by list_messages)."},
"folder": {"type": "string", "description": "Folder the message is in (default 'INBOX')."},
"include_body": {"type": "boolean", "description": "Include the full body text (default true)."},
},
"required": ["message_id"],
},
},
{
"name": "get_thread",
"title": "Get Thread",
"description": (
"Best-effort thread reconstruction for a message UID: gathers messages in the same "
"folder that share the Message-ID reference chain (In-Reply-To/References) or the same "
"base subject. IMAP has no native threads, so this is heuristic."
),
"inputSchema": {
"type": "object",
"properties": {
"message_id": {"type": "string", "description": "A message UID that belongs to the thread."},
"folder": {"type": "string", "description": "Folder to search (default 'INBOX')."},
},
"required": ["message_id"],
},
},
{
"name": "list_folders",
"title": "List Folders",
"description": "List all IMAP folders/mailboxes with total and unread message counts. Use to discover folder names for the other tools.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "modify_message",
"title": "Modify Message",
"description": (
"Change a message's state: add/remove flags, mark read/unread, move to another folder, "
"archive or delete. Friendly flag names: 'read'/'seen', 'flagged'/'starred', 'answered', "
"'deleted'. mark_read=true marks as read; move_to_folder='Archive' archives (moves); "
"add_flags=['deleted'] deletes (and expunges). Pass 'folder' if not INBOX."
),
"inputSchema": {
"type": "object",
"properties": {
"message_id": {"type": "string", "description": "The message UID to modify."},
"folder": {"type": "string", "description": "Folder the message is in (default 'INBOX')."},
"add_flags": {"type": ["string", "array"], "items": {"type": "string"},
"description": "Flag(s) to add: 'read', 'flagged', 'answered', 'deleted'. String or array."},
"remove_flags": {"type": ["string", "array"], "items": {"type": "string"},
"description": "Flag(s) to remove (e.g. 'read' to mark unread)."},
"mark_read": {"type": "boolean", "description": "Convenience: mark the message as read."},
"mark_unread": {"type": "boolean", "description": "Convenience: mark the message as unread."},
"move_to_folder": {"type": "string", "description": "Move the message to this folder (archive = move to your archive folder)."},
},
"required": ["message_id"],
},
},
{
"name": "send_message",
"title": "Send Message",
"description": (
"Send an email via SMTP. Supports in-thread replies by passing in_reply_to (the "
"Message-ID of the message being replied to, with or without angle brackets), which sets "
"the In-Reply-To/References headers. Attach files by passing local paths in "
"'attachments'; if any path is missing the email is NOT sent. The message is also "
"best-effort saved to the account's Sent folder."
),
"inputSchema": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Recipient address(es), comma-separated."},
"subject": {"type": "string", "description": "Subject line."},
"body": {"type": "string", "description": "Plain-text body."},
"cc": {"type": "string", "description": "CC address(es) (optional)."},
"bcc": {"type": "string", "description": "BCC address(es) (optional)."},
"in_reply_to": {"type": "string", "description": "Message-ID being replied to, for correct threading (optional)."},
"attachments": {"type": "array", "items": {"type": "string"},
"description": "File path(s) to attach (absolute or relative to the connector folder). Total ~25 MB."},
},
"required": ["to", "subject", "body"],
},
},
{
"name": "get_profile",
"title": "Get Profile",
"description": "Show the configured account: username, From address, IMAP/SMTP servers, folder count, and INBOX total/unread.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "create_folder",
"title": "Create Folder",
"description": "Create a new IMAP folder/mailbox. Fails if it already exists.",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Name of the new folder (e.g. 'Archive', 'Receipts')."},
},
"required": ["name"],
},
},
{
"name": "download_attachments",
"title": "Download Attachments",
"description": (
"Download all attachments from a message to a local folder (default data/email_attachments/). "
"Returns the absolute path and size of each saved file. Pass 'folder' if the message is not in INBOX."
),
"inputSchema": {
"type": "object",
"properties": {
"message_id": {"type": "string", "description": "The message UID to download attachments from."},
"folder": {"type": "string", "description": "Folder the message is in (default 'INBOX')."},
"dest_folder": {"type": "string", "description": "Local folder to save into (default data/email_attachments/)."},
},
"required": ["message_id"],
},
},
]
# ── JSON-RPC dispatch ────────────────────────────────────────────────────────────
TOOL_DISPATCH: dict[str, Callable[[dict], str]] = {
"status": _email_status,
"list_messages": _email_list_messages,
"get_message": _email_get_message,
"get_thread": _email_get_thread,
"list_folders": _email_list_folders,
"modify_message": _email_modify_message,
"send_message": _email_send_message,
"get_profile": _email_get_profile,
"create_folder": _email_create_folder,
"download_attachments": _email_download_attachments,
}
def _ok(req_id: Any, result: Any) -> str:
return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result})
def _text_result(req_id: Any, text: str, is_error: bool = False) -> str:
payload: dict = {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": text}]},
}
if is_error:
payload["result"]["isError"] = True
return json.dumps(payload)
def handle_request(msg: dict) -> str | None:
method = msg.get("method", "")
req_id = msg.get("id")
if method == "initialize":
return _ok(req_id, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "email", "version": "1.0.0"},
})
if method == "notifications/initialized":
return None
if method == "tools/list":
return _ok(req_id, {"tools": TOOLS})
if method == "tools/call":
params = msg.get("params", {})
tool_name = params.get("name", "")
tool_args = params.get("arguments", {}) or {}
handler = TOOL_DISPATCH.get(tool_name)
if handler is None:
return _text_result(req_id, f"Error: Unknown tool: {tool_name}", is_error=True)
try:
text = handler(tool_args)
return _text_result(req_id, text, is_error=text.startswith("Error:"))
except Exception as e:
log(f"Unhandled exception in tool '{tool_name}': {e}")
return _text_result(req_id, f"Error: Internal error in tool '{tool_name}': {e}", is_error=True)
return json.dumps({
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
})
# ── Main loop ────────────────────────────────────────────────────────────────────
def main() -> None:
log("Starting Email MCP server")
# Validate config and start the background push watcher (best-effort).
_start_watching()
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError as e:
log(f"Invalid JSON input: {e}")
continue
resp = handle_request(msg)
if resp is not None:
with _stdout_lock:
sys.stdout.write(resp + "\n")
sys.stdout.flush()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()