Files
grok-register/mail_service.py
T
Aaron LiangandGitHub 9b047db6f6 fix: harden postprocessing and recovery boundaries
Preserve local CPA success while surfacing hotload-copy warnings, retry transient DuckMail/YYDS message details, serialize pending recovery on target files, isolate optional NSFW failures, and restore main-module constant forwarding.
2026-07-15 12:02:57 +08:00

979 lines
38 KiB
Python

"""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 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)
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_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)