Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
971 lines
34 KiB
Python
971 lines
34 KiB
Python
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) |