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:
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"hmail_admin_password": "",
|
||||
"token": "",
|
||||
"allowed_domains": ["example.com"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user