Files
mcps/ros_mail_mcp.py
T

488 lines
17 KiB
Python

import imaplib
import smtplib
import ssl
import email
import re
import time
import json
import os
import sys
from email.header import decode_header, Header
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
IMAP_HOST = 'mail.rrrosmontis.icu'
IMAP_PORT = 993
SMTP_HOST = 'mail.rrrosmontis.icu'
SMTP_PORT = 465
def decode_mime_str(s):
if not s:
return ''
parts = decode_header(s)
decoded_parts = []
for part, charset in parts:
if isinstance(part, bytes):
try:
if charset:
decoded_parts.append(part.decode(charset, errors='replace'))
else:
decoded_parts.append(part.decode('utf-8', errors='replace'))
except Exception:
decoded_parts.append(part.decode('utf-8', errors='replace'))
else:
decoded_parts.append(part)
return ''.join(decoded_parts)
def decode_attachment_names(raw):
if not raw:
return []
parts = decode_header(raw)
decoded_parts = []
for part, charset in parts:
if isinstance(part, bytes):
try:
if charset:
decoded_parts.append(part.decode(charset, errors='replace'))
else:
decoded_parts.append(part.decode('utf-8', errors='replace'))
except Exception:
decoded_parts.append(part.decode('utf-8', errors='replace'))
else:
decoded_parts.append(part)
combined = ''.join(decoded_parts)
return [name.strip() for name in combined.split(',') if name.strip()]
def extract_email_body(msg):
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/html":
payload = part.get_payload(decode=True)
charset = part.get_content_charset() or 'utf-8'
try:
return payload.decode(charset, errors='replace')
except Exception:
return payload.decode('utf-8', errors='replace')
for part in msg.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True)
charset = part.get_content_charset() or 'utf-8'
try:
return payload.decode(charset, errors='replace')
except Exception:
return payload.decode('utf-8', errors='replace')
else:
payload = msg.get_payload(decode=True)
charset = msg.get_content_charset() or 'utf-8'
try:
return payload.decode(charset, errors='replace')
except Exception:
return payload.decode('utf-8', errors='replace')
return ''
def imap_connect(email_addr, password):
context = ssl._create_unverified_context()
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context)
conn.login(email_addr, password)
return conn
def smtp_connect(email_addr, password):
context = ssl._create_unverified_context()
smtp = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context)
smtp.ehlo()
smtp.login(email_addr, password)
return smtp
def find_sent_folder(conn):
typ, folder_data = conn.list()
if typ != 'OK':
return None
sent_candidates = ['Sent', 'Sent Messages', 'Sent Items',
'\u5df2\u53d1\u9001', '\u5df2\u53d1\u9001\u90ae\u4ef6']
for line in folder_data:
line_str = line.decode() if isinstance(line, bytes) else line
folder_name = None
match = re.search(r'"([^"]+)"$', line_str)
if match:
folder_name = match.group(1)
else:
parts = line_str.split()
if parts:
folder_name = parts[-1].strip('"')
if folder_name:
if any(folder_name.lower() == cand.lower() for cand in sent_candidates):
return folder_name
if 'sent' in folder_name.lower() or '\u5df2\u53d1\u9001' in folder_name:
return folder_name
return 'Sent'
def list_mailboxes(email_addr, password):
conn = imap_connect(email_addr, password)
try:
typ, data = conn.list()
if typ != 'OK':
return []
mailboxes = []
for line in data:
line_str = line.decode() if isinstance(line, bytes) else line
match = re.search(r'"([^"]+)"$', line_str)
if match:
mailboxes.append(match.group(1))
return mailboxes
finally:
conn.logout()
def list_messages(email_addr, password, folder='INBOX', page=1, per_page=20):
conn = imap_connect(email_addr, password)
try:
conn.select(folder)
result, data = conn.uid('search', None, "ALL")
if result != 'OK':
return [], False
uid_list = data[0].split()
total = len(uid_list)
start = max(0, total - page * per_page)
end = total - (page - 1) * per_page
page_uids = uid_list[start:end] if start < end else []
messages = []
for uid in page_uids:
result, msg_data = conn.uid('fetch', uid, '(BODY.PEEK[HEADER])')
if result != 'OK':
continue
raw_header = msg_data[0][1]
msg = email.message_from_bytes(raw_header)
messages.append({
'uid': uid.decode() if isinstance(uid, bytes) else uid,
'from': decode_mime_str(msg.get('From', '')),
'subject': decode_mime_str(msg.get('Subject', '')),
'date': msg.get('Date', ''),
'attachments': decode_attachment_names(msg.get('X-Attachments', ''))
})
messages.reverse()
return messages, page * per_page < total
finally:
conn.logout()
def read_message(email_addr, password, uid, folder='INBOX'):
conn = imap_connect(email_addr, password)
try:
conn.select(folder)
result, data = conn.uid('fetch', uid, '(RFC822)')
if result != 'OK':
return None
raw_email = data[0][1]
msg = email.message_from_bytes(raw_email)
return {
'uid': uid,
'body': extract_email_body(msg),
'subject': decode_mime_str(msg.get('Subject', '')),
'from': decode_mime_str(msg.get('From', '')),
'to': decode_mime_str(msg.get('To', '')),
'date': msg.get('Date', ''),
'attachments': decode_attachment_names(msg.get('X-Attachments', ''))
}
finally:
conn.logout()
def list_sent(email_addr, password, page=1, per_page=20):
conn = imap_connect(email_addr, password)
try:
sent_folder = find_sent_folder(conn)
if not sent_folder:
return [], False
try:
conn.select(sent_folder)
except Exception:
return [], False
result, data = conn.uid('search', None, "ALL")
if result != 'OK':
return [], False
uid_list = data[0].split()
total = len(uid_list)
start = max(0, total - page * per_page)
end = total - (page - 1) * per_page
page_uids = uid_list[start:end] if start < end else []
messages = []
for uid in page_uids:
result, msg_data = conn.uid('fetch', uid, '(BODY.PEEK[HEADER])')
if result != 'OK':
continue
raw_header = msg_data[0][1]
msg = email.message_from_bytes(raw_header)
messages.append({
'uid': uid.decode() if isinstance(uid, bytes) else uid,
'from': decode_mime_str(msg.get('From', '')),
'to': decode_mime_str(msg.get('To', '')),
'subject': decode_mime_str(msg.get('Subject', '')),
'date': msg.get('Date', ''),
'attachments': decode_attachment_names(msg.get('X-Attachments', ''))
})
messages.reverse()
return messages, page * per_page < total
finally:
conn.logout()
def send_email(email_addr, password, to_addr, subject, body):
smtp = smtp_connect(email_addr, password)
try:
msg = MIMEMultipart()
msg['From'] = email_addr
msg['To'] = to_addr
msg['Subject'] = subject
msg.attach(MIMEText(body, 'html', 'utf-8'))
smtp.send_message(msg)
smtp.quit()
# 保存到已发送
imap_conn = imap_connect(email_addr, password)
try:
sent_folder = find_sent_folder(imap_conn)
if sent_folder:
try:
imap_conn.select(sent_folder)
except Exception:
try:
imap_conn.create(sent_folder)
imap_conn.select(sent_folder)
except Exception:
return True
imap_conn.append(sent_folder, '\\Seen',
imaplib.Time2Internaldate(time.time()),
msg.as_bytes())
imap_conn.close()
finally:
imap_conn.logout()
return True
except Exception as e:
return str(e)
def delete_message(email_addr, password, uid, folder='INBOX'):
conn = imap_connect(email_addr, password)
try:
conn.select(folder)
result, _ = conn.uid('store', uid, '+FLAGS', '\\Deleted')
if result != 'OK':
return False
conn.expunge()
return True
finally:
conn.logout()
# ============================================================
# MCP JSON-RPC 处理
# ============================================================
TOOLS = [
{
"name": "list_mailboxes",
"description": "列出 IMAP 邮箱中的所有文件夹",
"inputSchema": {
"type": "object",
"properties": {
"email": {"type": "string", "description": "邮箱地址"},
"password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"}
},
"required": ["email"]
}
},
{
"name": "list_messages",
"description": "列出指定文件夹中的邮件(默认 INBOX),支持分页",
"inputSchema": {
"type": "object",
"properties": {
"email": {"type": "string", "description": "邮箱地址"},
"password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"},
"folder": {"type": "string", "description": "文件夹名,默认 INBOX"},
"page": {"type": "integer", "description": "页码,从 1 开始,默认 1"},
"per_page": {"type": "integer", "description": "每页数量,默认 20"}
},
"required": ["email"]
}
},
{
"name": "read_message",
"description": "读取指定 UID 的邮件全文",
"inputSchema": {
"type": "object",
"properties": {
"email": {"type": "string", "description": "邮箱地址"},
"password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"},
"uid": {"type": "string", "description": "邮件 UID"},
"folder": {"type": "string", "description": "文件夹名,默认 INBOX"}
},
"required": ["email", "uid"]
}
},
{
"name": "list_sent",
"description": "列出已发送邮件,支持分页",
"inputSchema": {
"type": "object",
"properties": {
"email": {"type": "string", "description": "邮箱地址"},
"password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"},
"page": {"type": "integer", "description": "页码,从 1 开始,默认 1"},
"per_page": {"type": "integer", "description": "每页数量,默认 20"}
},
"required": ["email"]
}
},
{
"name": "send_email",
"description": "发送邮件(HTML 格式),发送后会保存到已发送文件夹",
"inputSchema": {
"type": "object",
"properties": {
"email": {"type": "string", "description": "发件人邮箱地址"},
"password": {"type": "string", "description": "发件人邮箱密码"},
"to": {"type": "string", "description": "收件人邮箱地址"},
"subject": {"type": "string", "description": "邮件主题"},
"body": {"type": "string", "description": "邮件正文(HTML 格式)"}
},
"required": ["email", "to", "subject", "body"]
}
},
{
"name": "delete_message",
"description": "删除指定 UID 的邮件",
"inputSchema": {
"type": "object",
"properties": {
"email": {"type": "string", "description": "邮箱地址"},
"password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"},
"uid": {"type": "string", "description": "邮件 UID"},
"folder": {"type": "string", "description": "文件夹名,默认 INBOX"}
},
"required": ["email", "uid"]
}
}
]
TOOL_MAP = {
"list_mailboxes": list_mailboxes,
"list_messages": list_messages,
"read_message": read_message,
"list_sent": list_sent,
"send_email": send_email,
"delete_message": delete_message
}
def resolve_password(arguments):
if arguments.get('password'):
return arguments['password']
pw = os.environ.get('ROS_MAIL_MCP_PASSWORD', '')
if pw:
return pw
return None
def handle_request(req):
method = req.get("method", "")
req_id = req.get("id")
if method == "initialize":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "ros-mail-mcp",
"version": "1.0.0"
}
}
}
if method == "ping":
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
if method == "notifications/initialized":
return None
if method == "tools/list":
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
if method == "tools/call":
tool_name = req["params"]["name"]
arguments = req["params"].get("arguments", {})
tool_func = TOOL_MAP.get(tool_name)
if not tool_func:
return {"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32601, "message": f"未知工具: {tool_name}"}}
password = resolve_password(arguments)
if not password:
return {"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32602, "message": "缺少密码: 请在参数中传入 password 或设置环境变量 ROS_MAIL_MCP_PASSWORD"}}
arguments['password'] = password
# 参数名映射:schema 中的参数名 -> Python 函数参数名
if 'email' in arguments and 'email_addr' not in arguments:
arguments['email_addr'] = arguments.pop('email')
if 'to' in arguments and 'to_addr' not in arguments:
arguments['to_addr'] = arguments.pop('to')
try:
result = tool_func(**arguments)
if isinstance(result, tuple):
result = {"data": result[0], "has_more": result[1]}
elif isinstance(result, list):
result = {"mailboxes": result}
elif isinstance(result, dict):
pass
elif isinstance(result, bool):
result = {"success": result}
text = json.dumps(result, ensure_ascii=False, indent=2)
return {"jsonrpc": "2.0", "id": req_id,
"result": {"content": [{"type": "text", "text": text}]}}
except Exception as e:
return {"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32000, "message": str(e)}}
return {"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32601, "message": f"未知方法: {method}"}}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
resp = handle_request(req)
if resp is None:
continue
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + '\n')
sys.stdout.flush()
except json.JSONDecodeError:
pass
if __name__ == '__main__':
main()