Initial import of Shirobana bot backend

Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
RRRosmontis
2026-11-11 00:25:30 +08:00
commit 34089d0042
106 changed files with 28028 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
"""Authenticated, deliberately limited hMailServer operations for the remote bot."""
import base64
import binascii
import hmac
import logging
import os
import re
from flask import Blueprint, jsonify, request
import com_backend as backend
log = logging.getLogger(__name__)
bp = Blueprint('admin_com', __name__, url_prefix='/v1/mail/api/admin/com')
def _positive_env_int(name, default):
try:
value = int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
return value if value > 0 else default
MAX_REQUEST_BYTES = 1024 * 1024
ATTACHMENT_REQUEST_MAX_BYTES = _positive_env_int(
'MAIL_ATTACHMENT_REQUEST_MAX_BYTES', 40 * 1024 * 1024)
MAX_ATTACHMENT_COUNT = _positive_env_int('MAIL_ATTACHMENT_MAX_COUNT', 10)
MAX_ATTACHMENT_BYTES = _positive_env_int(
'MAIL_ATTACHMENT_MAX_FILE_BYTES', 20 * 1024 * 1024)
MAX_ATTACHMENT_TOTAL_BYTES = _positive_env_int(
'MAIL_ATTACHMENT_MAX_TOTAL_BYTES', 25 * 1024 * 1024)
@bp.before_request
def authenticate():
token = backend.CONFIG.get('token', '')
supplied = request.headers.get('Authorization', '')
if len(token) < 64 or not hmac.compare_digest(supplied.encode(), ('Bearer '+token).encode()):
return jsonify(error='unauthorized'), 401
is_attachment_send = request.endpoint == 'admin_com.send_with_attachments'
limit = ATTACHMENT_REQUEST_MAX_BYTES if is_attachment_send else MAX_REQUEST_BYTES
# 新端点拒绝无 Content-Length 的流式请求,避免绕过入口大小限制;
# 其他端点保留原有 1 MiB 行为和响应格式。
if is_attachment_send and request.content_length is None:
return jsonify(error='content length required'), 411
if request.content_length and request.content_length > limit:
return jsonify(error='request too large'), 413
@bp.after_request
def no_cache(response):
response.headers['Cache-Control'] = 'no-store'
return response
def address(value, local=True):
if not isinstance(value, str) or not re.fullmatch(r'[^\s<>@,;]+@[^\s<>@,;]+', value):
raise ValueError('invalid address')
value = value.lower()
if local and value.rsplit('@', 1)[1] not in backend.CONFIG['allowed_domains']:
raise ValueError('unsupported domain')
return value
def integer(value, low, high):
if isinstance(value, bool): raise ValueError('invalid integer')
value = int(value)
if value < low or value > high: raise ValueError('integer out of range')
return value
def attachment_filename(value):
"""只接受 MIME 显示文件名,绝不接受客户端提供的服务器路径。"""
if not isinstance(value, str) or not value or len(value) > 255:
raise ValueError('invalid attachment filename')
if value != value.strip() or value in ('.', '..'):
raise ValueError('invalid attachment filename')
if len(value.encode('utf-8')) > 1024:
raise ValueError('invalid attachment filename')
if any(ord(ch) < 32 or ord(ch) == 127 or ch in '<>:"/\\|?*' for ch in value):
raise ValueError('invalid attachment filename')
return value
def attachment_content_type(value):
# 仅 type/subtype,不含参数;同时排除 CR/LF 与其他头部注入字符。
if not isinstance(value, str) or len(value) > 127:
raise ValueError('invalid attachment MIME type')
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*/"
r"[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*", value):
raise ValueError('invalid attachment MIME type')
return value.lower()
def incoming_attachments(value):
if not isinstance(value, list) or not 1 <= len(value) <= MAX_ATTACHMENT_COUNT:
raise ValueError('invalid attachment count')
result, total = [], 0
max_encoded_size = 4 * ((MAX_ATTACHMENT_BYTES + 2) // 3)
for item in value:
# 白名单字段可防止把 path / filename_on_server 等字段带入后端。
if not isinstance(item, dict) or set(item) != {'filename', 'content_type', 'data_base64'}:
raise ValueError('invalid attachment')
filename = attachment_filename(item['filename'])
content_type = attachment_content_type(item['content_type'])
encoded = item['data_base64']
if not isinstance(encoded, str) or len(encoded) > max_encoded_size:
raise ValueError('attachment too large')
try:
content = base64.b64decode(encoded.encode('ascii'), validate=True)
except (UnicodeError, binascii.Error, ValueError):
raise ValueError('invalid attachment base64')
if len(content) > MAX_ATTACHMENT_BYTES:
raise ValueError('attachment too large')
total += len(content)
if total > MAX_ATTACHMENT_TOTAL_BYTES:
raise ValueError('attachments too large')
result.append({'filename': filename, 'content_type': content_type,
'content': content})
return result, total
def item(m):
return {'id': int(m.ID), 'subject': (m.Subject or '').strip() or '(无主题)',
'from': (m.From or '').strip(), 'from_address': (m.FromAddress or '').strip(),
'ts': backend._message_ts(m)}
@bp.route('/status', methods=['GET'])
def status():
with backend._ComSession() as app:
return jsonify(ok=True, version=app.Version)
@bp.route('/inbox', methods=['POST'])
def inbox():
data = request.get_json()
addr = address(data.get('address'))
limit = integer(data.get('limit', 10), 1, 100)
window = data.get('window_seconds')
if window is not None: window = integer(window, 0, 315360000)
with backend._ComSession() as app:
result = backend._list_inbox_messages(app, addr, window, limit)
return jsonify(items=result)
@bp.route('/message', methods=['POST'])
def message():
data = request.get_json()
addresses = data.get('addresses')
if not isinstance(addresses, list) or not 1 <= len(addresses) <= 30:
raise ValueError('invalid address list')
addresses = [address(a) for a in addresses]
mid = integer(data.get('id'), 1, 2**63-1)
with backend._ComSession() as app:
m = backend._find_message_by_id(app, addresses, mid)
if m is None: return jsonify(message=None)
images, attachments = backend._analyze_message(m)
result = dict(ID=int(m.ID), Subject=m.Subject or '', From=m.From or '',
FromAddress=m.FromAddress or '', Date=str(m.Date or ''),
Body=m.Body or '', HTMLBody=m.HTMLBody or '',
image_count=images, attachment_names=attachments)
return jsonify(message=result)
@bp.route('/changes', methods=['POST'])
def changes():
data = request.get_json()
addr = address(data.get('address'))
after = data.get('after')
if after is not None: after = integer(after, 0, 2**63-1)
limit = integer(data.get('limit', 10), 1, 100)
with backend._ComSession() as app:
msgs = backend._get_account(app, addr).IMAPFolders.ItemByName('INBOX').Messages
found, max_id = [], 0
for idx in range(msgs.Count):
m = msgs[idx]
mid = int(m.ID)
max_id = max(max_id, mid)
if after is not None and mid > after: found.append(item(m))
found.sort(key=lambda v: v['id'])
return jsonify(max_id=max_id, items=found[:limit])
def outgoing(data=None):
if data is None:
data = request.get_json()
sender, recipient = address(data.get('from_address')), address(data.get('to_address'), local=False)
subject, body = data.get('subject'), data.get('body_text')
if not isinstance(subject, str) or len(subject) > 2000 or '\r' in subject or '\n' in subject:
raise ValueError('invalid subject')
if not isinstance(body, str) or len(body) > 200000: raise ValueError('invalid body')
return sender, recipient, subject, body
@bp.route('/send', methods=['POST'])
def send():
args = outgoing()
with backend._ComSession() as app:
backend._get_account(app, args[0])
backend._send_mail(app, *args)
return jsonify(accepted=True)
@bp.route('/send_with_attachments', methods=['POST'])
def send_with_attachments():
data = request.get_json()
if not isinstance(data, dict):
raise ValueError('invalid request')
args = outgoing(data)
if not args[2].strip():
raise ValueError('invalid subject')
attachments, total = incoming_attachments(data.get('attachments'))
with backend._ComSession() as app:
backend._get_account(app, args[0])
sent_saved = backend._send_mail_with_attachments(app, *args, attachments)
return jsonify(ok=True, accepted=True, sent_saved=sent_saved,
attachment_names=[a['filename'] for a in attachments],
size=total)
@bp.route('/sent', methods=['POST'])
def sent():
args = outgoing()
with backend._ComSession() as app:
backend._save_to_sent(app, *args)
return jsonify(ok=True)
@bp.errorhandler(Exception)
def error(exc):
from werkzeug.exceptions import HTTPException
if isinstance(exc, HTTPException): return jsonify(error=exc.name), exc.code
if isinstance(exc, (ValueError, TypeError, AttributeError)):
return jsonify(error='invalid request'), 400
log.exception('COM operation failed')
return jsonify(error='mail backend unavailable'), 502
def install(app):
app.register_blueprint(bp)
+5
View File
@@ -0,0 +1,5 @@
{
"hmail_admin_password": "",
"token": "",
"allowed_domains": ["example.com"]
}
+275
View File
@@ -0,0 +1,275 @@
# COM helpers preserved from the current Windows utility bot.
import os, json, time, email
import smtplib
import tempfile
from pathlib import Path
from email import encoders
from email.header import Header
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid, parsedate_to_datetime
CONFIG_PATH = Path(os.environ.get("MAIL_COM_CONFIG", str(Path(__file__).with_name("com-private.json"))))
CONFIG = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
HMAIL_ADMIN_PASSWORD = CONFIG["hmail_admin_password"]
LIST_MAX = 10
class _ComSession:
"""线程内 COM 会话:CoInitialize + Dispatch + Authenticate,退出时 CoUninitialize。"""
def __enter__(self):
import pythoncom
import win32com.client
self._pycom = pythoncom
pythoncom.CoInitialize()
try:
app = win32com.client.Dispatch("hMailServer.Application")
except Exception:
pythoncom.CoUninitialize()
raise
if not app.Authenticate("Administrator", HMAIL_ADMIN_PASSWORD):
pythoncom.CoUninitialize()
raise RuntimeError("hMailServer 管理员认证失败")
self.app = app
return app
def __exit__(self, *exc):
self._pycom.CoUninitialize()
return False
def _get_account(app, address):
domain_name = address.split("@", 1)[1]
domain = app.Domains.ItemByName(domain_name)
return domain.Accounts.ItemByAddress(address)
def _message_ts(m):
for attr in ("InternalDate", "Date"):
try:
v = getattr(m, attr)
except Exception:
continue
if v is None:
continue
if isinstance(v, str):
try:
return parsedate_to_datetime(v).timestamp()
except Exception:
continue
else: # datetime-like
try:
return v.timestamp()
except Exception:
continue
return 0.0
def _decode_mime(s):
if not s:
return ""
try:
from email.header import decode_header
parts = decode_header(s)
out = []
for p, cs in parts:
if isinstance(p, bytes):
out.append(p.decode(cs or "utf-8", "replace"))
else:
out.append(p)
return "".join(out)
except Exception:
return s
def _analyze_message(m):
"""解析 .eml,返回 (image_count, attachment_names)。"""
image_count = 0
attachment_names = []
try:
fn = m.Filename
if not fn or not os.path.exists(fn):
return image_count, attachment_names
with open(fn, "rb") as f:
msg = email.message_from_bytes(f.read())
for part in msg.walk():
if part.is_multipart():
continue
ctype = (part.get_content_type() or "").lower()
disp = (part.get("Content-Disposition") or "").lower()
cid = part.get("Content-ID")
filename = part.get_filename()
if ctype.startswith("image/") or cid:
image_count += 1
continue
if "attachment" in disp or filename:
name = _decode_mime(filename) if filename else ""
attachment_names.append(name or "(未命名附件)")
except Exception:
pass
return image_count, attachment_names
def _list_inbox_messages(app, address, window_seconds=None, limit=LIST_MAX):
account = _get_account(app, address)
inbox = account.IMAPFolders.ItemByName("INBOX")
msgs = inbox.Messages
items = []
now = time.time()
for i in range(msgs.Count):
m = msgs[i]
ts = _message_ts(m)
if window_seconds is not None and (now - ts) > window_seconds:
continue
items.append({
"id": int(m.ID),
"subject": (m.Subject or "").strip() or "(无主题)",
"from": (m.From or "").strip(),
"from_address": (m.FromAddress or "").strip(),
"ts": ts,
})
items.sort(key=lambda x: x["ts"], reverse=True)
return items[:limit]
def _find_message_by_id(app, addresses, msg_id):
for addr in addresses:
try:
account = _get_account(app, addr)
inbox = account.IMAPFolders.ItemByName("INBOX")
return inbox.Messages.ItemByDBID(int(msg_id))
except Exception:
continue
return None
def _send_mail(app, from_address, to_address, subject, body_text):
import win32com.client
msg = win32com.client.Dispatch("hMailServer.Message")
msg.From = f"{from_address.split('@', 1)[0]} <{from_address}>"
msg.FromAddress = from_address
msg.Subject = subject
msg.Body = body_text
msg.AddRecipient("", to_address)
msg.Save()
def _build_attachment_mime(from_address, to_address, subject, body_text, attachments):
"""构造与 mailWeb.py 相同风格的 multipart/mixed UTF-8 邮件。
attachments 中只含已经解码的 bytes,绝不含服务器文件路径。
"""
msg = MIMEMultipart("mixed")
msg["From"] = f"{from_address.split('@', 1)[0]} <{from_address}>"
msg["To"] = to_address
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid()
msg.attach(MIMEText(body_text, "plain", "utf-8"))
names = []
for attachment in attachments:
filename = attachment["filename"]
content_type = attachment["content_type"]
maintype, subtype = content_type.split("/", 1)
part = MIMEBase(maintype, subtype)
part.set_payload(attachment["content"])
encoders.encode_base64(part)
# email 包会对中文文件名生成 RFC 2231 编码参数。
part.add_header("Content-Disposition", "attachment", filename=filename)
msg.attach(part)
names.append(filename)
if names:
# 保留 mailWeb.py 的兼容元数据;真正的附件仍以标准 MIME part 为准。
msg["X-Attachments"] = ",".join(str(Header(name, "utf-8")) for name in names)
return msg
def _deliver_mime(from_address, to_address, mime_bytes):
"""经本机 hMailServer SMTP 投递原始 MIME,避免猜测 COM 原始 MIME 属性。
Windows 实机验证点:hMailServer 的 127.0.0.1 IP range 必须允许本机
发件账号向目标地址投递;如生产使用 SSL/STARTTLS/认证,在 com-private.json
配置 smtp_ssl / smtp_starttls / smtp_username / smtp_password。
"""
host = CONFIG.get("smtp_host", "127.0.0.1")
port = int(CONFIG.get("smtp_port", 465 if CONFIG.get("smtp_ssl") else 25))
timeout = float(CONFIG.get("smtp_timeout", 30))
smtp_class = smtplib.SMTP_SSL if CONFIG.get("smtp_ssl") else smtplib.SMTP
with smtp_class(host, port, timeout=timeout) as smtp:
if CONFIG.get("smtp_starttls"):
smtp.starttls()
username = CONFIG.get("smtp_username")
if username:
smtp.login(username, CONFIG.get("smtp_password", ""))
refused = smtp.sendmail(from_address, [to_address], mime_bytes)
if refused:
raise RuntimeError("hMailServer SMTP 未接受全部收件人")
def _save_attachment_copy_to_sent(app, from_address, to_address, subject,
body_text, attachments):
"""best-effort 保存同一逻辑邮件(正文及附件)到 Sent,不再次投递。
hMailServer 5.6 COM Message 没有公开的原始 MIME 写入接口;这里只使用
文档化的 Messages.Add / Attachments.Add。附件先落在后端自建的临时目录,
文件名已经过端点校验,客户端不能指定任何服务器路径。COM 生成的 Sent
MIME 可能与线上 MIME 的边界、日期等字节不同。
Windows 实机验证点:确认 Attachments.Add 能读取服务账号临时目录,且
中文附件名在 Sent 和 WebMail 中显示正确。
"""
try:
account = _get_account(app, from_address)
try:
sent = account.IMAPFolders.ItemByName("Sent")
except Exception:
return False
with tempfile.TemporaryDirectory(prefix="mail-attachments-") as tmp:
m = sent.Messages.Add()
m.From = f"{from_address.split('@', 1)[0]} <{from_address}>"
m.FromAddress = from_address
m.Subject = subject
m.Body = body_text
m.AddRecipient("", to_address)
for index, attachment in enumerate(attachments):
entry_dir = os.path.join(tmp, str(index))
os.mkdir(entry_dir)
disk_path = os.path.join(entry_dir, attachment["filename"])
with open(disk_path, "wb") as fh:
fh.write(attachment["content"])
m.Attachments.Add(disk_path)
m.Save()
return True
except Exception:
return False
def _send_mail_with_attachments(app, from_address, to_address, subject,
body_text, attachments):
"""投递附件邮件并 best-effort 保存 Sent;无附件沿用旧 COM 路径。"""
if not attachments:
_send_mail(app, from_address, to_address, subject, body_text)
_save_to_sent(app, from_address, to_address, subject, body_text)
return None
msg = _build_attachment_mime(from_address, to_address, subject,
body_text, attachments)
_deliver_mime(from_address, to_address, msg.as_bytes())
return _save_attachment_copy_to_sent(
app, from_address, to_address, subject, body_text, attachments)
def _save_to_sent(app, from_address, to_address, subject, body_text):
"""把发件副本写入发件人账号的 Sent 文件夹(best-effort,不投递)。"""
try:
account = _get_account(app, from_address)
try:
sent = account.IMAPFolders.ItemByName("Sent")
except Exception:
return
m = sent.Messages.Add()
m.From = f"{from_address.split('@', 1)[0]} <{from_address}>"
m.FromAddress = from_address
m.Subject = subject
m.Body = body_text
try:
m.AddRecipient("", to_address)
except Exception:
pass
m.Save()
except Exception:
pass
+971
View File
@@ -0,0 +1,971 @@
import os
import secrets
import imaplib
import smtplib
import uuid
import time
import email
import re
import ssl # 新增:用于创建忽略证书的SSL上下文
import base64
import json
from email.header import decode_header, Header
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from flask import Flask, request, jsonify, Response # 增加 Response
from flask_cors import CORS
app = Flask(__name__)
app.secret_key = os.environ.get('MAIL_FLASK_SECRET_KEY', '') or secrets.token_hex(32)
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 上传上限 100MB
CORS(app)
# ========== 修改点1:改为本地地址 ==========
IMAP_HOST = '127.0.0.1'
IMAP_PORT = 993
SMTP_HOST = '127.0.0.1'
SMTP_PORT = 465
TOKEN_MAP = {}
TOKEN_EXPIRE = 30 * 24 * 3600 # 30 天;滑动过期:每次使用自动续期,闲置超 30 天才失效
# ========== 新增:注册配置 ==========
REGISTER_AUTH_CODE = os.environ.get('MAIL_REGISTER_AUTH_CODE', '') # 注册认证码(由环境注入)
HMAIL_ADMIN_PASSWORD = os.environ.get('HMAIL_ADMIN_PASSWORD', '') # hMailServer 管理员密码(由环境注入)
# ========== 新增:Autodiscover 配置 ==========
AUTODISCOVER_DOMAINS = ['rrrosmontis.icu', 'aeolianchi.me'] # 允许 Autodiscover 的邮箱域名列表
MAIL_SERVER = 'mail.rrrosmontis.icu' # 真实的邮件服务器地址(公网可解析)
IMAP_SSL_PORT = 993
POP_SSL_PORT = 995
SMTP_SSL_PORT = 465
def decode_mime_str(s):
"""通用解码邮件头字段(如 Subject, From 等)"""
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):
"""
解码 X-Attachments 头部中的附件名列表。
原始字符串可能包含多个 MIME 编码片段,用逗号分隔。
例如: "=?utf-8?b?5Lit5paH?=,=?utf-8?b?dGVzdC50eHQ=?="
返回解码后的普通文件名列表。
"""
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 generate_token(email, password):
token = uuid.uuid4().hex
TOKEN_MAP[token] = {'email': email, 'password': password, 'created': time.time()}
return token
def validate_token(token):
data = TOKEN_MAP.get(token)
if not data:
return None
if time.time() - data['created'] > TOKEN_EXPIRE:
del TOKEN_MAP[token]
return None
# 滑动续期:一旦使用即自动续 30 天
data['created'] = time.time()
return data['email'], data['password']
# ========== 修改点2:忽略SSL证书验证 ==========
def imap_login(email, password):
try:
context = ssl._create_unverified_context()
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context)
conn.login(email, password)
return conn
except Exception as e:
print(f"IMAP login error: {e}")
return None
# ========== 修改点3:SMTP同样忽略证书 ==========
def smtp_login(email, password):
try:
context = ssl._create_unverified_context()
smtp = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context)
smtp.ehlo()
smtp.login(email, password)
return smtp
except Exception as e:
print(f"SMTP login error: {e}")
return None
def extract_email_body(msg):
body = ""
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:
body = payload.decode(charset, errors='replace')
except:
body = payload.decode('utf-8', errors='replace')
return body
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:
body = payload.decode(charset, errors='replace')
except:
body = payload.decode('utf-8', errors='replace')
return body
else:
payload = msg.get_payload(decode=True)
charset = msg.get_content_charset() or 'utf-8'
try:
body = payload.decode(charset, errors='replace')
except:
body = payload.decode('utf-8', errors='replace')
return body
def build_cid_map(msg):
"""遍历 MIME,构建 {Content-ID: data URL},用于内嵌图片(cid:)的渲染。"""
cid_map = {}
for part in msg.walk():
cid = part.get("Content-ID")
if not cid:
continue
cid = cid.strip().strip("<>").strip()
if cid.lower().startswith("cid:"):
cid = cid[4:].strip()
if not cid:
continue
payload = part.get_payload(decode=True)
if not payload:
continue
ctype = part.get_content_type() or "application/octet-stream"
b64 = base64.b64encode(payload).decode("ascii")
cid_map[cid] = f"data:{ctype};base64,{b64}"
return cid_map
def replace_cid_with_data(body, cid_map):
"""把 HTML 正文里的 cid:xxx 替换为 data URL。"""
def _repl(m):
return cid_map.get(m.group(1), m.group(0))
return re.sub(r"cid:([A-Za-z0-9._\-@]+)", _repl, body)
# ========== 新增:Autodiscover 辅助函数 ==========
def extract_email_from_autodiscover_request(request_data: str) -> str | None:
"""从 Autodiscover 请求的 XML 中提取邮箱地址"""
match = re.search(r'<EMailAddress>(.*?)</EMailAddress>', request_data, re.IGNORECASE)
if match:
return match.group(1).strip()
return None
def generate_autodiscover_xml(email: str) -> str:
"""生成符合 Microsoft 规范的 Autodiscover 响应 XML"""
username = email
display_name = email.split('@')[0]
return f'''<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
<Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
<User>
<DisplayName>{display_name}</DisplayName>
</User>
<Account>
<AccountType>email</AccountType>
<Action>settings</Action>
<Protocol>
<Type>IMAP</Type>
<Server>{MAIL_SERVER}</Server>
<Port>{IMAP_SSL_PORT}</Port>
<LoginName>{username}</LoginName>
<SSL>on</SSL>
<Authentication>password</Authentication>
</Protocol>
<Protocol>
<Type>POP3</Type>
<Server>{MAIL_SERVER}</Server>
<Port>{POP_SSL_PORT}</Port>
<LoginName>{username}</LoginName>
<SSL>on</SSL>
<Authentication>password</Authentication>
</Protocol>
<Protocol>
<Type>SMTP</Type>
<Server>{MAIL_SERVER}</Server>
<Port>{SMTP_SSL_PORT}</Port>
<LoginName>{username}</LoginName>
<SSL>on</SSL>
<Authentication>password</Authentication>
<UsePOPAuth>off</UsePOPAuth>
<UseSPA>off</UseSPA>
</Protocol>
</Account>
</Response>
</Autodiscover>'''
# ========== 新增:Autodiscover 端点 ==========
@app.route('/autodiscover/autodiscover.xml', methods=['GET', 'POST'])
def autodiscover():
"""Microsoft Outlook / Exchange 自动发现端点"""
email = None
if request.method == 'GET':
email = request.args.get('Email')
else: # POST
body = request.get_data(as_text=True)
email = extract_email_from_autodiscover_request(body)
if not email:
return Response("Missing or invalid email address", status=400, mimetype='text/plain')
# 仅允许自己的域名
if not any(email.lower().endswith('@' + d) for d in AUTODISCOVER_DOMAINS):
return Response("Domain not allowed", status=403, mimetype='text/plain')
xml_response = generate_autodiscover_xml(email)
return Response(xml_response, mimetype='text/xml', status=200)
# ========== 原有路由 ==========
@app.route('/')
def index():
return jsonify({
'name': 'Royirin Rosmontis Mail API',
'endpoints': [
'/api/login [POST]',
'/api/register [POST]',
'/api/delete_account [POST]',
'/api/mailboxes [GET]',
'/api/messages [GET]',
'/api/message/<uid> [GET]',
'/api/send [POST]',
'/api/logout [POST]',
'/api/sent [GET]',
'/api/delete/<uid> [POST]'
]
})
@app.route('/v1/mail/api/login', methods=['POST'])
def api_login():
data = request.get_json()
email = data.get('email', '').strip()
password = data.get('password', '')
if not email or not password:
return jsonify({'error': '邮箱和密码不能为空'}), 400
imap = imap_login(email, password)
if imap:
imap.logout()
token = generate_token(email, password)
return jsonify({'token': token, 'email': email})
else:
return jsonify({'error': '邮箱或密码错误'}), 401
# ========== ROSMANKA ID SSO 集成 ==========
SSO_API_BASE = 'http://127.0.0.1:4550/v1/sso'
SSO_SECRETS_PATH = 'C:/call/backends/sso/secrets.json'
def _sso_service_token():
"""读取本机 SSO 的服务共享密钥(单一密钥源,不落代码)。"""
try:
with open(SSO_SECRETS_PATH, 'r', encoding='utf-8') as f:
return json.load(f).get('service_tokens', {}).get('mail', '')
except Exception:
return ''
@app.route('/v1/mail/api/sso_exchange', methods=['POST'])
def api_sso_exchange():
"""SSO 会话 token → mail token。
流程:SSO 验会话 + 检查 mail 服务 tier>=1 → 取回托管的邮箱凭据 → IMAP 复核 → 发 mail token。
"""
data = request.get_json(silent=True) or {}
sso_token = (data.get('sso_token') or '').strip()
if not sso_token:
return jsonify({'error': '缺少 sso_token'}), 400
try:
import requests as _rq
r = _rq.post(SSO_API_BASE + '/service/exchange',
json={'service': 'mail', 'token': sso_token},
headers={'X-SSO-Service-Token': _sso_service_token()}, timeout=10)
try:
d = r.json()
except Exception:
d = {}
except Exception:
return jsonify({'error': 'SSO 服务不可达'}), 502
if r.status_code != 200 or not d.get('ok'):
return jsonify({'error': d.get('error') or 'SSO 认证失败'}), (r.status_code if r.status_code != 200 else 401)
email_addr = d['external_id']
password = d['external_secret']
imap = imap_login(email_addr, password)
if not imap:
return jsonify({'error': '邮箱凭据校验失败,请联系管理员'}), 502
imap.logout()
token = generate_token(email_addr, password)
return jsonify({'token': token, 'email': email_addr, 'sso': True})
def create_hmail_account(email, password):
"""通过 hMailServer COM API 创建邮箱账户"""
try:
import win32com.client
import pythoncom
except ImportError:
return False, '服务器缺少 pywin32,无法创建账户'
if not HMAIL_ADMIN_PASSWORD or HMAIL_ADMIN_PASSWORD == 'CHANGE_ME':
return False, 'HMAIL_ADMIN_PASSWORD 未配置,请联系管理员'
pythoncom.CoInitialize()
try:
app = win32com.client.Dispatch("hMailServer.Application")
if not app.Authenticate("Administrator", HMAIL_ADMIN_PASSWORD):
return False, 'hMailServer 管理员认证失败'
domain_name = email.split('@')[1]
try:
domain = app.Domains.ItemByName(domain_name)
except Exception:
return False, f'域名 {domain_name} 不存在'
try:
existing = domain.Accounts.ItemByAddress(email)
if existing:
return False, '该邮箱已存在'
except Exception:
pass
account = domain.Accounts.Add()
account.Address = email
account.Password = password
account.Active = True
account.Save()
return True, None
except Exception as e:
return False, f'创建账户失败: {str(e)}'
finally:
pythoncom.CoUninitialize()
def delete_hmail_account(email):
"""通过 hMailServer COM API 删除邮箱账户"""
try:
import win32com.client
import pythoncom
except ImportError:
return False, '服务器缺少 pywin32,无法删除账户'
if not HMAIL_ADMIN_PASSWORD or HMAIL_ADMIN_PASSWORD == 'CHANGE_ME':
return False, 'HMAIL_ADMIN_PASSWORD 未配置,请联系管理员'
pythoncom.CoInitialize()
try:
app = win32com.client.Dispatch("hMailServer.Application")
if not app.Authenticate("Administrator", HMAIL_ADMIN_PASSWORD):
return False, 'hMailServer 管理员认证失败'
domain_name = email.split('@')[1]
try:
domain = app.Domains.ItemByName(domain_name)
except Exception:
return False, f'域名 {domain_name} 不存在'
try:
account = domain.Accounts.ItemByAddress(email)
except Exception:
return False, '该邮箱账户不存在'
account.Delete()
return True, None
except Exception as e:
return False, f'删除账户失败: {str(e)}'
finally:
pythoncom.CoUninitialize()
@app.route('/v1/mail/api/register', methods=['POST'])
def api_register():
# 注册已收归 ROSMANKA ID SSO(邀请码制),此端点停用
return jsonify({'error': '注册已迁移至统一认证中心', 'sso': 'https://sso.rrrosmontis.icu/register.html'}), 410
@app.route('/v1/mail/api/delete_account', methods=['POST'])
def api_delete_account():
# 注销已收归 ROSMANKA ID SSO(一处注销处处死完),此端点停用
return jsonify({'error': '注销已迁移至统一认证中心(账户中心 → 注销账户)',
'sso': 'https://sso.rrrosmontis.icu/profile/'}), 410
def get_hmail_forward(email):
"""通过 hMailServer COM API 获取账户的邮件转发配置"""
try:
import win32com.client
import pythoncom
except ImportError:
return None, '服务器缺少 pywin32,无法获取转发配置'
if not HMAIL_ADMIN_PASSWORD or HMAIL_ADMIN_PASSWORD == 'CHANGE_ME':
return None, 'HMAIL_ADMIN_PASSWORD 未配置,请联系管理员'
pythoncom.CoInitialize()
try:
app = win32com.client.Dispatch("hMailServer.Application")
if not app.Authenticate("Administrator", HMAIL_ADMIN_PASSWORD):
return None, 'hMailServer 管理员认证失败'
domain_name = email.split('@')[1]
try:
domain = app.Domains.ItemByName(domain_name)
except Exception:
return None, f'域名 {domain_name} 不存在'
try:
account = domain.Accounts.ItemByAddress(email)
except Exception:
return None, '该邮箱账户不存在'
return {'enabled': bool(account.ForwardEnabled), 'address': account.ForwardAddress or '',
'keep_original': bool(account.ForwardKeepOriginal)}, None
except Exception as e:
return None, f'获取转发配置失败: {str(e)}'
finally:
pythoncom.CoUninitialize()
def set_hmail_forward(email, enabled, address):
"""通过 hMailServer COM API 设置账户的邮件转发"""
try:
import win32com.client
import pythoncom
except ImportError:
return False, '服务器缺少 pywin32,无法设置转发配置'
if not HMAIL_ADMIN_PASSWORD or HMAIL_ADMIN_PASSWORD == 'CHANGE_ME':
return False, 'HMAIL_ADMIN_PASSWORD 未配置,请联系管理员'
pythoncom.CoInitialize()
try:
app = win32com.client.Dispatch("hMailServer.Application")
if not app.Authenticate("Administrator", HMAIL_ADMIN_PASSWORD):
return False, 'hMailServer 管理员认证失败'
domain_name = email.split('@')[1]
try:
domain = app.Domains.ItemByName(domain_name)
except Exception:
return False, f'域名 {domain_name} 不存在'
try:
account = domain.Accounts.ItemByAddress(email)
except Exception:
return False, '该邮箱账户不存在'
account.ForwardEnabled = bool(enabled)
account.ForwardKeepOriginal = True # 转发同时保留本地原件
if enabled and address:
account.ForwardAddress = address
account.Save()
return True, None
except Exception as e:
return False, f'设置转发配置失败: {str(e)}'
finally:
pythoncom.CoUninitialize()
@app.route('/v1/mail/api/forward', methods=['GET', 'POST'])
def api_forward():
"""查询/设置账户邮件转发"""
if request.method == 'GET':
token = request.args.get('token', '')
if not token:
return jsonify({'error': '缺少token'}), 401
creds = validate_token(token)
if not creds:
return jsonify({'error': 'token无效或已过期'}), 401
email_addr, _ = creds
data, error = get_hmail_forward(email_addr)
if error:
return jsonify({'error': error}), 500
return jsonify(data)
data = request.get_json(silent=True) or {}
token = data.get('token', '')
enabled = bool(data.get('enabled', False))
address = (data.get('address') or '').strip()
if not token:
return jsonify({'error': '缺少token'}), 401
creds = validate_token(token)
if not creds:
return jsonify({'error': 'token无效或已过期'}), 401
email_addr, _ = creds
# 地址暂允许为空,后续补齐转发地址后再开启必填校验
success, error = set_hmail_forward(email_addr, enabled, address)
if not success:
return jsonify({'error': error}), 500
return jsonify({'success': True})
@app.route('/v1/mail/api/change_password', methods=['POST'])
def api_change_password():
# 改密已收归 ROSMANKA ID SSO(一处更改处处更改),此端点停用
return jsonify({'error': '修改密码已迁移至统一认证中心(账户中心 → 安全)',
'sso': 'https://sso.rrrosmontis.icu/profile/'}), 410
@app.route('/v1/mail/api/mailboxes', methods=['GET'])
def api_mailboxes():
token = request.args.get('token')
if not token:
return jsonify({'error': '缺少token'}), 401
if not validate_token(token):
return jsonify({'error': 'token无效或已过期'}), 401
return jsonify({'mailboxes': ['INBOX']})
@app.route('/v1/mail/api/messages', methods=['GET'])
def api_messages():
token = request.args.get('token')
folder = request.args.get('folder', 'INBOX')
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 20))
if not token:
return jsonify({'error': '缺少token'}), 401
creds = validate_token(token)
if not creds:
return jsonify({'error': 'token无效或已过期'}), 401
email_addr, password = creds
try:
context = ssl._create_unverified_context()
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context)
conn.login(email_addr, password)
conn.select(folder)
result, data = conn.uid('search', None, "ALL")
if result != 'OK':
return jsonify({'messages': [], 'has_more': False})
uid_list = data[0].split()
total = len(uid_list)
start = max(0, total - page * per_page)
end = total - (page - 1) * per_page
if start < 0:
start = 0
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)
from_ = decode_mime_str(msg.get('From', '未知'))
subject = decode_mime_str(msg.get('Subject', '无主题'))
date = msg.get('Date', '')
attachments_raw = msg.get('X-Attachments', '')
attachments = decode_attachment_names(attachments_raw)
messages.append({
'uid': uid.decode() if isinstance(uid, bytes) else uid,
'from': from_,
'subject': subject,
'date': date,
'attachments': attachments
})
conn.close()
conn.logout()
messages.reverse()
return jsonify({
'messages': messages,
'has_more': page * per_page < total
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/v1/mail/api/sent', methods=['GET'])
def api_sent():
token = request.args.get('token')
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 20))
if not token:
return jsonify({'error': '缺少token'}), 401
creds = validate_token(token)
if not creds:
return jsonify({'error': 'token无效或已过期'}), 401
email_addr, password = creds
conn = None
try:
context = ssl._create_unverified_context()
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context)
conn.login(email_addr, password)
typ, folder_data = conn.list()
if typ != 'OK':
return jsonify({'error': '无法获取文件夹列表'}), 500
sent_candidates = ['Sent', 'Sent Messages', 'Sent Items', '已发送', '已发送邮件']
sent_folder = None
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):
sent_folder = folder_name
break
if 'sent' in folder_name.lower() or '已发送' in folder_name:
sent_folder = folder_name
break
if not sent_folder:
sent_folder = 'Sent'
try:
conn.select(sent_folder)
except Exception:
return jsonify({'messages': [], 'has_more': False})
result, data = conn.uid('search', None, "ALL")
if result != 'OK':
return jsonify({'messages': [], 'has_more': False})
uid_list = data[0].split()
total = len(uid_list)
start = max(0, total - page * per_page)
end = total - (page - 1) * per_page
if start < 0:
start = 0
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)
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_raw = msg.get('X-Attachments', '')
attachments = decode_attachment_names(attachments_raw)
messages.append({
'uid': uid.decode() if isinstance(uid, bytes) else uid,
'from': from_,
'to': to,
'subject': subject,
'date': date,
'attachments': attachments
})
messages.reverse()
return jsonify({
'messages': messages,
'has_more': page * per_page < total
})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
if conn:
try:
conn.close()
except:
pass
conn.logout()
@app.route('/v1/mail/api/message/<uid>', methods=['GET'])
def api_message(uid):
token = request.args.get('token')
folder = request.args.get('folder', 'INBOX')
if not token:
return jsonify({'error': '缺少token'}), 401
creds = validate_token(token)
if not creds:
return jsonify({'error': 'token无效或已过期'}), 401
email_addr, password = creds
try:
context = ssl._create_unverified_context()
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context)
conn.login(email_addr, password)
conn.select(folder)
result, data = conn.uid('fetch', uid, '(RFC822)')
if result != 'OK':
return jsonify({'error': '邮件不存在'}), 404
raw_email = data[0][1]
msg = email.message_from_bytes(raw_email)
body = extract_email_body(msg)
cid_map = build_cid_map(msg)
if cid_map:
body = replace_cid_with_data(body, cid_map)
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_raw = msg.get('X-Attachments', '')
attachments = decode_attachment_names(attachments_raw)
conn.close()
conn.logout()
return jsonify({
'body': body,
'subject': subject,
'from': from_,
'to': to,
'date': date,
'attachments': attachments
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/v1/mail/api/attachment/<uid>', methods=['GET'])
def api_attachment(uid):
token = request.args.get('token')
folder = request.args.get('folder', 'INBOX')
filename_b64 = request.args.get('filename_b64', '')
if not token:
return jsonify({'error': '缺少token'}), 401
creds = validate_token(token)
if not creds:
return jsonify({'error': 'token无效或已过期'}), 401
if not filename_b64:
return jsonify({'error': '缺少filename_b64'}), 400
try:
filename = base64.b64decode(filename_b64).decode('utf-8')
except Exception:
return jsonify({'error': 'filename_b64解码失败'}), 400
email_addr, password = creds
try:
context = ssl._create_unverified_context()
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context)
conn.login(email_addr, password)
conn.select(folder)
result, data = conn.uid('fetch', uid, '(RFC822)')
if result != 'OK':
return jsonify({'error': '邮件不存在'}), 404
raw_email = data[0][1]
msg = email.message_from_bytes(raw_email)
for part in msg.walk():
disp = part.get('Content-Disposition', '')
if not disp or 'attachment' not in disp:
continue
raw_name = part.get_filename()
if not raw_name:
continue
decoded_name = decode_mime_str(raw_name)
if decoded_name == filename:
payload = part.get_payload(decode=True)
if payload is None:
continue
content_type = part.get_content_type() or 'application/octet-stream'
# 对中文文件名按 UTF-8 进行 RFC 5987 编码,确保下载名不乱码
from urllib.parse import quote
ascii_name = quote(filename, safe='')
headers = {
'Content-Disposition': f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{ascii_name}",
'Content-Type': content_type
}
conn.close()
conn.logout()
return Response(payload, headers=headers)
conn.close()
conn.logout()
return jsonify({'error': '附件不存在'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/v1/mail/api/delete/<uid>', methods=['POST'])
def api_delete(uid):
data = request.get_json()
token = data.get('token')
folder = data.get('folder', 'INBOX')
if not token:
return jsonify({'error': '缺少token'}), 401
creds = validate_token(token)
if not creds:
return jsonify({'error': 'token无效或已过期'}), 401
email_addr, password = creds
try:
context = ssl._create_unverified_context()
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context)
conn.login(email_addr, password)
conn.select(folder)
result, _ = conn.uid('store', uid, '+FLAGS', '\\Deleted')
if result != 'OK':
return jsonify({'error': '删除失败'}), 500
conn.expunge()
conn.close()
conn.logout()
return jsonify({'success': True})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/v1/mail/api/send', methods=['POST'])
def api_send():
if 'token' not in request.form:
return jsonify({'error': '缺少token'}), 400
token = request.form['token']
to_addr = request.form.get('to')
subject = request.form.get('subject')
body = request.form.get('body')
files = request.files.getlist('attachments')
if not all([token, to_addr, subject]):
return jsonify({'error': '参数不全'}), 400
body = body or ''
creds = validate_token(token)
if not creds:
return jsonify({'error': 'token无效或已过期'}), 401
email_addr, password = creds
try:
context = ssl._create_unverified_context()
smtp = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context)
smtp.ehlo()
smtp.login(email_addr, password)
msg = MIMEMultipart()
msg['From'] = email_addr
msg['To'] = to_addr
msg['Subject'] = subject
msg.attach(MIMEText(body, 'html', 'utf-8'))
attachment_names = []
for file in files:
if file.filename:
part = MIMEBase('application', 'octet-stream')
part.set_payload(file.read())
encoders.encode_base64(part)
part.add_header('Content-Disposition', 'attachment', filename=file.filename)
msg.attach(part)
attachment_names.append(file.filename)
if attachment_names:
encoded_names = [str(Header(name, 'utf-8')) for name in attachment_names]
msg['X-Attachments'] = ','.join(encoded_names)
smtp.send_message(msg)
smtp.quit()
# 保存到已发送文件夹
try:
imap_context = ssl._create_unverified_context()
imap = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=imap_context)
imap.login(email_addr, password)
typ, folder_data = imap.list()
if typ == 'OK':
sent_candidates = ['Sent', 'Sent Messages', 'Sent Items', '已发送', '已发送邮件']
sent_folder = None
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):
sent_folder = folder_name
break
if 'sent' in folder_name.lower() or '已发送' in folder_name:
sent_folder = folder_name
break
if not sent_folder:
sent_folder = 'Sent'
try:
imap.select(sent_folder)
except Exception:
try:
imap.create(sent_folder)
imap.select(sent_folder)
except Exception:
imap.close()
imap.logout()
return jsonify({'success': True})
msg_bytes = msg.as_bytes()
imap.append(sent_folder, '\\Seen', imaplib.Time2Internaldate(time.time()), msg_bytes)
imap.close()
imap.logout()
else:
imap.logout()
except Exception:
pass
return jsonify({'success': True})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/v1/mail/api/logout', methods=['POST'])
def api_logout():
token = request.json.get('token')
if token and token in TOKEN_MAP:
del TOKEN_MAP[token]
return jsonify({'success': True})
if __name__ == '__main__':
# 原 wsgi_mailWeb.py 的 waitress 生产启动方式(nssm 服务 mailserver 入口)
from waitress import serve
serve(app, host='0.0.0.0', port=12005)
+754
View File
@@ -0,0 +1,754 @@
# -*- coding: utf-8 -*-
"""
ros-mail-mcp (HTTP 版)
======================
部署在 bj 上,与主后端 mailWeb.py 同目录,直接 import 复用其工具函数。
架构:
agent (HTTPS) -> nginx api.rrrosmontis.icu/v1/mail/mcp/ -> 127.0.0.1:12006 (本服务)
认证双层:
1. HTTP 层(可选):Bearer API Key(环境变量 MCP_API_KEY,设置才强制校验);
2. 业务层(主要):三种登录方式任选——
- login_sso:ROSMANKA ID SSO token 换取(推荐,agent 场景,密码不过 agent 侧);
- login:邮箱密码换取一次性 token(复用 mailWeb 的 generate_token/validate_token,
UUID 随机、30 天滑动过期(每次使用自动续期)、仅存内存);
其余工具只认 token;也兼容直接传 password(仅白名单域名,走 TLS)。
"""
import os
import re
import json
import time
import imaplib
import logging
import uvicorn
from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.responses import JSONResponse
from fastmcp import FastMCP
# ---- 复用主后端 mailWeb.py 的工具函数(同目录)----
from mailWeb import (
imap_login,
smtp_login,
decode_mime_str,
decode_attachment_names,
extract_email_body,
build_cid_map,
replace_cid_with_data,
generate_token,
validate_token,
TOKEN_EXPIRE,
AUTODISCOVER_DOMAINS,
SSO_SECRETS_PATH,
_sso_service_token,
)
import email as email_mod
# ============================== 配置 ==============================
HOST = os.environ.get('MCP_HOST', '127.0.0.1')
PORT = int(os.environ.get('MCP_PORT', '12006'))
API_KEY = os.environ.get('MCP_API_KEY', '')
ALLOWED_DOMAINS = [d.lower() for d in AUTODISCOVER_DOMAINS] # 密码直传时的域名白名单
# ---- SSO 绑定(OAuth 式)配置 ----
OIDC_CLIENT_ID = 'mail-mcp'
OIDC_AUTHORIZE_URL = 'https://api.rrrosmontis.icu/v1/sso/oidc/authorize'
BIND_CALLBACK_URL = 'https://api.rrrosmontis.icu/v1/mail/mcp/bind/callback'
SSO_TOKEN_URL = 'http://127.0.0.1:4550/v1/sso/oidc/token'
SSO_EXCHANGE_OIDC_URL = 'http://127.0.0.1:4550/v1/sso/service/exchange_oidc'
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
BINDINGS_PATH = os.path.join(_BASE_DIR, 'mcp_accounts.json') # 绑定账户持久化(凭据 Fernet 加密)
BIND_KEY_PATH = os.path.join(_BASE_DIR, 'mcp_bind.key') # 加密密钥(自动生成,仅本机)
PENDING_BINDS = {} # 进行中的绑定请求(内存,10 分钟有效):bind_id -> {state, created, result}
logging.basicConfig(level=logging.INFO,
format='%(asctime)s [mail-mcp] %(levelname)s %(message)s')
log = logging.getLogger('mail-mcp')
if not API_KEY:
log.warning('MCP_API_KEY 未设置!端点将不受 Bearer Key 保护,仅建议本地调试。')
mcp = FastMCP('ros-mail-mcp', instructions='Rosmontis 邮件 MCP。三种身份方式:① bind_account_start 发起 SSO 绑定'
'(OAuth 式,推荐:一次绑定后所有工具用 account=邮箱地址 调用,多账户重复绑定即可);'
'② login_sso / login 换取 token(30 天有效,每次使用自动续期);'
'③ 每次调用直传 password(仅限白名单域名)。')
# ============================== 绑定存储(Fernet 加密持久化) ==============================
_bind_fernet = None
def _get_bind_fernet():
global _bind_fernet
if _bind_fernet is None:
from cryptography.fernet import Fernet
if os.path.isfile(BIND_KEY_PATH):
with open(BIND_KEY_PATH, 'rb') as f:
key = f.read().strip()
else:
key = Fernet.generate_key()
with open(BIND_KEY_PATH, 'wb') as f:
f.write(key)
_bind_fernet = Fernet(key)
return _bind_fernet
def _load_bindings():
try:
with open(BINDINGS_PATH, 'r', encoding='utf-8') as f:
doc = json.load(f)
doc.setdefault('accounts', {})
return doc
except Exception:
return {'accounts': {}}
def _save_bindings(doc):
tmp = BINDINGS_PATH + '.tmp'
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(doc, f, ensure_ascii=False, indent=2)
os.replace(tmp, BINDINGS_PATH)
def _get_bound_credentials(account: str):
"""按邮箱地址取绑定凭据并滑动刷新 last_used。返回 (email, password) 或 None。"""
doc = _load_bindings()
acc = doc['accounts'].get(account.lower())
if not acc:
return None
acc['last_used'] = time.time()
_save_bindings(doc)
secret = _get_bind_fernet().decrypt(acc['secret_enc'].encode('ascii')).decode('utf-8')
return acc['email'], secret
def _oidc_client_secret():
try:
with open(SSO_SECRETS_PATH, 'r', encoding='utf-8') as f:
return json.load(f).get('oidc_client_secrets', {}).get(OIDC_CLIENT_ID, '')
except Exception:
return ''
# ============================== 认证逻辑 ==============================
class AuthError(Exception):
pass
def _resolve_credentials(email: str, token: str, password: str, account: str = ''):
"""返回 (email_addr, password)。优先级:token > account(SSO 绑定)> password 直传。"""
email = (email or '').strip()
token = (token or '').strip()
account = (account or '').strip()
if token:
creds = validate_token(token)
if not creds:
raise AuthError('token 无效或已过期(闲置超 30 天),请重新调用 login 工具')
tok_email, tok_pw = creds
if email and email.lower() != tok_email.lower():
raise AuthError('token 与该 email 不匹配')
return tok_email, tok_pw
if account:
creds = _get_bound_credentials(account)
if not creds:
raise AuthError(f'账户 {account} 未绑定,请先调用 bind_account_start 完成 SSO 绑定')
if email and email.lower() != creds[0].lower():
raise AuthError('email 与绑定的 account 不匹配')
return creds
if password and email:
domain = email.split('@')[-1].lower()
if domain not in ALLOWED_DOMAINS:
raise AuthError(f'该服务仅允许白名单域名邮箱({"/".join(ALLOWED_DOMAINS)})')
return email, password
raise AuthError('缺少凭据:请提供 token / account(SSO 绑定,推荐)/ email + password 之一')
def _find_sent_folder(conn):
typ, folder_data = conn.list()
if typ != 'OK':
return 'Sent'
candidates = ['Sent', 'Sent Messages', 'Sent Items', '已发送', '已发送邮件']
for line in folder_data:
line_str = line.decode() if isinstance(line, bytes) else line
m = re.search(r'"([^"]+)"$', line_str)
name = m.group(1) if m else (line_str.split()[-1].strip('"') if line_str.split() else None)
if not name:
continue
if any(name.lower() == c.lower() for c in candidates) or 'sent' in name.lower() or '已发送' in name:
return name
return 'Sent'
# ============================== MCP 工具 ==============================
@mcp.tool()
def login(email: str, password: str) -> dict:
"""登录邮箱,换取后续工具使用的访问 token(30 天有效,每次使用自动续期)。
密码只在本次 TLS 加密调用中传输,换取 UUID token 后不再使用。
这也是唯一需要密码的工具。
Args:
email: 完整邮箱地址
password: 邮箱密码
"""
conn = imap_login(email, password)
if not conn:
raise AuthError('邮箱登录失败:用户名或密码错误')
try:
conn.logout()
except Exception:
pass
token = generate_token(email, password)
return {'token': token, 'email': email, 'expires_in': TOKEN_EXPIRE}
@mcp.tool()
def login_sso(sso_token: str) -> dict:
"""通过 ROSMANKA ID SSO 登录,换取后续工具使用的 MCP 访问 token(30 天有效,每次使用自动续期)。
无需邮箱密码:agent 从已登录 sso.rrrosmontis.icu 的用户处获得 SSO token 后调用本工具,
服务端用 service token 向 SSO 服务交换邮箱凭据并立即转为本服务的随机 token,
凭据和 SSO token 均不返回给 agent。
Args:
sso_token: ROSMANKA ID SSO 颁发的用户 token
"""
import requests as rq
from mailWeb import SSO_API_BASE, _sso_service_token
try:
r = rq.post(SSO_API_BASE + '/service/exchange',
json={'service': 'mail', 'token': sso_token.strip()},
headers={'X-SSO-Service-Token': _sso_service_token()}, timeout=10)
try:
d = r.json()
except Exception:
d = {}
except Exception:
raise RuntimeError('SSO 服务暂时不可用,请稍后重试')
if r.status_code != 200 or not d.get('ok'):
raise AuthError(d.get('error') or 'SSO token 无效或已过期,请重新登录 sso.rrrosmontis.icu')
email_addr, password = d['external_id'], d['external_secret']
conn = imap_login(email_addr, password)
if conn:
try:
conn.logout()
except Exception:
pass
token = generate_token(email_addr, password)
return {'token': token, 'email': email_addr, 'sso': True}
@mcp.tool()
def logout(token: str) -> dict:
"""注销 token,立即使其失效。
Args:
token: login 返回的 token
"""
from mailWeb import TOKEN_MAP
if token and token in TOKEN_MAP:
del TOKEN_MAP[token]
return {'success': True}
# ============================== SSO 绑定工具(OAuth 式) ==============================
@mcp.tool()
def bind_account_start() -> dict:
"""开始绑定一个 SSO 邮箱账户(OAuth 式流程,全程无需经手密码)。
返回一个 SSO 授权链接,请用户在浏览器打开并完成登录授权(绑定哪个 SSO 账户由用户在浏览器里决定,
重复调用即可绑定多个账户)。用户看到"绑定成功"页面后,调用 bind_account_status 确认。
Returns:
bind_id: 绑定标识(10 分钟内有效)
url: 需要用户在浏览器打开的 SSO 授权链接
"""
import secrets as _s
from urllib.parse import quote
state = _s.token_urlsafe(24)
bind_id = _s.token_hex(8)
now = time.time()
for k in [k for k, v in PENDING_BINDS.items() if now - v['created'] > 600]:
del PENDING_BINDS[k]
PENDING_BINDS[bind_id] = {'state': state, 'created': now, 'result': None}
url = (f"{OIDC_AUTHORIZE_URL}?client_id={OIDC_CLIENT_ID}"
f"&redirect_uri={quote(BIND_CALLBACK_URL, safe='')}"
f"&response_type=code&scope=openid%20email&prompt=login&state={state}")
return {'bind_id': bind_id, 'url': url, 'expires_in': 600,
'hint': '请把 url 发给用户,让其用浏览器打开完成 SSO 登录授权,然后调用 bind_account_status(bind_id) 确认'}
@mcp.tool()
def bind_account_status(bind_id: str) -> dict:
"""查询 SSO 绑定结果。用户在浏览器完成授权后返回 bound=true 与绑定的邮箱地址。
Args:
bind_id: bind_account_start 返回的绑定标识
"""
p = PENDING_BINDS.get(bind_id)
if not p:
raise ValueError('bind_id 不存在或已过期(10 分钟),请重新调用 bind_account_start')
if p['result']:
email_addr = p['result']['email']
del PENDING_BINDS[bind_id]
return {'bound': True, 'email': email_addr}
return {'bound': False, 'hint': '用户尚未完成授权,请等用户在浏览器完成后再查询'}
@mcp.tool()
def list_accounts() -> dict:
"""列出所有已通过 SSO 绑定的邮箱账户(不含任何凭据信息)。"""
doc = _load_bindings()
return {'accounts': [{'email': a['email'],
'bound_at': int(a.get('bound_at', 0)),
'last_used': int(a.get('last_used', 0))}
for a in doc['accounts'].values()]}
@mcp.tool()
def unbind_account(email: str, token: str) -> dict:
"""解绑一个已绑定的邮箱账户(删除服务端托管凭据,该邮箱此后需重新绑定或改用 token 登录)。
安全要求:必须提供该账户自己的有效 token(先 login 证明你是该账户的所有者),防止跨账户解绑。
Args:
email: 要解绑的邮箱地址
token: 该邮箱的有效 MCP token
"""
creds = validate_token(token)
if not creds:
raise AuthError('token 无效或已过期')
if creds[0].lower() != (email or '').lower():
raise AuthError('token 与要解绑的 email 不匹配')
doc = _load_bindings()
if email.lower() in doc['accounts']:
del doc['accounts'][email.lower()]
_save_bindings(doc)
return {'success': True}
# ============================== SSO 绑定回调(浏览器回跳,非 MCP 工具) ==============================
@mcp.custom_route('/bind/callback', methods=['GET'])
async def bind_callback(request):
from starlette.responses import HTMLResponse
code = request.query_params.get('code', '')
state = request.query_params.get('state', '')
if not code or not state:
return HTMLResponse('<h3>绑定失败:缺少 code 或 state 参数</h3>', status_code=400)
target = None
for bid, p in PENDING_BINDS.items():
if p['state'] == state:
target = (bid, p)
break
if not target:
return HTMLResponse('<h3>绑定失败:授权请求不存在或已过期,请回到 Agent 重新发起绑定</h3>', status_code=400)
_, pend = target
import requests as rq
try:
tr = rq.post(SSO_TOKEN_URL, data={
'grant_type': 'authorization_code',
'code': code,
'client_id': OIDC_CLIENT_ID,
'client_secret': _oidc_client_secret(),
'redirect_uri': BIND_CALLBACK_URL,
}, timeout=10)
td = tr.json()
if tr.status_code != 200 or 'access_token' not in td:
return HTMLResponse(f'<h3>绑定失败:换取 access token 失败({td.get("error", "unknown")})</h3>',
status_code=502)
xr = rq.post(SSO_EXCHANGE_OIDC_URL,
json={'service': 'mail', 'access_token': td['access_token']},
headers={'X-SSO-Service-Token': _sso_service_token()}, timeout=10)
xd = xr.json()
if xr.status_code != 200 or not xd.get('ok'):
return HTMLResponse(f'<h3>绑定失败:{xd.get("error", "SSO 凭据交换失败")}</h3>', status_code=502)
except Exception as e:
return HTMLResponse(f'<h3>绑定失败:SSO 服务暂时不可用({e})</h3>', status_code=502)
email_addr = xd['external_id']
secret = xd['external_secret']
doc = _load_bindings()
doc['accounts'][email_addr.lower()] = {
'email': email_addr,
'secret_enc': _get_bind_fernet().encrypt(secret.encode('utf-8')).decode('ascii'),
'bound_at': time.time(),
'last_used': time.time(),
}
_save_bindings(doc)
pend['result'] = {'email': email_addr}
return HTMLResponse(
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>绑定成功</title></head>'
'<body style="font-family:sans-serif;text-align:center;padding-top:15vh">'
f'<h2>✅ 邮箱绑定成功</h2><p>已将 <b>{email_addr}</b> 绑定到邮件 MCP 服务。</p>'
'<p>你可以关闭本页,回到 Agent 继续。</p></body></html>')
@mcp.tool()
def list_mailboxes(email: str = '', token: str = '', password: str = '', account: str = '') -> dict:
"""列出 IMAP 邮箱中的所有文件夹。
Args:
email: 邮箱地址(用 token 时可省略,服务端自动取 token 对应邮箱)
token: login 返回的 token(推荐)
password: 邮箱密码直传(仅限白名单域名,不推荐)
account: 已 SSO 绑定的邮箱地址(推荐,bind_account_start 一次绑定后即用)
"""
email_addr, pw = _resolve_credentials(email, token, password, account)
conn = imap_login(email_addr, pw)
if not conn:
raise AuthError('IMAP 登录失败')
try:
typ, data = conn.list()
if typ != 'OK':
return {'mailboxes': []}
mailboxes = []
for line in data:
line_str = line.decode() if isinstance(line, bytes) else line
m = re.search(r'"([^"]+)"$', line_str)
if m:
mailboxes.append(m.group(1))
return {'mailboxes': mailboxes}
finally:
try:
conn.logout()
except Exception:
pass
@mcp.tool()
def list_messages(email: str = '', token: str = '', password: str = '', account: str = '',
folder: str = 'INBOX', page: int = 1, per_page: int = 20) -> dict:
"""分页列出指定文件夹中的邮件(默认 INBOX,按时间倒序,返回最新一页)。
Args:
email: 邮箱地址(用 token 时可省略)
token: login 返回的 token(推荐)
password: 邮箱密码直传(仅限白名单域名,不推荐)
account: 已 SSO 绑定的邮箱地址(推荐,bind_account_start 一次绑定后即用)
folder: 文件夹名,默认 INBOX
page: 页码,从 1 开始,默认 1
per_page: 每页数量,默认 20
"""
email_addr, pw = _resolve_credentials(email, token, password, account)
conn = imap_login(email_addr, pw)
if not conn:
raise AuthError('IMAP 登录失败')
try:
conn.select(folder)
result, data = conn.uid('search', None, 'ALL')
if result != 'OK':
return {'messages': [], 'has_more': 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' or not msg_data or not msg_data[0]:
continue
msg = email_mod.message_from_bytes(msg_data[0][1])
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': messages, 'has_more': page * per_page < total, 'total': total}
finally:
try:
conn.logout()
except Exception:
pass
@mcp.tool()
def list_sent(email: str = '', token: str = '', password: str = '', account: str = '',
page: int = 1, per_page: int = 20) -> dict:
"""分页列出已发送邮件(按时间倒序)。
Args:
email: 邮箱地址(用 token 时可省略)
token: login 返回的 token(推荐)
password: 邮箱密码直传(仅限白名单域名,不推荐)
account: 已 SSO 绑定的邮箱地址(推荐,bind_account_start 一次绑定后即用)
page: 页码,从 1 开始,默认 1
per_page: 每页数量,默认 20
"""
email_addr, pw = _resolve_credentials(email, token, password, account)
conn = imap_login(email_addr, pw)
if not conn:
raise AuthError('IMAP 登录失败')
try:
sent_folder = _find_sent_folder(conn)
try:
conn.select(sent_folder)
except Exception:
return {'messages': [], 'has_more': False}
result, data = conn.uid('search', None, 'ALL')
if result != 'OK':
return {'messages': [], 'has_more': 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' or not msg_data or not msg_data[0]:
continue
msg = email_mod.message_from_bytes(msg_data[0][1])
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': messages, 'has_more': page * per_page < total, 'total': total}
finally:
try:
conn.logout()
except Exception:
pass
@mcp.tool()
def read_message(email: str = '', token: str = '', password: str = '', account: str = '',
uid: str = '', folder: str = 'INBOX') -> dict:
"""读取指定 UID 的邮件全文(HTML 正文中的内嵌图片 cid: 引用会被替换为 data URL)。
Args:
email: 邮箱地址(用 token 时可省略)
token: login 返回的 token(推荐)
password: 邮箱密码直传(仅限白名单域名,不推荐)
account: 已 SSO 绑定的邮箱地址(推荐,bind_account_start 一次绑定后即用)
uid: 邮件 UID(从 list_messages / list_sent 获取)
folder: 文件夹名,默认 INBOX
"""
if not uid:
raise ValueError('uid 不能为空')
email_addr, pw = _resolve_credentials(email, token, password, account)
conn = imap_login(email_addr, pw)
if not conn:
raise AuthError('IMAP 登录失败')
try:
conn.select(folder)
result, data = conn.uid('fetch', uid, '(RFC822)')
if result != 'OK' or not data or not data[0]:
raise ValueError(f'邮件不存在: uid={uid}')
msg = email_mod.message_from_bytes(data[0][1])
body = extract_email_body(msg)
cid_map = build_cid_map(msg)
if cid_map:
body = replace_cid_with_data(body, cid_map)
return {
'uid': uid,
'body': body,
'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:
try:
conn.logout()
except Exception:
pass
@mcp.tool()
def read_attachment(email: str = '', token: str = '', password: str = '', account: str = '',
uid: str = '', filename: str = '', folder: str = 'INBOX') -> dict:
"""下载指定邮件中的附件(返回 base64 内容)。
建议仅在需要时调用;大附件会占用较多上下文。
Args:
email: 邮箱地址(用 token 时可省略)
token: login 返回的 token(推荐)
password: 邮箱密码直传(仅限白名单域名,不推荐)
account: 已 SSO 绑定的邮箱地址(推荐,bind_account_start 一次绑定后即用)
uid: 邮件 UID
filename: 附件文件名(从 list_messages 的 attachments 字段获取)
folder: 文件夹名,默认 INBOX
"""
import base64
if not uid or not filename:
raise ValueError('uid 和 filename 均不能为空')
email_addr, pw = _resolve_credentials(email, token, password, account)
conn = imap_login(email_addr, pw)
if not conn:
raise AuthError('IMAP 登录失败')
try:
conn.select(folder)
result, data = conn.uid('fetch', uid, '(RFC822)')
if result != 'OK' or not data or not data[0]:
raise ValueError(f'邮件不存在: uid={uid}')
msg = email_mod.message_from_bytes(data[0][1])
for part in msg.walk():
disp = part.get('Content-Disposition', '')
if not disp or 'attachment' not in disp:
continue
raw_name = part.get_filename()
if not raw_name:
continue
if decode_mime_str(raw_name) == filename:
payload = part.get_payload(decode=True)
if payload is None:
continue
content_type = part.get_content_type() or 'application/octet-stream'
return {
'uid': uid,
'filename': filename,
'content_type': content_type,
'size': len(payload),
'content_b64': base64.b64encode(payload).decode('ascii'),
}
raise ValueError(f'附件不存在: {filename}')
finally:
try:
conn.logout()
except Exception:
pass
@mcp.tool()
def send_email(email: str = '', token: str = '', password: str = '', account: str = '',
to: str = '', subject: str = '', body: str = '') -> dict:
"""发送 HTML 格式邮件,发送后自动保存到已发送文件夹。
Args:
email: 发件人邮箱地址(用 token 时可省略)
token: login 返回的 token(推荐)
password: 邮箱密码直传(仅限白名单域名,不推荐)
account: 已 SSO 绑定的邮箱地址(推荐,bind_account_start 一次绑定后即用)
to: 收件人邮箱地址
subject: 邮件主题
body: 邮件正文(HTML 格式)
"""
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
if not all([to, subject]):
raise ValueError('to 和 subject 不能为空')
email_addr, pw = _resolve_credentials(email, token, password, account)
smtp = smtp_login(email_addr, pw)
if not smtp:
raise AuthError('SMTP 登录失败')
try:
msg = MIMEMultipart()
msg['From'] = email_addr
msg['To'] = to
msg['Subject'] = subject
msg.attach(MIMEText(body or '', 'html', 'utf-8'))
smtp.send_message(msg)
smtp.quit()
except Exception as e:
raise RuntimeError(f'发送失败: {e}')
# 保存到已发送
try:
conn = imap_login(email_addr, pw)
if conn:
try:
sent_folder = _find_sent_folder(conn)
try:
conn.select(sent_folder)
except Exception:
try:
conn.create(sent_folder)
conn.select(sent_folder)
except Exception:
return {'success': True}
conn.append(sent_folder, '\\Seen',
imaplib.Time2Internaldate(time.time()), msg.as_bytes())
conn.close()
finally:
conn.logout()
except Exception:
pass
return {'success': True}
@mcp.tool()
def delete_message(email: str = '', token: str = '', password: str = '', account: str = '',
uid: str = '', folder: str = 'INBOX') -> dict:
"""删除指定 UID 的邮件(移动到 \\Deleted 并 expunge,不可恢复)。
Args:
email: 邮箱地址(用 token 时可省略)
token: login 返回的 token(推荐)
password: 邮箱密码直传(仅限白名单域名,不推荐)
account: 已 SSO 绑定的邮箱地址(推荐,bind_account_start 一次绑定后即用)
uid: 邮件 UID
folder: 文件夹名,默认 INBOX
"""
if not uid:
raise ValueError('uid 不能为空')
email_addr, pw = _resolve_credentials(email, token, password, account)
conn = imap_login(email_addr, pw)
if not conn:
raise AuthError('IMAP 登录失败')
try:
conn.select(folder)
result, _ = conn.uid('store', uid, '+FLAGS', '\\Deleted')
if result != 'OK':
raise RuntimeError('删除失败')
conn.expunge()
return {'success': True}
finally:
try:
conn.logout()
except Exception:
pass
# ============================== HTTP 层(Bearer Key 中间件) ==============================
class BearerKeyMiddleware:
"""ASGI 中间件:设置了 MCP_API_KEY 时,除 /healthz 外所有请求必须携带
Authorization: Bearer <MCP_API_KEY>;未设置则不校验(依赖业务层 SSO/token 认证)。"""
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope['type'] != 'http':
await self.app(scope, receive, send)
return
path = scope.get('path', '')
if path == '/healthz':
resp = JSONResponse({'status': 'ok', 'service': 'ros-mail-mcp'})
await resp(scope, receive, send)
return
if path.startswith('/bind/'): # SSO 绑定回调来自用户浏览器,无 Bearer 头
await self.app(scope, receive, send)
return
headers = dict((k.decode('latin-1').lower(), v.decode('latin-1'))
for k, v in scope.get('headers', []))
auth = headers.get('authorization', '')
if API_KEY and auth != f'Bearer {API_KEY}':
resp = JSONResponse({'error': 'unauthorized: 缺少或错误的 Bearer API Key'}, status_code=401)
await resp(scope, receive, send)
return
await self.app(scope, receive, send)
def main():
app = mcp.http_app(path='/mcp/', stateless_http=True,
allowed_hosts=['api.rrrosmontis.icu', '127.0.0.1', 'localhost'])
app = BearerKeyMiddleware(app)
log.info(f'ros-mail-mcp listening on http://{HOST}:{PORT}/mcp/')
uvicorn.run(app, host=HOST, port=PORT, log_level='warning')
if __name__ == '__main__':
main()
+26
View File
@@ -0,0 +1,26 @@
"""Single Windows service for the existing mail website, MCP and remote COM API."""
import os
import uvicorn
from starlette.middleware.wsgi import WSGIMiddleware
import mailWeb
import mail_mcp
import admin_com
admin_com.install(mailWeb.app)
web = WSGIMiddleware(mailWeb.app)
mcp = mail_mcp.BearerKeyMiddleware(mail_mcp.mcp.http_app(
path='/mcp/', stateless_http=True,
allowed_hosts=['api.rrrosmontis.icu', '127.0.0.1', 'localhost']))
async def application(scope, receive, send):
# Lifespan goes to FastMCP, so its session manager starts and stops normally.
if scope['type'] == 'http' and scope.get('path', '').startswith('/v1/mail/api/'):
await web(scope, receive, send)
else:
await mcp(scope, receive, send)
if __name__ == '__main__':
uvicorn.run(application, host='127.0.0.1', port=int(os.environ.get('MAIL_PORT', '12005')),
log_level='warning', access_log=False, timeout_graceful_shutdown=20)