"""接入临时邮箱服务并负责邮箱创建、邮件轮询和验证码提取。""" 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)