refactor: modularize registration runtime safely
This commit is contained in:
+988
@@ -0,0 +1,988 @@
|
||||
"""Temporary-mail providers shared by GUI, CLI, and debug tooling."""
|
||||
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 _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 and log_callback:
|
||||
log_callback(f"[Debug] 跳过疑似非目标邮件 id={msg_id} address={msg_addr} to={recipients}")
|
||||
continue
|
||||
parts = []
|
||||
# 先直接从列表项取内容,避免 detail 接口差异导致漏码
|
||||
for field in ("text", "raw", "content", "intro", "body", "snippet"):
|
||||
value = msg.get(field)
|
||||
if isinstance(value, str) and value.strip():
|
||||
parts.append(value)
|
||||
html_list = msg.get("html") or []
|
||||
if isinstance(html_list, str):
|
||||
html_list = [html_list]
|
||||
for h in html_list:
|
||||
parts.append(re.sub(r"<[^>]+>", " ", h))
|
||||
subject = str(msg.get("subject", "") or "")
|
||||
combined = "\n".join(parts)
|
||||
# 再尝试 detail 接口补全内容
|
||||
try:
|
||||
detail = cloudflare_get_message_detail(api_base, dev_token, msg_id)
|
||||
for field in ("text", "raw", "content", "intro", "body", "snippet"):
|
||||
value = detail.get(field)
|
||||
if isinstance(value, str) and value.strip():
|
||||
combined += "\n" + value
|
||||
html_list2 = detail.get("html") or []
|
||||
if isinstance(html_list2, str):
|
||||
html_list2 = [html_list2]
|
||||
for h in html_list2:
|
||||
combined += "\n" + re.sub(r"<[^>]+>", " ", h)
|
||||
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
|
||||
parts = []
|
||||
code_value = str(msg.get("code", "") or "").strip()
|
||||
if code_value:
|
||||
parts.append(f"verification code: {code_value}")
|
||||
for field in ("text", "content", "html", "body", "snippet"):
|
||||
value = msg.get(field)
|
||||
values = value if isinstance(value, list) else [value]
|
||||
for item in values:
|
||||
if isinstance(item, str) and item.strip():
|
||||
parts.append(re.sub(r"<[^>]+>", " ", item))
|
||||
subject = str(msg.get("subject", "") or "")
|
||||
combined = "\n".join(parts)
|
||||
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_ids = set()
|
||||
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 or msg_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(msg_id)
|
||||
recipients = [t.get("address", "").lower() for t in (msg.get("to") or [])]
|
||||
if email.lower() not in recipients:
|
||||
continue
|
||||
try:
|
||||
detail = get_message_detail(dev_token, msg_id)
|
||||
except Exception as exc:
|
||||
if log_callback:
|
||||
log_callback(f"[Debug] 获取邮件详情失败: {exc}")
|
||||
continue
|
||||
parts = []
|
||||
text_body = detail.get("text") or ""
|
||||
if text_body:
|
||||
parts.append(text_body)
|
||||
html_list = detail.get("html") or []
|
||||
for h in html_list:
|
||||
parts.append(re.sub(r"<[^>]+>", " ", h))
|
||||
combined = "\n".join(parts)
|
||||
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)
|
||||
match = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", text, re.IGNORECASE)
|
||||
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_ids = set()
|
||||
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 or msg_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(msg_id)
|
||||
to_addrs = [t.get("address", "").lower() for t in (msg.get("to") or [])]
|
||||
if address.lower() not in to_addrs:
|
||||
continue
|
||||
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
|
||||
parts = []
|
||||
text_body = detail.get("text") or ""
|
||||
if text_body:
|
||||
parts.append(text_body)
|
||||
html_list = detail.get("html") or []
|
||||
for h in html_list:
|
||||
parts.append(re.sub(r"<[^>]+>", " ", h))
|
||||
combined = "\n".join(parts)
|
||||
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)
|
||||
Reference in New Issue
Block a user