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.
979 lines
38 KiB
Python
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)
|