988 lines
38 KiB
Python
988 lines
38 KiB
Python
"""接入临时邮箱服务并负责邮箱创建、邮件轮询和验证码提取。"""
|
||
import re
|
||
import secrets
|
||
import string
|
||
import time
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from curl_cffi import requests
|
||
|
||
DUCKMAIL_API_BASE = "https://api.duckmail.sbs"
|
||
|
||
YYDS_API_BASE = "https://maliapi.215.im/v1"
|
||
|
||
|
||
config = {}
|
||
_cf_domain_index = 0
|
||
_cloudmail_domain_index = 0
|
||
_OWN_NAMES = {'cloudmail_get_email_and_token', 'get_messages', 'cloudflare_get_messages', 'get_yyds_api_key', 'yyds_generate_username', 'yyds_get_domains', 'yyds_get_email_and_token', 'yyds_get_oai_code', 'get_email_provider', 'cloudflare_get_domains', 'extract_verification_code', 'get_cloudflare_api_base', 'cloudflare_apply_auth_params', 'duckmail_get_oai_code', 'create_account', 'get_yyds_jwt', 'get_message_detail', 'yyds_create_account', 'get_duckmail_api_key', 'get_cloudflare_path', 'cloudflare_create_account', 'cloudflare_get_token', 'cloudflare_get_oai_code', 'get_cloudmail_public_token', 'generate_username', 'yyds_get_message_detail', 'cloudflare_next_default_domain', 'yyds_get_messages', 'yyds_get_token', 'get_domains', 'get_token', 'cloudflare_create_temp_address', 'get_cloudflare_api_key', 'get_cloudmail_path', 'get_cloudmail_api_base', 'cloudmail_get_oai_code', 'cloudflare_build_headers', 'cloudflare_is_admin_create_path', 'cloudmail_next_domain', 'cloudflare_get_message_detail', 'cloudmail_get_messages', 'get_user_agent', 'yyds_pick_domain', '_pick_list_payload', 'get_email_and_token', 'get_oai_code', 'get_cloudflare_auth_mode', 'pick_domain'}
|
||
|
||
|
||
def bind_runtime(namespace):
|
||
global config
|
||
config = namespace.get("config", config)
|
||
for name, value in namespace.items():
|
||
if name.startswith("__") or name in _OWN_NAMES or name in {"config", "_cf_domain_index", "_cloudmail_domain_index"}:
|
||
continue
|
||
globals()[name] = value
|
||
|
||
|
||
def normalize_mail_body(*sources):
|
||
"""Return normalized text from provider payloads with string/list HTML support."""
|
||
parts = []
|
||
for source in sources:
|
||
if not isinstance(source, dict):
|
||
continue
|
||
for key in ("text", "raw", "content", "intro", "body", "snippet"):
|
||
value = source.get(key)
|
||
values = value if isinstance(value, (list, tuple)) else [value]
|
||
for item in values:
|
||
if isinstance(item, str) and item.strip():
|
||
parts.append(item)
|
||
html_value = source.get("html")
|
||
html_items = html_value if isinstance(html_value, (list, tuple)) else [html_value]
|
||
for item in html_items:
|
||
if isinstance(item, str) and item.strip():
|
||
parts.append(re.sub(r"<[^>]+>", " ", item))
|
||
return "\n".join(parts)
|
||
|
||
|
||
def _pick_list_payload(data):
|
||
if isinstance(data, list):
|
||
return data
|
||
if isinstance(data, dict):
|
||
if isinstance(data.get("results"), list):
|
||
return data.get("results")
|
||
if isinstance(data.get("hydra:member"), list):
|
||
return data.get("hydra:member")
|
||
if isinstance(data.get("data"), list):
|
||
return data.get("data")
|
||
if isinstance(data.get("messages"), list):
|
||
return data.get("messages")
|
||
if isinstance(data.get("data"), dict):
|
||
nested = data.get("data")
|
||
if isinstance(nested.get("messages"), list):
|
||
return nested.get("messages")
|
||
return []
|
||
|
||
def cloudflare_apply_auth_params(params=None):
|
||
merged = dict(params or {})
|
||
key = get_cloudflare_api_key()
|
||
mode = get_cloudflare_auth_mode()
|
||
if key and mode == "query-key":
|
||
merged["key"] = key
|
||
return merged
|
||
|
||
def cloudflare_build_headers(content_type=False):
|
||
headers = {"Content-Type": "application/json"} if content_type else {}
|
||
key = get_cloudflare_api_key()
|
||
mode = get_cloudflare_auth_mode()
|
||
if key:
|
||
if mode == "x-api-key":
|
||
headers["X-API-Key"] = key
|
||
elif mode == "x-admin-auth":
|
||
headers["x-admin-auth"] = key
|
||
elif mode != "none":
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
return headers
|
||
|
||
def cloudflare_create_account(api_base, address, password, api_key=None, expires_in=0):
|
||
headers = cloudflare_build_headers(content_type=True)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
payload = {"address": address, "password": password, "expiresIn": expires_in}
|
||
path = get_cloudflare_path("cloudflare_path_accounts", "/accounts")
|
||
params = cloudflare_apply_auth_params()
|
||
resp = http_post(f"{api_base}{path}", json=payload, headers=headers, params=params)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
def cloudflare_create_temp_address(api_base):
|
||
"""适配 cloudflare_temp_email 新建地址接口并兼容 admin 创建模式。"""
|
||
path = get_cloudflare_path("cloudflare_path_accounts", "/api/new_address")
|
||
url = f"{api_base}{path}"
|
||
domain = cloudflare_next_default_domain()
|
||
is_admin_create = cloudflare_is_admin_create_path(path)
|
||
if is_admin_create:
|
||
payload = {"name": generate_username(10), "enablePrefix": True}
|
||
if domain:
|
||
payload["domain"] = domain
|
||
headers = cloudflare_build_headers(content_type=True)
|
||
else:
|
||
payload = {}
|
||
if domain:
|
||
payload["domain"] = domain
|
||
headers = {"Content-Type": "application/json"}
|
||
resp = http_post(url, json=payload, headers=headers)
|
||
resp.raise_for_status()
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
raise Exception(f"Cloudflare {path} 返回非JSON: {resp.text[:300]}")
|
||
address = data.get("address")
|
||
jwt = data.get("jwt")
|
||
if not address or not jwt:
|
||
raise Exception(f"Cloudflare {path} 缺少 address/jwt: {data}")
|
||
return address, jwt
|
||
|
||
def cloudflare_get_domains(api_base, api_key=None):
|
||
headers = cloudflare_build_headers(content_type=False)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
path = get_cloudflare_path("cloudflare_path_domains", "/domains")
|
||
params = cloudflare_apply_auth_params()
|
||
resp = http_get(f"{api_base}{path}", headers=headers, params=params)
|
||
resp.raise_for_status()
|
||
return _pick_list_payload(resp.json())
|
||
|
||
def cloudflare_get_message_detail(api_base, token, message_id):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
candidates = [
|
||
f"{api_base}/api/mail/{message_id}",
|
||
f"{api_base}{get_cloudflare_path('cloudflare_path_messages', '/messages')}/{message_id}",
|
||
]
|
||
last_err = None
|
||
for url in candidates:
|
||
try:
|
||
resp = http_get(
|
||
url,
|
||
headers=headers,
|
||
params=cloudflare_apply_auth_params(),
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict) and isinstance(data.get("data"), dict):
|
||
return data["data"]
|
||
return data
|
||
except Exception as exc:
|
||
last_err = exc
|
||
continue
|
||
raise Exception(f"Cloudflare 获取邮件详情失败: {last_err}")
|
||
|
||
def cloudflare_get_messages(api_base, token):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
path = get_cloudflare_path("cloudflare_path_messages", "/messages")
|
||
params = {"limit": 20, "offset": 0}
|
||
params = cloudflare_apply_auth_params(params)
|
||
resp = http_get(f"{api_base}{path}", headers=headers, params=params)
|
||
resp.raise_for_status()
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
raise Exception(f"Cloudflare messages 返回非JSON: {resp.text[:300]}")
|
||
return _pick_list_payload(data)
|
||
|
||
def cloudflare_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
):
|
||
api_base = get_cloudflare_api_base()
|
||
if not api_base:
|
||
raise Exception("Cloudflare API Base 未配置")
|
||
deadline = time.time() + timeout
|
||
# 同一封邮件正文可能延迟可读,允许多次重试解析,避免偶发漏码
|
||
seen_attempts = {}
|
||
next_resend_at = time.time() + 35
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
if resend_callback and time.time() >= next_resend_at:
|
||
try:
|
||
resend_callback()
|
||
if log_callback:
|
||
log_callback("[*] 已触发重新发送验证码")
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 触发重发验证码失败: {exc}")
|
||
next_resend_at = time.time() + 35
|
||
try:
|
||
messages = cloudflare_get_messages(api_base, dev_token)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloudflare 拉取邮件列表失败: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
continue
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloudflare 本轮邮件数量: {len(messages)}")
|
||
|
||
for msg in messages:
|
||
msg_id = msg.get("id") or msg.get("msgid")
|
||
if not msg_id:
|
||
continue
|
||
attempt = int(seen_attempts.get(msg_id, 0))
|
||
if attempt >= 5:
|
||
continue
|
||
seen_attempts[msg_id] = attempt + 1
|
||
recipients = [t.get("address", "").lower() for t in (msg.get("to") or [])]
|
||
msg_addr = str(msg.get("address", "")).lower()
|
||
# 优先匹配目标邮箱;若结构不一致也允许继续解析,避免接口字段漂移导致漏码
|
||
address_matched = True
|
||
if recipients:
|
||
address_matched = email.lower() in recipients
|
||
elif msg_addr:
|
||
address_matched = msg_addr == email.lower()
|
||
if not address_matched:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 跳过疑似非目标邮件 id={msg_id} address={msg_addr} to={recipients}")
|
||
continue
|
||
# 先直接从列表项取内容,避免 detail 接口差异导致漏码
|
||
subject = str(msg.get("subject", "") or "")
|
||
combined = normalize_mail_body(msg)
|
||
# 再尝试 detail 接口补全内容
|
||
try:
|
||
detail = cloudflare_get_message_detail(api_base, dev_token, msg_id)
|
||
detail_body = normalize_mail_body(detail)
|
||
if detail_body:
|
||
combined += "\n" + detail_body
|
||
if not subject:
|
||
subject = str(detail.get("subject", "") or "")
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloudflare detail接口失败,改用列表内容解析: {exc}")
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloudflare 收到邮件: {subject}")
|
||
code = extract_verification_code(combined, subject)
|
||
if code:
|
||
if log_callback:
|
||
log_callback(f"[*] Cloudflare 从邮件中提取到验证码: {code}")
|
||
return code
|
||
elif log_callback:
|
||
log_callback(f"[Debug] 邮件已解析但未提取到验证码 id={msg_id} attempt={seen_attempts[msg_id]}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"Cloudflare 在 {timeout}s 内未收到验证码邮件")
|
||
|
||
def cloudflare_get_token(api_base, address, password, api_key=None):
|
||
headers = cloudflare_build_headers(content_type=True)
|
||
if api_key and "Authorization" in headers:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
if api_key and "X-API-Key" in headers:
|
||
headers["X-API-Key"] = api_key
|
||
path = get_cloudflare_path("cloudflare_path_token", "/token")
|
||
resp = http_post(
|
||
f"{api_base}{path}",
|
||
json={"address": address, "password": password},
|
||
headers=headers,
|
||
params=cloudflare_apply_auth_params(),
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict):
|
||
if data.get("token"):
|
||
return data.get("token")
|
||
if isinstance(data.get("data"), dict) and data["data"].get("token"):
|
||
return data["data"].get("token")
|
||
return None
|
||
|
||
def cloudflare_is_admin_create_path(path):
|
||
"""判断当前创建邮箱路径是否为 cloudflare_temp_email 管理员创建接口。"""
|
||
return str(path or "").rstrip("/").lower() == "/admin/new_address"
|
||
|
||
def cloudflare_next_default_domain():
|
||
"""按配置轮换选择 Cloudflare 临时邮箱域名。"""
|
||
global _cf_domain_index
|
||
domains = [x.strip() for x in str(config.get("defaultDomains", "") or "").split(",") if x.strip()]
|
||
if not domains:
|
||
return ""
|
||
domain = domains[_cf_domain_index % len(domains)]
|
||
_cf_domain_index += 1
|
||
return domain
|
||
|
||
def cloudmail_get_email_and_token():
|
||
"""生成无需预创建账号的 Cloud Mail 收件地址。"""
|
||
if not get_cloudmail_api_base():
|
||
raise Exception("Cloud Mail API Base 未配置")
|
||
if not get_cloudmail_public_token():
|
||
raise Exception("Cloud Mail Public Token 未配置")
|
||
domain = cloudmail_next_domain()
|
||
if not domain:
|
||
raise Exception("Cloud Mail 收件域名未配置")
|
||
address = f"{generate_username(12)}@{domain}"
|
||
# 仅返回非敏感占位凭证;公共 Token 始终只从 config.json 读取。
|
||
return address, f"cloudmail:{address}"
|
||
|
||
def cloudmail_get_messages(address):
|
||
api_base = get_cloudmail_api_base()
|
||
public_token = get_cloudmail_public_token()
|
||
if not api_base:
|
||
raise Exception("Cloud Mail API Base 未配置")
|
||
if not public_token:
|
||
raise Exception("Cloud Mail Public Token 未配置")
|
||
payload = {
|
||
"toEmail": address,
|
||
"type": 0,
|
||
"isDel": 0,
|
||
"timeSort": "desc",
|
||
"num": 1,
|
||
"size": 20,
|
||
}
|
||
resp = http_post(
|
||
f"{api_base}{get_cloudmail_path()}",
|
||
headers={
|
||
"Authorization": public_token,
|
||
"Content-Type": "application/json",
|
||
},
|
||
json=payload,
|
||
timeout=20,
|
||
)
|
||
resp.raise_for_status()
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
raise Exception(f"Cloud Mail 邮件接口返回非JSON: {resp.text[:300]}")
|
||
if not isinstance(data, dict):
|
||
raise Exception(f"Cloud Mail 邮件接口返回格式错误: {data}")
|
||
result_code = data.get("code")
|
||
if result_code not in (None, 200, "200"):
|
||
raise Exception(
|
||
f"Cloud Mail 邮件接口失败: code={result_code}, message={data.get('message', '')}"
|
||
)
|
||
messages = data.get("data")
|
||
if isinstance(messages, list):
|
||
return messages
|
||
return _pick_list_payload(data)
|
||
|
||
def cloudmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
):
|
||
# dev_token 是为了保持现有邮箱 Provider 调用契约;Cloud Mail 使用配置中的公共 Token。
|
||
_ = dev_token
|
||
deadline = time.time() + timeout
|
||
seen_attempts = {}
|
||
next_resend_at = time.time() + 35
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
if resend_callback and time.time() >= next_resend_at:
|
||
try:
|
||
resend_callback()
|
||
if log_callback:
|
||
log_callback("[*] 已触发重新发送验证码")
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 触发重发验证码失败: {exc}")
|
||
next_resend_at = time.time() + 35
|
||
try:
|
||
messages = cloudmail_get_messages(email)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloud Mail 拉取邮件列表失败: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
continue
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloud Mail 本轮邮件数量: {len(messages)}")
|
||
for msg in messages:
|
||
msg_id = msg.get("emailId") or msg.get("email_id") or msg.get("id")
|
||
if not msg_id:
|
||
continue
|
||
attempt = int(seen_attempts.get(msg_id, 0))
|
||
if attempt >= 5:
|
||
continue
|
||
seen_attempts[msg_id] = attempt + 1
|
||
target_address = str(
|
||
msg.get("toEmail") or msg.get("to_email") or ""
|
||
).strip().lower()
|
||
if target_address and target_address != email.lower():
|
||
continue
|
||
code_value = str(msg.get("code", "") or "").strip()
|
||
combined = normalize_mail_body(msg)
|
||
if code_value:
|
||
combined = f"verification code: {code_value}\n{combined}"
|
||
subject = str(msg.get("subject", "") or "")
|
||
if log_callback:
|
||
log_callback(f"[Debug] Cloud Mail 收到邮件: {subject}")
|
||
code = extract_verification_code(combined, subject)
|
||
if code:
|
||
if log_callback:
|
||
log_callback(f"[*] Cloud Mail 从邮件中提取到验证码: {code}")
|
||
return code
|
||
if log_callback:
|
||
log_callback(
|
||
f"[Debug] Cloud Mail 邮件已解析但未提取到验证码 "
|
||
f"id={msg_id} attempt={seen_attempts[msg_id]}"
|
||
)
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"Cloud Mail 在 {timeout}s 内未收到验证码邮件")
|
||
|
||
def cloudmail_next_domain():
|
||
"""按配置轮换选择 Cloud Mail 无人收件域名。"""
|
||
global _cloudmail_domain_index
|
||
domains = [
|
||
item.strip().lstrip("@")
|
||
for item in str(config.get("cloudmail_domains", "") or "").split(",")
|
||
if item.strip().lstrip("@")
|
||
]
|
||
if not domains:
|
||
return ""
|
||
domain = domains[_cloudmail_domain_index % len(domains)]
|
||
_cloudmail_domain_index += 1
|
||
return domain
|
||
|
||
def create_account(address, password, api_key=None, expires_in=0):
|
||
headers = {"Content-Type": "application/json"}
|
||
key = api_key or get_duckmail_api_key()
|
||
if key:
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
data = {"address": address, "password": password, "expiresIn": expires_in}
|
||
resp = http_post(f"{DUCKMAIL_API_BASE}/accounts", json=data, headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
def duckmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
):
|
||
deadline = time.time() + timeout
|
||
seen_attempts = {}
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
try:
|
||
messages = get_messages(dev_token)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 拉取邮件列表失败: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
continue
|
||
for msg in messages:
|
||
msg_id = msg.get("id") or msg.get("msgid")
|
||
if not msg_id:
|
||
continue
|
||
recipients = [t.get("address", "").lower() for t in (msg.get("to") or [])]
|
||
if email.lower() not in recipients:
|
||
continue
|
||
attempt = int(seen_attempts.get(msg_id, 0))
|
||
if attempt >= 5:
|
||
continue
|
||
seen_attempts[msg_id] = attempt + 1
|
||
try:
|
||
detail = get_message_detail(dev_token, msg_id)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] 获取邮件详情失败: {exc}")
|
||
continue
|
||
combined = normalize_mail_body(detail)
|
||
subject = detail.get("subject", "")
|
||
if log_callback:
|
||
log_callback(f"[Debug] 收到邮件: {subject}")
|
||
code = extract_verification_code(combined, subject)
|
||
if code:
|
||
if log_callback:
|
||
log_callback(f"[*] 从邮件中提取到验证码: {code}")
|
||
return code
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"在 {timeout}s 内未收到验证码邮件")
|
||
|
||
def extract_verification_code(text, subject=""):
|
||
if subject:
|
||
match = re.search(r"^([A-Z0-9]{3}-[A-Z0-9]{3})\s+xAI", subject, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
for source in (subject, text):
|
||
match = re.search(
|
||
r"(?:confirmation|verification)\s+code\s*[::]\s*([A-Z0-9]{3}-[A-Z0-9]{3})",
|
||
source,
|
||
re.IGNORECASE,
|
||
)
|
||
if match:
|
||
return match.group(1)
|
||
# Keep this case-sensitive: lower-case CSS tokens such as `per-100` are not codes.
|
||
match = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", text)
|
||
if match:
|
||
return match.group(1)
|
||
patterns = [
|
||
r"verification\s+code[:\s]+(\d{4,8})",
|
||
r"your\s+code[:\s]+(\d{4,8})",
|
||
r"confirm(?:ation)?\s+code[:\s]+(\d{4,8})",
|
||
]
|
||
for pattern in patterns:
|
||
match = re.search(pattern, text, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
return None
|
||
|
||
def generate_username(length=10):
|
||
chars = string.ascii_lowercase + string.digits
|
||
return "".join(secrets.choice(chars) for _ in range(length))
|
||
|
||
def get_cloudflare_api_base():
|
||
return str(config.get("cloudflare_api_base", "") or "").rstrip("/")
|
||
|
||
def get_cloudflare_api_key():
|
||
return config.get("cloudflare_api_key", "")
|
||
|
||
def get_cloudflare_auth_mode():
|
||
return str(config.get("cloudflare_auth_mode", "none") or "none").lower()
|
||
|
||
def get_cloudflare_path(key, default_path):
|
||
raw = str(config.get(key, default_path) or default_path).strip()
|
||
if not raw.startswith("/"):
|
||
raw = "/" + raw
|
||
return raw
|
||
|
||
def get_cloudmail_api_base():
|
||
return str(config.get("cloudmail_api_base", "") or "").strip().rstrip("/")
|
||
|
||
def get_cloudmail_path():
|
||
raw = str(
|
||
config.get("cloudmail_path_messages", "/api/public/emailList")
|
||
or "/api/public/emailList"
|
||
).strip()
|
||
return raw if raw.startswith("/") else "/" + raw
|
||
|
||
def get_cloudmail_public_token():
|
||
return str(config.get("cloudmail_public_token", "") or "").strip()
|
||
|
||
def get_domains(api_key=None):
|
||
headers = {}
|
||
key = api_key or get_duckmail_api_key()
|
||
if key:
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/domains", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json().get("hydra:member", [])
|
||
|
||
def get_duckmail_api_key():
|
||
return config.get("duckmail_api_key", "")
|
||
|
||
def get_email_and_token(api_key=None):
|
||
provider = get_email_provider()
|
||
if provider == "yyds":
|
||
return yyds_get_email_and_token(api_key=api_key, jwt=get_yyds_jwt())
|
||
if provider == "cloudmail":
|
||
return cloudmail_get_email_and_token()
|
||
if provider == "cloudflare":
|
||
api_base = get_cloudflare_api_base()
|
||
if not api_base:
|
||
raise Exception("Cloudflare API Base 未配置")
|
||
try:
|
||
# cloudflare_temp_email 专用模式
|
||
return cloudflare_create_temp_address(api_base)
|
||
except Exception as primary_exc:
|
||
# 兜底回退到 Mail.tm 风格
|
||
key = api_key or get_cloudflare_api_key()
|
||
domains = cloudflare_get_domains(api_base, api_key=key)
|
||
if not domains:
|
||
raise Exception(f"Cloudflare 创建邮箱失败: {primary_exc}")
|
||
verified = [d for d in domains if d.get("isVerified")]
|
||
target = verified[0] if verified else domains[0]
|
||
domain = target.get("domain")
|
||
if not domain:
|
||
raise Exception("Cloudflare 域名数据格式错误,缺少 domain 字段")
|
||
username = generate_username(10)
|
||
address = f"{username}@{domain}"
|
||
password = secrets.token_urlsafe(12)
|
||
cloudflare_create_account(
|
||
api_base, address, password, api_key=key, expires_in=0
|
||
)
|
||
token = cloudflare_get_token(api_base, address, password, api_key=key)
|
||
if not token:
|
||
raise Exception("获取 Cloudflare 邮箱 token 失败")
|
||
return address, token
|
||
key = api_key or get_duckmail_api_key()
|
||
domain = pick_domain(api_key=key)
|
||
username = generate_username(10)
|
||
address = f"{username}@{domain}"
|
||
password = secrets.token_urlsafe(12)
|
||
create_account(address, password, api_key=key, expires_in=0)
|
||
token = get_token(address, password)
|
||
if not token:
|
||
raise Exception("获取 DuckMail token 失败")
|
||
return address, token
|
||
|
||
def get_email_provider():
|
||
return config.get("email_provider", "duckmail")
|
||
|
||
def get_message_detail(token, message_id):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/messages/{message_id}", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
def get_messages(token):
|
||
headers = {"Authorization": f"Bearer {token}"}
|
||
resp = http_get(f"{DUCKMAIL_API_BASE}/messages", headers=headers)
|
||
resp.raise_for_status()
|
||
return resp.json().get("hydra:member", [])
|
||
|
||
def get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
cancel_callback=None,
|
||
resend_callback=None,
|
||
):
|
||
provider = get_email_provider()
|
||
if provider == "yyds":
|
||
return yyds_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
jwt=get_yyds_jwt(),
|
||
cancel_callback=cancel_callback,
|
||
)
|
||
if provider == "cloudmail":
|
||
return cloudmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
resend_callback=resend_callback,
|
||
)
|
||
if provider == "cloudflare":
|
||
return cloudflare_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
resend_callback=resend_callback,
|
||
)
|
||
return duckmail_get_oai_code(
|
||
dev_token,
|
||
email,
|
||
timeout=timeout,
|
||
poll_interval=poll_interval,
|
||
log_callback=log_callback,
|
||
cancel_callback=cancel_callback,
|
||
)
|
||
|
||
def get_token(address, password):
|
||
data = {"address": address, "password": password}
|
||
resp = http_post(f"{DUCKMAIL_API_BASE}/token", json=data)
|
||
resp.raise_for_status()
|
||
return resp.json().get("token")
|
||
|
||
def get_user_agent():
|
||
return config.get(
|
||
"user_agent",
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
|
||
)
|
||
|
||
def get_yyds_api_key():
|
||
return config.get("yyds_api_key", "")
|
||
|
||
def get_yyds_jwt():
|
||
return config.get("yyds_jwt", "")
|
||
|
||
def pick_domain(api_key=None):
|
||
domains = get_domains(api_key=api_key)
|
||
if not domains:
|
||
raise Exception("DuckMail 没有返回任何可用域名")
|
||
private = [d for d in domains if d.get("ownerId")]
|
||
verified_private = [d for d in private if d.get("isVerified")]
|
||
if verified_private:
|
||
return verified_private[0]["domain"]
|
||
public = [d for d in domains if d.get("isVerified")]
|
||
if public:
|
||
return public[0]["domain"]
|
||
raise Exception("DuckMail 无已验证域名可用")
|
||
|
||
def yyds_create_account(address=None, domain=None, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
headers = {"Content-Type": "application/json"}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
payload = {}
|
||
if address:
|
||
payload["address"] = address
|
||
if domain:
|
||
payload["domain"] = domain
|
||
elif key or token:
|
||
payload["autoDomainStrategy"] = "prefer_owned"
|
||
resp = http_post(f"{YYDS_API_BASE}/accounts", json=payload, headers=headers)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {})
|
||
raise Exception(f"YYDS 创建邮箱失败: {data}")
|
||
|
||
def yyds_generate_username(length=10):
|
||
chars = string.ascii_lowercase + string.digits
|
||
return "".join(secrets.choice(chars) for _ in range(length))
|
||
|
||
def yyds_get_domains(api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
headers = {}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_get(f"{YYDS_API_BASE}/domains", headers=headers)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
return data.get("data", []) if data.get("success") else []
|
||
|
||
def yyds_get_email_and_token(api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
if not token and not key:
|
||
raise Exception("YYDS API Key 或 JWT 未配置")
|
||
domain = yyds_pick_domain(api_key=key, jwt=token)
|
||
username = yyds_generate_username(10)
|
||
result = yyds_create_account(
|
||
address=username, domain=domain, api_key=key, jwt=token
|
||
)
|
||
address = result.get("address") or f"{username}@{domain}"
|
||
temp_token = result.get("token")
|
||
if not temp_token:
|
||
temp_token = yyds_get_token(address, api_key=key, jwt=token)
|
||
if not temp_token:
|
||
raise Exception("获取 YYDS token 失败")
|
||
print(f"[*] 已创建 YYDS 邮箱: {address}")
|
||
return address, temp_token
|
||
|
||
def yyds_get_message_detail(message_id, token=None, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
temp_token = token or jwt or get_yyds_jwt()
|
||
headers = {}
|
||
if temp_token:
|
||
headers["Authorization"] = f"Bearer {temp_token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_get(f"{YYDS_API_BASE}/messages/{message_id}", headers=headers)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {})
|
||
raise Exception(f"YYDS 获取邮件详情失败: {data}")
|
||
|
||
def yyds_get_messages(address, token=None, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
temp_token = token or jwt or get_yyds_jwt()
|
||
headers = {}
|
||
if temp_token:
|
||
headers["Authorization"] = f"Bearer {temp_token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_get(
|
||
f"{YYDS_API_BASE}/messages",
|
||
params={"address": address},
|
||
headers=headers,
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {}).get("messages", [])
|
||
return []
|
||
|
||
def yyds_get_oai_code(
|
||
token,
|
||
address,
|
||
timeout=180,
|
||
poll_interval=3,
|
||
log_callback=None,
|
||
jwt=None,
|
||
cancel_callback=None,
|
||
):
|
||
deadline = time.time() + timeout
|
||
seen_attempts = {}
|
||
while time.time() < deadline:
|
||
raise_if_cancelled(cancel_callback)
|
||
try:
|
||
messages = yyds_get_messages(address, token=token, jwt=jwt)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] YYDS 拉取邮件列表失败: {exc}")
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
continue
|
||
for msg in messages:
|
||
msg_id = msg.get("id")
|
||
if not msg_id:
|
||
continue
|
||
to_addrs = [t.get("address", "").lower() for t in (msg.get("to") or [])]
|
||
if address.lower() not in to_addrs:
|
||
continue
|
||
attempt = int(seen_attempts.get(msg_id, 0))
|
||
if attempt >= 5:
|
||
continue
|
||
seen_attempts[msg_id] = attempt + 1
|
||
try:
|
||
detail = yyds_get_message_detail(msg_id, token=token, jwt=jwt)
|
||
except Exception as exc:
|
||
if log_callback:
|
||
log_callback(f"[Debug] YYDS 获取邮件详情失败: {exc}")
|
||
continue
|
||
combined = normalize_mail_body(detail)
|
||
subject = detail.get("subject", "")
|
||
if log_callback:
|
||
log_callback(f"[Debug] YYDS 收到邮件: {subject}")
|
||
code = extract_verification_code(combined, subject)
|
||
if code:
|
||
if log_callback:
|
||
log_callback(f"[*] YYDS 从邮件中提取到验证码: {code}")
|
||
return code
|
||
sleep_with_cancel(poll_interval, cancel_callback)
|
||
raise Exception(f"YYDS 在 {timeout}s 内未收到验证码邮件")
|
||
|
||
def yyds_get_token(address, api_key=None, jwt=None):
|
||
key = api_key or get_yyds_api_key()
|
||
token = jwt or get_yyds_jwt()
|
||
headers = {"Content-Type": "application/json"}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
elif key:
|
||
headers["X-API-Key"] = key
|
||
resp = http_post(
|
||
f"{YYDS_API_BASE}/token", json={"address": address}, headers=headers
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if data.get("success"):
|
||
return data.get("data", {}).get("token")
|
||
raise Exception(f"YYDS 获取token失败: {data}")
|
||
|
||
def yyds_pick_domain(api_key=None, jwt=None):
|
||
domains = yyds_get_domains(api_key=api_key, jwt=jwt)
|
||
if not domains:
|
||
raise Exception("YYDS 没有返回任何可用域名")
|
||
private = [d for d in domains if d.get("isVerified") and not d.get("isPublic")]
|
||
if private:
|
||
return private[0]["domain"]
|
||
public = [d for d in domains if d.get("isVerified") and d.get("isPublic")]
|
||
if public:
|
||
return public[0]["domain"]
|
||
verified = [d for d in domains if d.get("isVerified")]
|
||
if verified:
|
||
return verified[0]["domain"]
|
||
raise Exception("YYDS 无已验证域名可用")
|
||
|
||
|
||
|
||
class CloudflareMailClient:
|
||
"""Standalone Cloudflare mail client used by the debug CLI."""
|
||
def __init__(self, api_base, auth_mode="none", api_key="", create_path="/api/new_address", timeout=20):
|
||
self.api_base = str(api_base or "").rstrip("/")
|
||
self.auth_mode = str(auth_mode or "none").lower()
|
||
self.api_key = str(api_key or "")
|
||
self.create_path = self.normalize_path(create_path, "/api/new_address")
|
||
self.timeout = int(timeout)
|
||
|
||
@staticmethod
|
||
def normalize_path(path, default_path):
|
||
raw = (path or default_path).strip() or default_path
|
||
return raw if raw.startswith("/") else "/" + raw
|
||
|
||
def build_auth_headers(self, content_type=False):
|
||
headers = {"Content-Type": "application/json"} if content_type else {}
|
||
if not self.api_key:
|
||
return headers
|
||
if self.auth_mode == "x-admin-auth":
|
||
headers["x-admin-auth"] = self.api_key
|
||
elif self.auth_mode == "x-api-key":
|
||
headers["X-API-Key"] = self.api_key
|
||
elif self.auth_mode == "bearer":
|
||
headers["Authorization"] = "Bearer " + self.api_key
|
||
return headers
|
||
|
||
@staticmethod
|
||
def json_or_text(response):
|
||
try:
|
||
return response.json(), ""
|
||
except Exception:
|
||
return None, str(getattr(response, "text", "") or "")[:400]
|
||
|
||
def create_address(self, domain="", name=""):
|
||
is_admin = self.create_path.rstrip("/").lower() == "/admin/new_address"
|
||
payload = {}
|
||
headers = {"Content-Type": "application/json"}
|
||
if is_admin:
|
||
payload = {"name": name.strip() if str(name).strip() else generate_username(), "enablePrefix": True}
|
||
if str(domain).strip():
|
||
payload["domain"] = str(domain).strip()
|
||
headers = self.build_auth_headers(content_type=True)
|
||
elif str(domain).strip():
|
||
payload["domain"] = str(domain).strip()
|
||
response = requests.post(self.api_base + self.create_path, json=payload, headers=headers, timeout=self.timeout)
|
||
response.raise_for_status()
|
||
data, raw = self.json_or_text(response)
|
||
if not data:
|
||
raise RuntimeError("%s 非JSON: %s" % (self.create_path, raw))
|
||
address = str(data.get("address", "")).strip()
|
||
jwt = str(data.get("jwt", "")).strip()
|
||
if not address or not jwt:
|
||
raise RuntimeError("%s 缺少 address/jwt: %r" % (self.create_path, data))
|
||
return address, jwt
|
||
|
||
def fetch_box(self, jwt, path, params):
|
||
response = requests.get(
|
||
self.api_base + path,
|
||
params=params,
|
||
headers={"Authorization": "Bearer " + str(jwt)},
|
||
timeout=self.timeout,
|
||
)
|
||
if response.status_code >= 400:
|
||
return []
|
||
data, _ = self.json_or_text(response)
|
||
return _pick_list_payload(data)
|
||
|
||
def probe_all_boxes(self, jwt):
|
||
probes = [
|
||
("/api/mails", {"limit": 20, "offset": 0}),
|
||
("/api/sendbox", {"limit": 20, "offset": 0}),
|
||
("/api/mails", {"limit": 20, "offset": 0, "box": "trash"}),
|
||
("/api/mails", {"limit": 20, "offset": 0, "folder": "trash"}),
|
||
("/api/mails", {"limit": 20, "offset": 0, "deleted": "1"}),
|
||
("/api/mails", {"limit": 20, "offset": 0, "status": "deleted"}),
|
||
]
|
||
return [("%s?%s" % (path, params), self.fetch_box(jwt, path, params)) for path, params in probes]
|
||
|
||
def get_detail(self, jwt, mail_id):
|
||
for path in ("/api/mail/%s" % mail_id, "/api/mails/%s" % mail_id):
|
||
try:
|
||
response = requests.get(
|
||
self.api_base + path,
|
||
headers={"Authorization": "Bearer " + str(jwt)},
|
||
timeout=self.timeout,
|
||
)
|
||
if response.status_code >= 400:
|
||
continue
|
||
data, _ = self.json_or_text(response)
|
||
if isinstance(data, dict):
|
||
return data
|
||
except Exception:
|
||
continue
|
||
return {}
|
||
|
||
@staticmethod
|
||
def flatten_mail_text(item, detail):
|
||
subject = str(item.get("subject") or detail.get("subject") or "")
|
||
parts = []
|
||
for source in (item, detail):
|
||
for key in ("text", "raw", "content", "intro", "body", "snippet"):
|
||
value = source.get(key)
|
||
if isinstance(value, str) and value.strip():
|
||
parts.append(value)
|
||
html_value = source.get("html")
|
||
if isinstance(html_value, str):
|
||
html_value = [html_value]
|
||
if isinstance(html_value, list):
|
||
parts.extend(re.sub(r"<[^>]+>", " ", item) for item in html_value if isinstance(item, str))
|
||
return subject, "\n".join(parts)
|