Files
shirobana-bot/mail-bridge/mailWeb.py
T
RRRosmontis 34089d0042 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.
2026-11-11 00:25:30 +08:00

971 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)