"""账号管理(Account Management)—— 从 auto(Node) 项目移植的 `plus`(GPT 管理) 功能。 本模块是「加法式」的:仅新增一个 Flask Blueprint,挂到主 app(app.register_blueprint)。 不改动任何既有功能。页面地址 /account-manager/,API 前缀 /api/plus。 数据存储:card-binding/data/plus.json(记录形状与 auto/data/plus.json 一致: email, password, clientId, refreshToken, note, createdAt,拿 RT 后追加 codexRt, codexClientId, codexAccessToken, codexIdToken, codexStatus, banned, banReason)。 两类操作的实现: 纯 Python(列表/导入/删除/删封号/导出)—— 忠实复刻 Node 的纯数据逻辑; Node 子进程(查收 inbox / 批量拿 RT)—— 直接复用 auto 的 get-rt.js / plus-mailbox.js, 经 account-manager/node/*.mjs 包装,用 `node ", " ", s, flags=re.S | re.I) s = re.sub(r"<[^>]+>", " ", s) s = re.sub(r" |&|‌|&#\d+;", " ", s) return re.sub(r"\s+", " ", s).strip() def _clean_header_text(s: str) -> str: """清理发件人/主题/日期等短字段:解码常见实体并折叠空白, 但保留形如 的邮箱地址(不当作 HTML 标签删除)。""" t = str(s or "") t = t.replace("<", "<").replace(">", ">").replace("&", "&") t = re.sub(r" |‌|&#\d+;", " ", t) return re.sub(r"\s+", " ", t).strip() def _parse_api_mailbox_html(html: str, limit: int = 15) -> list: """解析第三方取件 API 返回的 HTML(icloud-api.top 等)为逐封邮件记录。 页面结构:每封邮件为一个
,内含 .fr(发件人)/.su(主题)/.dt(日期)/.bd(正文)。 返回 get-rt.js 期望的形状:[{from_addr, subject, body, received_at(ISO), extracted_code}]。""" from email.utils import parsedate_to_datetime text = str(html or "") chunks = text.split('
')[1:] out: list = [] for ch in chunks[:limit]: frm_m = re.search(r'
(.*?)
', ch, re.S) su_m = re.search(r'
(.*?)
', ch, re.S) dt_m = re.search(r'
(.*?)
', ch, re.S) bd_m = re.search(r'
(.*)$', ch, re.S) frm = _clean_header_text(frm_m.group(1)) if frm_m else "" subject = _clean_header_text(su_m.group(1)) if su_m else "" raw_date = _clean_header_text(dt_m.group(1)) if dt_m else "" body = _strip_html_text(bd_m.group(1)) if bd_m else "" try: dt = parsedate_to_datetime(raw_date) recv_iso = dt.isoformat() if dt else raw_date except Exception: recv_iso = raw_date code = _extract_code(subject) or _extract_code(body) out.append({ "from_addr": frm, "subject": subject, "body": body, "received_at": recv_iso, "extracted_code": code, }) # 无 card 结构时退回整页扫描(尽量仍可取到码) if not out: clean = _strip_html_text(text) code = _extract_code(clean) if clean: out.append({ "from_addr": "", "subject": clean[:120], "body": clean, "received_at": "", "extracted_code": code, }) return out def _msg_text(msg) -> str: parts: list = [] try: walk = msg.walk() if msg.is_multipart() else [msg] for p in walk: if p.get_content_type() not in ("text/plain", "text/html"): continue try: payload = p.get_payload(decode=True) if payload: parts.append(payload.decode(p.get_content_charset() or "utf-8", "replace")) except Exception: continue except Exception: pass return "\n".join(parts) def _mailbox_test_imap(email: str, credential: str, limit: int = 5) -> dict: import imaplib import email as emaillib from email.header import decode_header, make_header host = _imap_host_for(email) M = imaplib.IMAP4_SSL(host, 993, timeout=20) try: M.login(email, credential.replace(" ", "")) M.select("INBOX", readonly=True) typ, data = M.search(None, "ALL") ids = data[0].split() if data and data[0] else [] ids = ids[-limit:][::-1] messages: list = [] for i in ids: # iCloud 对 (RFC822) 返回空,需用 BODY.PEEK[](且不置 \Seen 标记) typ, md = M.fetch(i, "(BODY.PEEK[])") if typ != "OK" or not md: continue raw = next((p[1] for p in md if isinstance(p, tuple) and len(p) >= 2 and p[1]), None) if not raw: continue msg = emaillib.message_from_bytes(raw) try: subject = str(make_header(decode_header(msg.get("Subject", "")))) except Exception: subject = msg.get("Subject", "") try: frm = str(make_header(decode_header(msg.get("From", "")))) except Exception: frm = msg.get("From", "") body = _msg_text(msg) code = _extract_code(subject) or _extract_code(re.sub(r"<[^>]+>", " ", body)) messages.append({ "from": frm, "subject": subject, "date": msg.get("Date", ""), "code": code, }) return {"ok": True, "type": "imap", "host": host, "count": len(messages), "messages": messages} finally: try: M.logout() except Exception: pass def _mailbox_test_api(email: str, url: str) -> dict: if httpx is None: raise RuntimeError("httpx 不可用,无法请求取件 API") r = httpx.get(url, timeout=25, follow_redirects=True) parsed = _parse_api_mailbox_html(r.text or "", 15) # 展示层:优先展示 OpenAI/ChatGPT 验证码那封 def _score(m): blob = f"{m.get('from_addr','')} {m.get('subject','')}".lower() s = 0 if "openai" in blob or "chatgpt" in blob: s += 2 if "verification" in blob or "验证" in blob: s += 1 return s best = sorted(parsed, key=_score, reverse=True)[0] if parsed else None return { "ok": r.status_code == 200, "type": "api", "status": r.status_code, "code": (best or {}).get("extracted_code", ""), "count": len(parsed), "messages": [{ "from": m.get("from_addr", ""), "subject": m.get("subject", "")[:200], "date": m.get("received_at", ""), "code": m.get("extracted_code", ""), } for m in parsed], } def _decode_header(v: str) -> str: from email.header import decode_header, make_header try: return str(make_header(decode_header(v or ""))) except Exception: return str(v or "") def _mailbox_messages(email: str, credential: str, limit: int = 15) -> list: """读取邮箱最新邮件,返回 get-rt.js 期望的形状: [{from_addr, subject, body, received_at(ISO), extracted_code}]。""" if re.match(r"^https?://", credential, re.I): if httpx is None: return [] try: r = httpx.get(credential, timeout=25, follow_redirects=True) except Exception: return [] return _parse_api_mailbox_html(r.text or "", limit) import imaplib import email as emaillib from email.utils import parsedate_to_datetime host = _imap_host_for(email) M = imaplib.IMAP4_SSL(host, 993, timeout=20) try: M.login(email, credential.replace(" ", "")) M.select("INBOX", readonly=True) typ, data = M.search(None, "ALL") ids = (data[0].split() if data and data[0] else [])[-limit:][::-1] msgs: list = [] for i in ids: typ, md = M.fetch(i, "(BODY.PEEK[])") raw = None if typ == "OK" and md: raw = next((p[1] for p in md if isinstance(p, tuple) and len(p) >= 2 and p[1]), None) if not raw: continue m = emaillib.message_from_bytes(raw) subject = _decode_header(m.get("Subject", "")) frm = _decode_header(m.get("From", "")) body = _msg_text(m) recv = m.get("Date", "") try: dt = parsedate_to_datetime(recv) recv_iso = dt.isoformat() if dt else recv except Exception: recv_iso = recv code = _extract_code(subject) or _extract_code(re.sub(r"<[^>]+>", " ", body)) msgs.append({ "from_addr": frm, "subject": subject, "body": body, "received_at": recv_iso, "extracted_code": code, }) return msgs finally: try: M.logout() except Exception: pass def _clamp_int(v, lo, hi, default): try: n = int(round(float(v))) except (TypeError, ValueError): return default return max(lo, min(hi, n)) # ---------- sub2api 解析:兼容 JSON 与 email----password----clientId----refreshToken 行格式 ---------- def parse_sub2api(raw) -> list: text = raw if isinstance(raw, str) else json.dumps(raw) text = str(text or "").strip() if not text: return [] # 1) 优先尝试 sub2api JSON(导出文件 / 账号数组) obj = None try: obj = json.loads(text) except Exception: obj = None if obj is not None: if isinstance(obj, list): arr = obj elif isinstance(obj, dict) and isinstance(obj.get("accounts"), list): arr = obj["accounts"] else: arr = [] out = [] for a in arr: if not isinstance(a, dict): continue cred = a.get("credentials") or a.get("creds") or {} extra = a.get("extra") or {} email = a.get("name") or a.get("email") or extra.get("email") or "" access_token = cred.get("access_token") or a.get("access_token") or "" refresh_token = cred.get("refresh_token") or a.get("refresh_token") or "" if not email and not access_token and not refresh_token: continue out.append({ "email": str(email or "").strip(), "password": str(cred.get("password") or a.get("password") or ""), "accessToken": str(access_token or ""), "refreshToken": str(refresh_token or ""), "clientId": str(cred.get("client_id") or a.get("client_id") or ""), "chatgptAccountId": str(cred.get("chatgpt_account_id") or a.get("chatgpt_account_id") or ""), "planType": str(a.get("plan_type") or cred.get("plan_type") or ""), }) return out # 2) 行格式(非 JSON)——逐行自动识别: # 含分隔符(---- / tab / |)且 >=4 段 → email----password----clientId----refreshToken; # 否则整行视为一个裸 access token(仅 accessToken,其余留空)。 out = [] seen = set() def _dedupe_key(email, at, rt): if email: return "email:" + email.lower() if at: return "at:" + at if rt: return "rt:" + rt return "" def _add(email, password, client_id, refresh_token, access_token): key = _dedupe_key(email, access_token, refresh_token) if not key or key in seen: return seen.add(key) out.append({ "email": email, "password": password, "clientId": client_id, "refreshToken": refresh_token, "accessToken": access_token, "chatgptAccountId": "", "planType": "", }) for raw_line in text.split("\n"): line = raw_line.replace("\r", "").strip() if not line: continue delim = "----" if "----" in line else ("\t" if "\t" in line else ("|" if "|" in line else None)) if delim is not None: parts = [s.strip() for s in line.split(delim) if s.strip()] if len(parts) >= 4 and _EMAIL_RE.match(parts[0]): _add(parts[0], parts[1], parts[2], parts[3], "") # 分隔符行但字段不足/首列非邮箱 → 视为非法,跳过 continue # 无分隔符:可能是空白分隔的 4 字段,或裸 access token ws = [s for s in re.split(r"\s+", line) if s] if len(ws) >= 4 and _EMAIL_RE.match(ws[0]): _add(ws[0], ws[1], ws[2], ws[3], "") else: # 裸 AT:JWT 不含空白,取整行作为 accessToken _add("", "", "", "", ws[0] if len(ws) == 1 else line) return out # ---------- JWT / 账号派生 ---------- def decode_jwt_payload(token: str): try: parts = str(token or "").split(".") if len(parts) < 2: return None seg = parts[1].replace("-", "+").replace("_", "/") seg += "=" * ((4 - len(seg) % 4) % 4) data = json.loads(base64.b64decode(seg).decode("utf-8", "replace")) return data if isinstance(data, dict) else None except Exception: return None def enrich_probe_account(acc: dict) -> dict: a = acc or {} payload = decode_jwt_payload(a.get("accessToken")) or {} auth = payload.get("https://api.openai.com/auth") or {} plan_raw = str(auth.get("chatgpt_plan_type") or "").strip() # 实时 accounts/check 检测得到的套餐(livePlanType / 存储的 planType)权威; # JWT 里的 chatgpt_plan_type 常年过期(PLUS 账号也可能显示 free),仅作兜底。 effective_plan = str(a.get("livePlanType") or a.get("planType") or "").strip() or plan_raw exp = int(payload.get("exp") or 0) if payload else 0 now = int(time.time()) stored = a.get("status") if a.get("status") in ("valid", "invalid", "unknown") else "unknown" token_expired = (exp <= now) if exp else None remaining = (exp - now) if exp else None # 展示状态:ok / expiring / invalid / unknown(源于 JWT exp,尊重刷新失败标记的 invalid) if stored == "invalid": display = "invalid" elif exp: display = "ok" if remaining > PROBE_TOKEN_NEAR_EXP_SEC else "expiring" elif stored == "valid": display = "ok" else: display = "unknown" return { "id": a.get("id"), "email": a.get("email") or "", "password": a.get("password") or "", "twoFactorSecret": a.get("twoFactorSecret") or "", "accessToken": a.get("accessToken") or "", "refreshToken": a.get("refreshToken") or "", "clientId": a.get("clientId") or "", "chatgptAccountId": a.get("chatgptAccountId") or auth.get("chatgpt_account_id") or "", "planType": effective_plan or "unknown", "planLabel": effective_plan.upper() if effective_plan else "未知", "tokenExp": exp, "tokenExpired": token_expired, "tokenExpiresInSec": remaining, "status": display, "rawStatus": stored, "hasAccess": bool(a.get("accessToken")), "hasRefresh": bool(a.get("refreshToken")), # SUB / sub2api 第二阶段授权是否成功(用于账号列表的"SUB"列) "subOk": bool(a.get("subOk")), # 批量直开(card-flow)开通成功的标记(pool-mark-opened 写入) "opened": bool(a.get("opened")), "activatedAt": a.get("activatedAt"), "note": a.get("note") or "", "lastCheckedAt": a.get("lastCheckedAt") or 0, "createdAt": a.get("createdAt") or 0, } # ---------- 代理:从池里取一个(round-robin),转成 httpx 代理 URL ---------- def _proxy_url_from(proxy: dict): if not proxy or not proxy.get("host") or not proxy.get("port"): return None user = proxy.get("username") if user: from urllib.parse import quote as _q auth = f"{_q(str(user))}:{_q(str(proxy.get('password') or ''))}@" else: auth = "" return f"http://{auth}{proxy['host']}:{proxy['port']}" def _pick_proxy_url(): proxies = load_proxies() ok = [p for p in proxies if p.get("status") == "ok"] pool = ok if ok else proxies if not pool: return None with _probe_lock: idx = _probe_rr["i"] % len(pool) _probe_rr["i"] += 1 return _proxy_url_from(pool[idx]) # ---------- 核心:refresh_token 换 access_token(普通 HTTPS,忠实复刻 probe.js refreshOpenAiTokens)---------- def refresh_openai_tokens(refresh_token: str, proxy_url: str | None = None) -> dict: if not refresh_token: return {"ok": False, "accessToken": "", "refreshToken": "", "error": "missing_refresh_token", "httpStatus": 0} if httpx is None: return {"ok": False, "accessToken": "", "refreshToken": "", "error": "httpx_unavailable", "httpStatus": 0} data = { "grant_type": "refresh_token", "client_id": OPENAI_CLIENT_ID, "refresh_token": refresh_token, "scope": OPENAI_SCOPE, } headers = {"content-type": "application/x-www-form-urlencoded", "user-agent": CHROME_UA} try: client_kwargs = {"timeout": 30.0, "headers": headers} if proxy_url: client_kwargs["proxy"] = proxy_url with httpx.Client(**client_kwargs) as client: r = client.post(OPENAI_TOKEN_URL, data=data) try: body = r.json() except Exception: body = {} if r.status_code == 200 and body.get("access_token"): return { "ok": True, "accessToken": str(body.get("access_token") or ""), "refreshToken": str(body.get("refresh_token") or refresh_token or ""), "error": "", "httpStatus": r.status_code, } err = str(body.get("error") or body.get("error_description") or f"HTTP {r.status_code}") return {"ok": False, "accessToken": "", "refreshToken": "", "error": err, "httpStatus": r.status_code} except Exception as exc: # noqa: BLE001 return {"ok": False, "accessToken": "", "refreshToken": "", "error": str(exc), "httpStatus": 0} def _refresh_with_fallback(refresh_token: str) -> dict: """先经代理池刷新;若因区域被 403(unsupported_country...) 拦截,则直连再试一次。 auto 原实现是直连(无代理),这里保留“代理池优先 + 直连兜底”以兼顾区域限制。""" proxy_url = _pick_proxy_url() r = refresh_openai_tokens(refresh_token, proxy_url) if (not r.get("ok")) and proxy_url and r.get("httpStatus") == 403 \ and re.search(r"unsupported_country|region|territory", r.get("error") or "", re.I): r2 = refresh_openai_tokens(refresh_token, None) # 直连兜底 if r2.get("ok") or r2.get("httpStatus") not in (0, 403): return r2 return r # ---------- 实时套餐校验:用 access_token 调 accounts/check(curl_cffi 子进程)---------- def _plan_check_once(access_token: str, account_id: str, proxy_url: str | None) -> dict: payload = {"access_token": access_token, "account_id": account_id or "", "proxy": proxy_url or "DIRECT"} try: proc = subprocess.run( [PY_BIN, PLAN_CHECK_SCRIPT], input=json.dumps(payload), capture_output=True, text=True, encoding="utf-8", cwd=str(ROOT), timeout=90, ) except FileNotFoundError: return {"ok": False, "httpStatus": 0, "error": f"未找到 Python 解释器:{PY_BIN}"} except subprocess.TimeoutExpired: return {"ok": False, "httpStatus": 0, "error": "套餐校验超时"} out = (proc.stdout or "").strip() if not out: return {"ok": False, "httpStatus": 0, "error": (proc.stderr or "no output")[:300]} try: return json.loads(out.splitlines()[-1]) except Exception: return {"ok": False, "httpStatus": 0, "error": out[:300]} def _live_plan_check(access_token: str, account_id: str) -> dict: """先经代理池校验;若区域 403 被拦,直连兜底再试一次。""" proxy_url = _pick_proxy_url() r = _plan_check_once(access_token, account_id, proxy_url) if (not r.get("ok")) and proxy_url and ( r.get("httpStatus") in (403, 0) or re.search(r"unsupported_country|region|territory", str(r.get("error") or ""), re.I) ): r2 = _plan_check_once(access_token, account_id, None) # 直连兜底 if r2.get("ok"): return r2 return r def _refresh_probe_by_id(acc_id: str) -> dict: with _probe_lock: items = load_probe() acc = next((a for a in items if str(a.get("id")) == str(acc_id)), None) if not acc: return {"ok": False, "notFound": True, "error": "账号不存在"} if not acc.get("refreshToken"): acc["status"] = "invalid" acc["lastCheckedAt"] = int(time.time() * 1000) save_probe(items) return {"ok": False, "error": "缺少 refreshToken", "account": enrich_probe_account(acc)} rt = acc["refreshToken"] r = _refresh_with_fallback(rt) with _probe_lock: items = load_probe() acc = next((a for a in items if str(a.get("id")) == str(acc_id)), None) if not acc: return {"ok": False, "notFound": True, "error": "账号不存在"} acc["lastCheckedAt"] = int(time.time() * 1000) if r.get("ok") and r.get("accessToken"): acc["accessToken"] = r["accessToken"] if r.get("refreshToken"): acc["refreshToken"] = r["refreshToken"] acc["status"] = "valid" save_probe(items) return {"ok": True, "account": enrich_probe_account(acc)} invalid = r.get("httpStatus") == 401 or re.search(r"invalid_grant|invalid_token|unauthorized", r.get("error") or "", re.I) acc["status"] = "invalid" if invalid else (acc.get("status") or "unknown") save_probe(items) return {"ok": False, "error": r.get("error") or "refresh_failed", "httpStatus": r.get("httpStatus"), "account": enrich_probe_account(acc)} def _run_probe_auto_refresh() -> dict: with _probe_auto_lock: if _probe_auto["running"]: return _probe_auto["lastResult"] _probe_auto["running"] = True summary = {"scanned": 0, "refreshed": 0, "skipped": 0, "failed": 0, "invalid": 0} try: snapshot = load_probe() now = int(time.time()) summary["scanned"] = len(snapshot) updates: dict = {} # id -> 待写回字段(仅按 id 合并,避免覆盖并发删除/编辑) for acc in snapshot: try: if not acc.get("refreshToken"): summary["skipped"] += 1 continue payload = decode_jwt_payload(acc.get("accessToken")) exp = int(payload.get("exp") or 0) if payload else 0 status = str(acc.get("status") or "unknown") need = status == "unknown" or not exp or exp <= now + PROBE_TOKEN_NEAR_EXP_SEC if not need: summary["skipped"] += 1 continue r = _refresh_with_fallback(acc["refreshToken"]) upd = {"lastCheckedAt": int(time.time() * 1000)} if r.get("ok") and r.get("accessToken"): upd["accessToken"] = r["accessToken"] if r.get("refreshToken"): upd["refreshToken"] = r["refreshToken"] upd["status"] = "valid" summary["refreshed"] += 1 else: invalid = r.get("httpStatus") == 401 or re.search(r"invalid_grant|invalid_token|unauthorized", r.get("error") or "", re.I) if invalid: upd["status"] = "invalid" summary["invalid"] += 1 else: summary["failed"] += 1 updates[str(acc.get("id"))] = upd except Exception: summary["failed"] += 1 # 按 id 合并写回当前存储(重新加载,跳过期间已被删除的账号) with _probe_lock: items = load_probe() by_id = {str(a.get("id")): a for a in items} for acc_id, upd in updates.items(): target = by_id.get(acc_id) if target is not None: target.update(upd) save_probe(items) with _probe_auto_lock: _probe_auto["lastRunAt"] = int(time.time() * 1000) _probe_auto["lastResult"] = summary return summary finally: with _probe_auto_lock: _probe_auto["running"] = False def _auto_refresh_state() -> dict: s = load_settings() with _probe_auto_lock: return { "enabled": s.get("probeTokenAutoRefresh", True) is not False, "intervalMin": _clamp_int(s.get("probeTokenAutoRefreshMin"), 5, 1440, 15), "lastRunAt": _probe_auto["lastRunAt"], "lastResult": _probe_auto["lastResult"], "running": _probe_auto["running"], } def _auto_refresh_loop(): time.sleep(8) # 启动后稍晚跑一轮,避免拖慢 listen(对齐 auto) while True: try: state = _auto_refresh_state() if state["enabled"]: _run_probe_auto_refresh() interval = _auto_refresh_state()["intervalMin"] except Exception: interval = 15 _probe_auto_wake.wait(timeout=interval * 60) _probe_auto_wake.clear() # ---------- GET /api/probe/accounts ---------- @bp.get("/api/probe/accounts") def api_probe_list(): items = load_probe() return jsonify({ "accounts": [enrich_probe_account(a) for a in items], "autoRefresh": _auto_refresh_state(), }) # ---------- POST /api/probe/accounts/import ---------- @bp.post("/api/probe/accounts/import") def api_probe_import(): body = request.get_json(silent=True) or {} parsed = parse_sub2api(body.get("text") or "") with _probe_lock: items = load_probe() by_email = {str(a.get("email") or "").lower(): a for a in items if a.get("email")} by_at = {str(a.get("accessToken")): a for a in items if a.get("accessToken")} by_rt = {str(a.get("refreshToken")): a for a in items if a.get("refreshToken")} added = 0 updated = 0 for item in parsed: key = str(item.get("email") or "").lower() # 有邮箱按邮箱去重;无邮箱(裸AT/仅RT)按 accessToken / refreshToken 去重 existing = by_email.get(key) if key else None if existing is None and not key: if item.get("accessToken"): existing = by_at.get(str(item.get("accessToken"))) if existing is None and item.get("refreshToken"): existing = by_rt.get(str(item.get("refreshToken"))) if existing: existing["accessToken"] = item.get("accessToken") or existing.get("accessToken") or "" existing["refreshToken"] = item.get("refreshToken") or existing.get("refreshToken") or "" if item.get("password"): existing["password"] = item["password"] if item.get("clientId"): existing["clientId"] = item["clientId"] existing["chatgptAccountId"] = item.get("chatgptAccountId") or existing.get("chatgptAccountId") or "" existing["planType"] = item.get("planType") or existing.get("planType") or "" existing["status"] = "unknown" existing["lastCheckedAt"] = 0 updated += 1 else: rec = { "id": str(uuid.uuid4()), "email": item.get("email") or "", "password": item.get("password") or "", "clientId": item.get("clientId") or "", "refreshToken": item.get("refreshToken") or "", "accessToken": item.get("accessToken") or "", "chatgptAccountId": item.get("chatgptAccountId") or "", "planType": item.get("planType") or "", "note": "", "status": "unknown", "lastCheckedAt": 0, "createdAt": int(time.time() * 1000), } items.append(rec) if key: by_email[key] = rec if rec.get("accessToken"): by_at[str(rec["accessToken"])] = rec if rec.get("refreshToken"): by_rt[str(rec["refreshToken"])] = rec added += 1 save_probe(items) total = len(items) # 导入后触发未知状态账号的自动刷新(若开启) if (added + updated) > 0 and _auto_refresh_state()["enabled"]: threading.Thread(target=_run_probe_auto_refresh, name="probe-import-refresh", daemon=True).start() return jsonify({"added": added, "updated": updated, "parsed": len(parsed), "total": total}) # ---------- PUT /api/probe/accounts(编辑)---------- @bp.put("/api/probe/accounts") def api_probe_edit(): body = request.get_json(silent=True) or {} acc_id = str(body.get("id") or "") if not acc_id: return jsonify({"error": "缺少 id"}), 400 with _probe_lock: items = load_probe() acc = next((a for a in items if str(a.get("id")) == acc_id), None) if not acc: return jsonify({"error": "账号不存在"}), 404 if body.get("email") is not None: acc["email"] = str(body.get("email") or "").strip() if body.get("accessToken") is not None: acc["accessToken"] = str(body.get("accessToken") or "").strip() if body.get("refreshToken") is not None: acc["refreshToken"] = str(body.get("refreshToken") or "").strip() if body.get("chatgptAccountId") is not None: acc["chatgptAccountId"] = str(body.get("chatgptAccountId") or "").strip() if body.get("password") is not None: acc["password"] = str(body.get("password") or "") if body.get("twoFactorSecret") is not None: acc["twoFactorSecret"] = str(body.get("twoFactorSecret") or "").strip() if body.get("note") is not None: acc["note"] = str(body.get("note") or "") # 编辑后:可解 JWT 且原为 invalid 则回到 unknown 等待下次校验 payload = decode_jwt_payload(acc.get("accessToken")) if payload and payload.get("exp"): acc["status"] = "unknown" if acc.get("status") == "invalid" else (acc.get("status") or "unknown") save_probe(items) enriched = enrich_probe_account(acc) return jsonify({"account": enriched}) # ---------- POST /api/probe/accounts/refresh(刷新一个)---------- @bp.post("/api/probe/accounts/refresh") def api_probe_refresh(): body = request.get_json(silent=True) or {} acc_id = str(body.get("id") or "") if not acc_id: return jsonify({"error": "缺少 id"}), 400 result = _refresh_probe_by_id(acc_id) if not result.get("ok") and result.get("notFound"): return jsonify({"error": "账号不存在"}), 404 return jsonify(result) # ---------- POST /api/probe/accounts/detect(用 access_token 实时查真实套餐 FREE/PLUS/PRO)---------- # 优先实时调用 chatgpt.com/backend-api/accounts/check(curl_cffi 子进程,读 plan_type + # has_active_subscription)——这是账号“当前真实级别”,而非 AT 内 JWT 的历史标记。 # 若 AT 已过期且有 refreshToken,先刷新拿到最新 AT 再校验;实时校验失败(区域/被拦/无网络)时 # 回退用 AT 内 JWT 字段,并在返回中标注 source=jwt 与失败原因,绝不谎报为实时结果。 @bp.post("/api/probe/accounts/detect") def api_probe_detect(): body = request.get_json(silent=True) or {} acc_id = str(body.get("id") or "") if not acc_id: return jsonify({"error": "缺少 id"}), 400 with _probe_lock: acc = next((a for a in load_probe() if str(a.get("id")) == acc_id), None) if acc is None: return jsonify({"error": "账号不存在"}), 404 has_rt = bool(acc.get("refreshToken")) payload = decode_jwt_payload(acc.get("accessToken")) exp = int(payload.get("exp") or 0) if payload else 0 # AT 过期且有 refreshToken:先刷新,拿到可用于实时校验的最新 AT refreshed = False if has_rt and (not exp or exp <= int(time.time())): refreshed = bool(_refresh_probe_by_id(acc_id).get("ok")) with _probe_lock: items = load_probe() acc = next((a for a in items if str(a.get("id")) == acc_id), None) if acc is None: return jsonify({"error": "账号不存在"}), 404 access_token = str(acc.get("accessToken") or "") account_id = str(acc.get("chatgptAccountId") or "") if not access_token: with _probe_lock: items = load_probe() acc = next((a for a in items if str(a.get("id")) == acc_id), None) pub = enrich_probe_account(acc) if acc else {} return jsonify({"ok": False, "error": "缺少 Access Token,无法检测套餐", "account": pub}) # 实时 accounts/check live = _live_plan_check(access_token, account_id) if live.get("ok") and (live.get("plan") or live.get("active")): plan = str(live.get("plan") or ("plus" if live.get("active") else "")).strip().lower() with _probe_lock: items = load_probe() acc = next((a for a in items if str(a.get("id")) == acc_id), None) if acc is None: return jsonify({"error": "账号不存在"}), 404 if plan: acc["planType"] = plan acc["livePlanType"] = plan # 实时检测权威值,enrich 优先使用,避免被 JWT/再导入覆盖 if live.get("accountId") and not acc.get("chatgptAccountId"): acc["chatgptAccountId"] = live.get("accountId") acc["lastCheckedAt"] = int(time.time() * 1000) pub = enrich_probe_account(acc) save_probe(items) return jsonify({ "ok": True, "source": "live", "refreshed": refreshed, "active": bool(live.get("active")), "planType": pub.get("planType"), "planLabel": pub.get("planLabel"), "account": pub, }) # 实时失败 → 回退 JWT,如实标注 with _probe_lock: items = load_probe() acc = next((a for a in items if str(a.get("id")) == acc_id), None) if acc is None: return jsonify({"error": "账号不存在"}), 404 pub = enrich_probe_account(acc) acc["planType"] = pub.get("planType") acc["lastCheckedAt"] = int(time.time() * 1000) save_probe(items) reason = str(live.get("error") or "").strip() if live.get("httpStatus"): reason = f"HTTP {live.get('httpStatus')} {reason}".strip() return jsonify({ "ok": True, "source": "jwt", "refreshed": refreshed, "liveError": reason or "实时校验失败", "planType": pub.get("planType"), "planLabel": pub.get("planLabel"), "account": pub, }) # ---------- 批量检测:≤5 线程并发跑实时套餐校验 ---------- def _probe_detect_once(acc_id: str) -> bool: """对单个账号跑实时 accounts/check(代理→直连兜底),写回 planType/livePlanType。 per-account 异常不抛出(由 _safe_detect_one 兜底),返回是否成功检测到套餐。""" with _probe_lock: items = load_probe() acc = next((a for a in items if str(a.get("id")) == str(acc_id)), None) if not acc: return False access_token = str(acc.get("accessToken") or "") account_id = str(acc.get("chatgptAccountId") or "") if not access_token: return False live = _live_plan_check(access_token, account_id) # 不持锁跑子进程,允许并发 with _probe_lock: items = load_probe() acc = next((a for a in items if str(a.get("id")) == str(acc_id)), None) if not acc: return False acc["lastCheckedAt"] = int(time.time() * 1000) if live.get("ok") and (live.get("plan") or live.get("active")): plan = str(live.get("plan") or ("plus" if live.get("active") else "")).strip().lower() if plan: acc["planType"] = plan acc["livePlanType"] = plan if live.get("accountId") and not acc.get("chatgptAccountId"): acc["chatgptAccountId"] = live.get("accountId") save_probe(items) return True save_probe(items) return False def _safe_detect_one(acc_id: str) -> bool: try: return _probe_detect_once(acc_id) except Exception: return False def _run_probe_detect_batch(ids: list) -> None: from concurrent.futures import ThreadPoolExecutor, as_completed with _probe_detect_lock: _probe_detect.update({"running": True, "total": len(ids), "done": 0, "ok": 0, "fail": 0, "startedAt": int(time.time() * 1000), "finishedAt": 0}) try: with ThreadPoolExecutor(max_workers=5) as ex: futs = [ex.submit(_safe_detect_one, i) for i in ids] for fut in as_completed(futs): ok = False try: ok = bool(fut.result()) except Exception: ok = False with _probe_detect_lock: _probe_detect["done"] += 1 if ok: _probe_detect["ok"] += 1 else: _probe_detect["fail"] += 1 finally: with _probe_detect_lock: _probe_detect["running"] = False _probe_detect["finishedAt"] = int(time.time() * 1000) # ---------- POST /api/probe/accounts/detect-batch(选中或全部,≤5 线程)---------- @bp.post("/api/probe/accounts/detect-batch") def api_probe_detect_batch(): body = request.get_json(silent=True) or {} ids = body.get("ids") with _probe_lock: items = load_probe() if ids: idset = {str(x) for x in ids} targets = [str(a.get("id")) for a in items if str(a.get("id")) in idset and a.get("accessToken")] else: targets = [str(a.get("id")) for a in items if a.get("accessToken")] with _probe_detect_lock: if _probe_detect["running"]: return jsonify({"ok": False, "error": "批量检测正在进行中", "status": dict(_probe_detect)}), 409 if not targets: return jsonify({"ok": False, "error": "没有可检测的账号(需有 Access Token)"}), 400 threading.Thread(target=_run_probe_detect_batch, args=(targets,), name="probe-detect-batch", daemon=True).start() return jsonify({"ok": True, "total": len(targets)}) # ---------- GET /api/probe/accounts/detect-batch/status(进度轮询)---------- @bp.get("/api/probe/accounts/detect-batch/status") def api_probe_detect_batch_status(): with _probe_detect_lock: return jsonify(dict(_probe_detect)) # ---------- GET/POST /api/probe/accounts/auto-refresh(开关 + 间隔)---------- @bp.get("/api/probe/accounts/auto-refresh") def api_probe_auto_get(): return jsonify(_auto_refresh_state()) @bp.post("/api/probe/accounts/auto-refresh") def api_probe_auto_post(): body = request.get_json(silent=True) or {} s = load_settings() enabled = body.get("enabled") s["probeTokenAutoRefresh"] = enabled is not False and enabled != "false" if body.get("intervalMin") is not None: s["probeTokenAutoRefreshMin"] = _clamp_int(body.get("intervalMin"), 5, 1440, 15) save_settings(s) _probe_auto_wake.set() # 唤醒调度线程,使新配置立即生效 if body.get("runNow"): threading.Thread(target=_run_probe_auto_refresh, name="probe-run-now", daemon=True).start() return jsonify(_auto_refresh_state()) # ---------- DELETE /api/probe/accounts(ids / all)---------- @bp.delete("/api/probe/accounts") def api_probe_delete(): body = request.get_json(silent=True) or {} with _probe_lock: items = load_probe() before = len(items) if body.get("all"): items = [] else: ids = {str(i) for i in (body.get("ids") or [])} items = [a for a in items if str(a.get("id")) not in ids] save_probe(items) total = len(items) return jsonify({"removed": before - total, "total": total}) # ---------- POST /api/probe/accounts/export-at(批量导出 AT,一行一个)---------- @bp.post("/api/probe/accounts/export-at") def api_probe_export_at(): body = request.get_json(silent=True) or {} ids = body.get("ids") items = load_probe() base = [a for a in items if str(a.get("id")) in {str(i) for i in ids}] if ids else items tokens = [str(a.get("accessToken") or "").strip() for a in base] tokens = [t for t in tokens if t] ts = time.strftime("%Y-%m-%dT%H-%M-%S", time.gmtime()) return Response( "\n".join(tokens) + ("\n" if tokens else ""), mimetype="text/plain", headers={"Content-Disposition": f'attachment; filename="probe_at_export_{ts}.txt"', "X-Export-Count": str(len(tokens))}, ) # ---------- POST /api/probe/accounts/export-sub(批量导出 SUB2API JSON)---------- @bp.post("/api/probe/accounts/export-sub") def api_probe_export_sub(): body = request.get_json(silent=True) or {} ids = body.get("ids") items = load_probe() base = [a for a in items if str(a.get("id")) in {str(i) for i in ids}] if ids else items # 有第二阶段 SUB 令牌的用 SUB 令牌导出;否则回退第一阶段 refreshToken(clientId 用 sub2api 默认常量)。 with_rt = [a for a in base if str(a.get("subRefreshToken") or a.get("refreshToken") or "").strip()] accounts = [] for a in with_rt: email = a.get("email") or "" sub_rt = str(a.get("subRefreshToken") or "").strip() if sub_rt: credentials = {"refresh_token": sub_rt, "client_id": a.get("subClientId") or SUB2API_DEFAULT_CLIENT_ID, "email": email} if a.get("subAccessToken"): credentials["access_token"] = a.get("subAccessToken") else: credentials = {"refresh_token": a.get("refreshToken"), "client_id": SUB2API_DEFAULT_CLIENT_ID, "email": email} if a.get("accessToken"): credentials["access_token"] = a.get("accessToken") if a.get("chatgptAccountId"): credentials["chatgpt_account_id"] = a.get("chatgptAccountId") accounts.append({ "name": email, "platform": "openai", "type": "oauth", "credentials": credentials, "extra": {"email": email}, "concurrency": 1, "priority": 1, "rate_multiplier": 1, "auto_pause_on_expired": True, }) payload = { "type": "sub2api-data", "version": 1, "exported_at": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), "proxies": [], "accounts": accounts, } ts = time.strftime("%Y-%m-%dT%H-%M-%S", time.gmtime()) return Response( json.dumps(payload, ensure_ascii=False, indent=2), mimetype="application/json", headers={"Content-Disposition": f'attachment; filename="sub2api_export_{ts}.json"', "X-Export-Count": str(len(accounts))}, ) # ---------- POST /api/probe/accounts/export-pw(批量导出账密,一行一个 email----password)---------- @bp.post("/api/probe/accounts/export-pw") def api_probe_export_pw(): body = request.get_json(silent=True) or {} ids = body.get("ids") items = load_probe() base = [a for a in items if str(a.get("id")) in {str(i) for i in ids}] if ids else items # 首行表头 + 每行 email----password----twoFactorSecret(字段缺失留空但保留分隔符)。 lines = ["账号----密码----2FA"] for a in base: email = str(a.get("email") or "").strip() if not email: continue pw = str(a.get("password") or "").strip() tfa = str(a.get("twoFactorSecret") or "").strip() lines.append(f"{email}----{pw}----{tfa}") ts = time.strftime("%Y-%m-%dT%H-%M-%S", time.gmtime()) return Response( "\n".join(lines) + "\n", mimetype="text/plain", headers={"Content-Disposition": f'attachment; filename="probe_pw_export_{ts}.txt"', "X-Export-Count": str(max(0, len(lines) - 1))}, # 数据行数(不含表头) ) # ========================================================================== # 设置:代理池 + 接码/SMS 配置的读写(供页面「设置」弹窗使用) # ========================================================================== def _proxy_to_line(p: dict) -> str: if p.get("raw"): return str(p["raw"]) host = p.get("host") or "" port = p.get("port") or "" user = p.get("username") or "" pw = p.get("password") or "" if user: return f"{host}:{port}:{user}:{pw}" return f"{host}:{port}" def _parse_proxy_line(line: str): line = line.strip() if not line: return None # 支持 scheme://user:pass@host:port m = re.match(r"^(?:(\w+)://)?(?:([^:@/]+):([^@/]*)@)?([^:/@]+):(\d+)$", line) if m: _scheme, u1, p1, host, port = m.groups() rec = {"raw": line, "host": host, "port": int(port)} if u1: rec["username"] = u1 rec["password"] = p1 or "" return rec # 支持 host:port:user:pass parts = line.split(":") if len(parts) == 2: return {"raw": line, "host": parts[0], "port": int(parts[1])} if parts[1].isdigit() else None if len(parts) >= 4 and parts[1].isdigit(): return {"raw": line, "host": parts[0], "port": int(parts[1]), "username": parts[2], "password": ":".join(parts[3:])} return None @bp.get("/api/account-config") def api_account_config_get(): proxies = load_proxies() settings = load_settings() sms = settings.get("sms") or {} provider = sms.get("provider") or "smscode" prov_cfg = sms.get(provider) or {} mb = load_mailbox() def _sms_view(cfg: dict) -> dict: cfg = cfg or {} country = cfg.get("country") if country in (None, ""): country = cfg.get("countryId") return { "apiKey": cfg.get("apiKey") or "", "baseUrl": cfg.get("baseUrl") or "", "country": "" if country in (None, "") else country, "maxPrice": cfg.get("maxPrice") or "", "service": cfg.get("service") or "", } return jsonify({ "proxies": [_proxy_to_line(p) for p in proxies], "proxyCount": len(proxies), "sms": { "provider": provider, "pollTimeout": sms.get("pollTimeout") or 180000, **_sms_view(prov_cfg), }, "smsAll": {p: _sms_view(sms.get(p) or {}) for p in ("smscode", "smsbower", "herosms")}, "providers": ["smscode", "smsbower", "herosms"], "mailbox": { "entries": [_mailbox_public(e) for e in mb.get("entries", [])], "count": len(mb.get("entries", [])), "unused": sum(1 for e in mb.get("entries", []) if not e.get("used")), "split": bool(mb.get("split")), }, # 浏览器注册(CloakBrowser)设置:license key + 固定注册密码(与 proxy/sms/mailbox 同一套 settings.json 持久化)。 "cloak": { "licenseKey": settings.get("cloakLicenseKey") or "", "regPassword": settings.get("regPassword") or "", }, }) @bp.post("/api/account-config") def api_account_config_post(): body = request.get_json(silent=True) or {} result = {"proxySaved": False, "smsSaved": False} # 代理池:支持传字符串(一行一个)或已解析数组 if "proxies" in body: raw = body.get("proxies") lines = raw.split("\n") if isinstance(raw, str) else [str(x) for x in (raw or [])] parsed = [] bad = [] for ln in lines: ln = ln.strip() if not ln: continue rec = _parse_proxy_line(ln) if rec: parsed.append(rec) else: bad.append(ln) CONFIG_DIR.mkdir(parents=True, exist_ok=True) PROXY_FILE.write_text(json.dumps(parsed, ensure_ascii=False, indent=2), encoding="utf-8") result["proxySaved"] = True result["proxyCount"] = len(parsed) result["proxyBad"] = bad # 接码/SMS 配置 if "sms" in body and isinstance(body["sms"], dict): sms_in = body["sms"] settings = load_settings() sms = settings.get("sms") or {} provider = sms_in.get("provider") if sms_in.get("provider") in ("smscode", "smsbower", "herosms") else (sms.get("provider") or "smscode") sms["provider"] = provider if sms_in.get("pollTimeout") is not None: try: sms["pollTimeout"] = int(sms_in["pollTimeout"]) except (TypeError, ValueError): pass prov_cfg = dict(sms.get(provider) or {}) if sms_in.get("apiKey") is not None: prov_cfg["apiKey"] = str(sms_in.get("apiKey") or "") if sms_in.get("baseUrl"): prov_cfg["baseUrl"] = str(sms_in.get("baseUrl")) if sms_in.get("country") is not None: raw_country = str(sms_in.get("country") or "").strip() country_val: object = "" if raw_country: country_val = int(raw_country) if raw_country.isdigit() else raw_country # smscode 用 countryId,其余(smsbower/herosms)用 country if provider == "smscode": prov_cfg["countryId"] = country_val else: prov_cfg["country"] = country_val if sms_in.get("maxPrice") is not None: prov_cfg["maxPrice"] = str(sms_in.get("maxPrice") or "").strip() sms[provider] = prov_cfg settings["sms"] = sms save_settings(settings) result["smsSaved"] = True # 邮箱池:这里只更新 split 开关,条目增改删走 /api/mailbox/* 独立接口(避免清掉 used 状态) if "mailbox" in body and isinstance(body["mailbox"], dict): mb = load_mailbox() mb["split"] = bool(body["mailbox"].get("split")) save_mailbox(mb) result["mailboxSaved"] = True # 浏览器注册(CloakBrowser)license key:存到 settings.json 的 cloakLicenseKey, # 供 cloak_reg_runner.mjs 启动隐身 Chromium 时使用(Pro 用最新二进制)。 if "cloak" in body and isinstance(body["cloak"], dict): settings = load_settings() if body["cloak"].get("licenseKey") is not None: settings["cloakLicenseKey"] = str(body["cloak"].get("licenseKey") or "").strip() # 固定注册密码:两种注册模式共用(reg_password.mjs 优先读 settings.regPassword); # 留空则清空,运行时回退到内置默认强口令。 if body["cloak"].get("regPassword") is not None: settings["regPassword"] = str(body["cloak"].get("regPassword") or "").strip() save_settings(settings) result["cloakSaved"] = True return jsonify(result) # ---------- 邮箱池条目管理 ---------- @bp.post("/api/mailbox/import") def api_mailbox_import(): body = request.get_json(silent=True) or {} parsed = parse_mailbox_lines(body.get("text") or "") with _plus_lock: mb = load_mailbox() entries, added = merge_mailbox_entries(mb.get("entries", []), parsed) mb["entries"] = entries save_mailbox(mb) return jsonify({ "added": added, "parsed": len(parsed), "count": len(entries), "unused": sum(1 for e in entries if not e.get("used")), "entries": [_mailbox_public(e) for e in entries], }) @bp.post("/api/mailbox/update") def api_mailbox_update(): body = request.get_json(silent=True) or {} email = str(body.get("email") or "").strip().lower() if not email: return jsonify({"ok": False, "error": "缺少邮箱"}), 400 with _plus_lock: mb = load_mailbox() e = next((x for x in mb.get("entries", []) if x.get("email", "").lower() == email), None) if not e: return jsonify({"ok": False, "error": "邮箱不存在"}), 404 if body.get("credential") is not None: cred = str(body.get("credential") or "").strip() if cred: e["credential"] = cred e["type"] = "api" if re.match(r"^https?://", cred, re.I) else "imap" if body.get("used") is not None: e["used"] = bool(body.get("used")) save_mailbox(mb) return jsonify({"ok": True, "entry": _mailbox_public(e)}) @bp.post("/api/mailbox/split-generate") def api_mailbox_split_generate(): """为每个主邮箱(local 不含 +)生成 count 个 local+XXXX 别名,复用主邮箱凭证,并入列表。""" body = request.get_json(silent=True) or {} try: count = max(1, min(200, int(body.get("count") or 10))) except (TypeError, ValueError): count = 10 with _plus_lock: mb = load_mailbox() entries = mb.get("entries", []) existing = {e.get("email", "").lower() for e in entries} added = 0 for e in list(entries): email = e.get("email", "") if "@" not in email: continue local, _, domain = email.partition("@") if "+" in local: continue # 已是别名不再分裂 for _ in range(count): alias = "" for _try in range(50): cand = f"{local}+{uuid.uuid4().hex[:4]}@{domain}" if cand.lower() not in existing: alias = cand break if not alias: continue existing.add(alias.lower()) entries.append({ "email": alias, "credential": e.get("credential", ""), "type": e.get("type", "imap"), "used": False, "alias_of": email, }) added += 1 mb["entries"] = entries save_mailbox(mb) return jsonify({ "added": added, "count": len(entries), "unused": sum(1 for e in entries if not e.get("used")), "entries": [_mailbox_public(e) for e in entries], }) @bp.post("/api/mailbox/delete") def api_mailbox_delete(): body = request.get_json(silent=True) or {} targets = {str(x).strip().lower() for x in (body.get("emails") or [])} one = str(body.get("email") or "").strip().lower() if one: targets.add(one) if not targets: return jsonify({"ok": False, "error": "缺少邮箱"}), 400 with _plus_lock: mb = load_mailbox() before = len(mb.get("entries", [])) mb["entries"] = [e for e in mb.get("entries", []) if e.get("email", "").lower() not in targets] save_mailbox(mb) entries = mb["entries"] return jsonify({ "ok": True, "removed": before - len(entries), "count": len(entries), "unused": sum(1 for e in entries if not e.get("used")), "entries": [_mailbox_public(e) for e in entries], }) @bp.post("/api/mailbox/clear") def api_mailbox_clear(): """清空邮箱池全部条目(前端已做主题化二次确认)。""" with _plus_lock: mb = load_mailbox() removed = len(mb.get("entries", [])) mb["entries"] = [] save_mailbox(mb) return jsonify({"ok": True, "removed": removed, "count": 0, "unused": 0, "entries": []}) @bp.post("/api/sms/balance") def api_sms_balance(): """查询接码平台余额(smsbower / herosms 走 handler_api.php 的 getBalance)。""" body = request.get_json(silent=True) or {} settings = load_settings() sms = settings.get("sms") or {} provider = str(body.get("provider") or sms.get("provider") or "smsbower").strip() prov_cfg = sms.get(provider) or {} api_key = str(body.get("apiKey") or prov_cfg.get("apiKey") or "").strip() base_url = str(body.get("baseUrl") or prov_cfg.get("baseUrl") or "").strip() if not api_key: return jsonify({"ok": False, "error": "未配置 API Key"}) if not base_url: return jsonify({"ok": False, "error": "未配置 Base URL"}) if httpx is None: return jsonify({"ok": False, "error": "httpx 不可用"}) try: r = httpx.get(base_url, params={"api_key": api_key, "action": "getBalance"}, timeout=20) text = (r.text or "").strip() if text.startswith("ACCESS_BALANCE"): bal = text.split(":", 1)[1].strip() if ":" in text else "" return jsonify({"ok": True, "provider": provider, "balance": bal, "raw": text}) errmap = { "BAD_KEY": "API 密钥不正确", "BAD_ACTION": "动作错误", "ERROR_SQL": "服务端错误,请稍后再试", } return jsonify({"ok": False, "error": errmap.get(text, text or f"HTTP {r.status_code}")}) except Exception as exc: return jsonify({"ok": False, "error": f"{type(exc).__name__}: {exc}"}) @bp.post("/api/mailbox/test") def api_mailbox_test(): body = request.get_json(silent=True) or {} email = str(body.get("email") or "").strip() credential = str(body.get("credential") or "").strip() if not credential: for e in load_mailbox().get("entries", []): if e.get("email", "").lower() == email.lower(): credential = e.get("credential", "") break if not _EMAIL_RE.match(email) or not credential: return jsonify({"ok": False, "error": "缺少邮箱或凭证"}), 400 try: if re.match(r"^https?://", credential, re.I): return jsonify(_mailbox_test_api(email, credential)) return jsonify(_mailbox_test_imap(email, credential)) except Exception as exc: return jsonify({"ok": False, "error": f"{type(exc).__name__}: {exc}"}) # 启动 TOKEN 自动刷新后台调度线程(daemon) _probe_auto_thread = threading.Thread(target=_auto_refresh_loop, name="probe-auto-refresh", daemon=True) _probe_auto_thread.start()