,内含 .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()