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
+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)