cfmail.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. """cfmail integration for zhuce6."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass
  4. from datetime import datetime, timezone
  5. import json
  6. import os
  7. from pathlib import Path
  8. import re
  9. import secrets
  10. import threading
  11. import time
  12. from typing import Any
  13. from curl_cffi import requests as cffi_requests
  14. from .base_mailbox import BaseMailbox, MailboxAccount
  15. from .paths import resolve_cfmail_config_path
  16. DEFAULT_CFMAIL_CONFIG_PATH = resolve_cfmail_config_path()
  17. DEFAULT_CFMAIL_FAIL_THRESHOLD = 3
  18. DEFAULT_CFMAIL_COOLDOWN_SECONDS = 1800
  19. DEFAULT_CFMAIL_REQUEST_ATTEMPTS = 3
  20. DEFAULT_CFMAIL_RETRY_BASE_DELAY_SECONDS = 1.0
  21. DEFAULT_CFMAIL_MAIL_LIST_LIMIT = 30
  22. DEFAULT_CFMAIL_WAIT_POLL_INTERVAL_SECONDS = 3
  23. CFMAIL_RETRYABLE_STATUS_CODES = {408, 425, 429, 500, 502, 503, 504, 520, 521, 522, 523, 524}
  24. CFMAIL_WAIT_ABORT_PREDICATE = None
  25. CFMAIL_WAIT_PROGRESS_CALLBACK = None
  26. @dataclass(frozen=True)
  27. class CfmailAccount:
  28. name: str
  29. worker_domain: str
  30. email_domain: str
  31. admin_password: str
  32. def _normalize_host(value: str) -> str:
  33. normalized = str(value or "").strip()
  34. if normalized.startswith("https://"):
  35. normalized = normalized[len("https://") :]
  36. elif normalized.startswith("http://"):
  37. normalized = normalized[len("http://") :]
  38. return normalized.strip().strip("/")
  39. def load_cfmail_accounts_from_file(config_path: str | Path, *, silent: bool = False) -> list[dict[str, Any]]:
  40. path = Path(str(config_path or "").strip())
  41. if not path.exists():
  42. return []
  43. try:
  44. data = json.loads(path.read_text(encoding="utf-8"))
  45. except Exception:
  46. if silent:
  47. return []
  48. raise
  49. if isinstance(data, list):
  50. return data
  51. if isinstance(data, dict) and isinstance(data.get("accounts"), list):
  52. return data["accounts"]
  53. return []
  54. def _normalize_cfmail_account(raw: dict[str, Any]) -> CfmailAccount | None:
  55. if not isinstance(raw, dict):
  56. return None
  57. if not raw.get("enabled", True):
  58. return None
  59. name = str(raw.get("name") or "").strip()
  60. worker_domain = _normalize_host(raw.get("worker_domain") or raw.get("WORKER_DOMAIN") or "")
  61. email_domain = _normalize_host(raw.get("email_domain") or raw.get("EMAIL_DOMAIN") or "")
  62. admin_password = str(raw.get("admin_password") or raw.get("ADMIN_PASSWORD") or "").strip()
  63. if not name or not worker_domain or not email_domain or not admin_password:
  64. return None
  65. return CfmailAccount(
  66. name=name,
  67. worker_domain=worker_domain,
  68. email_domain=email_domain,
  69. admin_password=admin_password,
  70. )
  71. def build_cfmail_accounts(raw_accounts: list[dict[str, Any]]) -> list[CfmailAccount]:
  72. accounts: list[CfmailAccount] = []
  73. seen_names: set[str] = set()
  74. for raw in raw_accounts:
  75. account = _normalize_cfmail_account(raw)
  76. if not account:
  77. continue
  78. key = account.name.lower()
  79. if key in seen_names:
  80. continue
  81. seen_names.add(key)
  82. accounts.append(account)
  83. return accounts
  84. def enabled_cfmail_accounts(config_path: str | Path | None = None) -> list[CfmailAccount]:
  85. return build_cfmail_accounts(load_cfmail_accounts_from_file(config_path or DEFAULT_CFMAIL_CONFIG_PATH, silent=True))
  86. def active_cfmail_domain(config_path: str | Path | None = None) -> str:
  87. accounts = enabled_cfmail_accounts(config_path)
  88. if not accounts:
  89. return ""
  90. return str(accounts[0].email_domain or "").strip().lower()
  91. def cfmail_headers(*, jwt: str = "", use_json: bool = False) -> dict[str, str]:
  92. headers = {"Accept": "application/json"}
  93. if use_json:
  94. headers["Content-Type"] = "application/json"
  95. if jwt:
  96. headers["Authorization"] = f"Bearer {jwt}"
  97. return headers
  98. def _is_transient_cfmail_exception(exc: Exception) -> bool:
  99. message = str(exc or "").lower()
  100. markers = (
  101. "connection timed out",
  102. "connection closed abruptly",
  103. "connection reset",
  104. "connection refused",
  105. "tls connect error",
  106. "recv failure",
  107. "send failure",
  108. "http/2 stream",
  109. "operation timed out",
  110. "curl: (7)",
  111. "curl: (28)",
  112. "curl: (35)",
  113. "curl: (52)",
  114. "curl: (55)",
  115. "curl: (56)",
  116. )
  117. return any(marker in message for marker in markers)
  118. def _response_body_snippet(response: Any, limit: int = 240) -> str:
  119. try:
  120. if response is None:
  121. return ""
  122. text = str(getattr(response, "text", "") or "").strip()
  123. if text:
  124. return " ".join(text.split())[:limit]
  125. if getattr(response, "content", None):
  126. payload = response.json()
  127. return " ".join(json.dumps(payload, ensure_ascii=False).split())[:limit]
  128. except Exception:
  129. return ""
  130. return ""
  131. def _message_timestamp_seconds(message: dict[str, Any]) -> float | None:
  132. raw = message.get("createdAt")
  133. if raw is None:
  134. return None
  135. if isinstance(raw, (int, float)):
  136. return float(raw)
  137. value = str(raw or "").strip()
  138. if not value:
  139. return None
  140. try:
  141. normalized = value.replace("Z", "+00:00")
  142. dt = datetime.fromisoformat(normalized)
  143. if dt.tzinfo is None:
  144. dt = dt.replace(tzinfo=timezone.utc)
  145. return dt.timestamp()
  146. except Exception:
  147. return None
  148. class CfmailAccountManager:
  149. def __init__(
  150. self,
  151. config_path: str | Path | None = None,
  152. *,
  153. profile_mode: str = "auto",
  154. hot_reload_enabled: bool = True,
  155. fail_threshold: int = DEFAULT_CFMAIL_FAIL_THRESHOLD,
  156. cooldown_seconds: int = DEFAULT_CFMAIL_COOLDOWN_SECONDS,
  157. ) -> None:
  158. self.config_path = Path(config_path or DEFAULT_CFMAIL_CONFIG_PATH)
  159. self.profile_mode = str(profile_mode or "auto").strip() or "auto"
  160. self.hot_reload_enabled = hot_reload_enabled
  161. self.fail_threshold = max(1, int(fail_threshold))
  162. self.cooldown_seconds = max(0, int(cooldown_seconds))
  163. self._account_lock = threading.Lock()
  164. self._reload_lock = threading.Lock()
  165. self._failure_lock = threading.Lock()
  166. self._account_index = 0
  167. self.accounts = build_cfmail_accounts(
  168. load_cfmail_accounts_from_file(self.config_path, silent=True)
  169. )
  170. self.config_mtime = self._current_mtime()
  171. self.failure_state: dict[str, dict[str, Any]] = {}
  172. def _current_mtime(self) -> float | None:
  173. try:
  174. return self.config_path.stat().st_mtime
  175. except OSError:
  176. return None
  177. def account_names(self, accounts: list[CfmailAccount] | None = None) -> str:
  178. items = accounts if accounts is not None else self.accounts
  179. return ", ".join(account.name for account in items) if items else "无"
  180. def set_accounts(self, accounts: list[CfmailAccount]) -> None:
  181. with self._account_lock:
  182. self.accounts = accounts
  183. self._account_index = 0
  184. self.prune_failure_state(accounts)
  185. def prune_failure_state(self, accounts: list[CfmailAccount] | None = None) -> None:
  186. valid_keys = {account.name.lower() for account in (accounts if accounts is not None else self.accounts)}
  187. with self._failure_lock:
  188. for key in list(self.failure_state.keys()):
  189. if key not in valid_keys:
  190. self.failure_state.pop(key, None)
  191. def skip_remaining_seconds(self, account_name: str) -> int:
  192. key = str(account_name or "").strip().lower()
  193. if not key:
  194. return 0
  195. with self._failure_lock:
  196. cooldown_until = float((self.failure_state.get(key) or {}).get("cooldown_until") or 0)
  197. return max(0, int(cooldown_until - time.time()))
  198. def record_success(self, account_name: str) -> None:
  199. key = str(account_name or "").strip().lower()
  200. if not key:
  201. return
  202. with self._failure_lock:
  203. state = self.failure_state.setdefault(key, {"name": account_name})
  204. state["name"] = account_name
  205. state["consecutive_failures"] = 0
  206. state["cooldown_until"] = 0
  207. state["last_error"] = ""
  208. state["last_success_at"] = time.time()
  209. def record_failure(self, account_name: str, reason: str = "") -> None:
  210. key = str(account_name or "").strip().lower()
  211. if not key:
  212. return
  213. now = time.time()
  214. with self._failure_lock:
  215. state = self.failure_state.setdefault(key, {"name": account_name})
  216. state["name"] = account_name
  217. state["consecutive_failures"] = int(state.get("consecutive_failures") or 0) + 1
  218. state["last_error"] = str(reason or "").strip()[:300]
  219. state["last_failed_at"] = now
  220. if state["consecutive_failures"] >= self.fail_threshold:
  221. state["cooldown_until"] = max(float(state.get("cooldown_until") or 0), now + self.cooldown_seconds)
  222. state["consecutive_failures"] = 0
  223. def reload_if_needed(self, force: bool = False) -> bool:
  224. if not self.hot_reload_enabled:
  225. return False
  226. mtime = self._current_mtime()
  227. if mtime is None:
  228. return False
  229. with self._reload_lock:
  230. if not force and self.config_mtime == mtime:
  231. return False
  232. accounts = build_cfmail_accounts(load_cfmail_accounts_from_file(self.config_path, silent=True))
  233. if not accounts:
  234. self.config_mtime = mtime
  235. return False
  236. self.set_accounts(accounts)
  237. self.config_mtime = mtime
  238. return True
  239. def select_account(self, profile_name: str | None = None) -> CfmailAccount | None:
  240. selected_name = str(profile_name or self.profile_mode or "auto").strip() or "auto"
  241. accounts = self.accounts
  242. if not accounts:
  243. return None
  244. if selected_name.lower() != "auto":
  245. selected_key = selected_name.lower()
  246. for account in accounts:
  247. if account.name.lower() == selected_key:
  248. return account
  249. return None
  250. with self._account_lock:
  251. start_index = self._account_index % len(accounts)
  252. for offset in range(len(accounts)):
  253. index = (start_index + offset) % len(accounts)
  254. account = accounts[index]
  255. if self.skip_remaining_seconds(account.name) > 0:
  256. continue
  257. self._account_index = (index + 1) % len(accounts)
  258. return account
  259. return None
  260. class CfMailMailbox(BaseMailbox):
  261. def __init__(
  262. self,
  263. *,
  264. manager: CfmailAccountManager | None = None,
  265. profile_name: str = "auto",
  266. proxy: str | None = None,
  267. ) -> None:
  268. self.manager = manager or DEFAULT_CFMAIL_MANAGER
  269. self.profile_name = str(profile_name or "auto").strip() or "auto"
  270. # Cfmail worker inbox APIs are public web endpoints and do not benefit from
  271. # the shared register SOCKS5 path. In live traffic, routing these mailbox
  272. # operations through register proxies causes repeated
  273. # `curl: (97) cannot complete SOCKS5 connection` failures against the
  274. # worker domain. Keep mailbox create/list/wait on direct egress so the
  275. # register proxy pool only carries the OpenAI auth chain.
  276. del proxy
  277. self.proxies = None
  278. self.last_wait_diagnostics: dict[str, Any] = {}
  279. def _mail_list_limit(self) -> int:
  280. raw = str(os.getenv("ZHUCE6_CFMAIL_MAIL_LIST_LIMIT", str(DEFAULT_CFMAIL_MAIL_LIST_LIMIT)) or "").strip()
  281. try:
  282. value = int(raw)
  283. except Exception:
  284. value = DEFAULT_CFMAIL_MAIL_LIST_LIMIT
  285. return max(10, min(value, 100))
  286. def _request_with_retry(
  287. self,
  288. *,
  289. method: str,
  290. url: str,
  291. retry_label: str,
  292. max_attempts: int = DEFAULT_CFMAIL_REQUEST_ATTEMPTS,
  293. retry_delay: float = DEFAULT_CFMAIL_RETRY_BASE_DELAY_SECONDS,
  294. **kwargs: Any,
  295. ) -> Any:
  296. last_exc: Exception | None = None
  297. last_response: Any | None = None
  298. requester = getattr(cffi_requests, method.lower())
  299. for attempt in range(1, max_attempts + 1):
  300. try:
  301. response = requester(url, **kwargs)
  302. last_response = response
  303. except Exception as exc:
  304. last_exc = exc
  305. if attempt < max_attempts and _is_transient_cfmail_exception(exc):
  306. time.sleep(retry_delay * attempt)
  307. continue
  308. raise
  309. if response.status_code in CFMAIL_RETRYABLE_STATUS_CODES and attempt < max_attempts:
  310. time.sleep(retry_delay * attempt)
  311. continue
  312. return response
  313. if last_exc is not None:
  314. raise last_exc
  315. if last_response is not None:
  316. return last_response
  317. raise RuntimeError(f"{retry_label} request failed without response")
  318. def get_email(self) -> MailboxAccount:
  319. self.manager.reload_if_needed()
  320. account = self.manager.select_account(self.profile_name)
  321. if not account:
  322. raise RuntimeError(
  323. f"cfmail account unavailable, current accounts: {self.manager.account_names()}"
  324. )
  325. local = f"oc{secrets.token_hex(8)}"
  326. try:
  327. response = self._request_with_retry(
  328. method="POST",
  329. url=f"https://{account.worker_domain}/admin/new_address",
  330. retry_label="cfmail create mailbox",
  331. headers={
  332. "x-admin-auth": account.admin_password,
  333. **cfmail_headers(use_json=True),
  334. },
  335. json={
  336. "enablePrefix": True,
  337. "name": local,
  338. "domain": account.email_domain,
  339. },
  340. proxies=self.proxies,
  341. timeout=15,
  342. impersonate="chrome",
  343. )
  344. if response.status_code != 200:
  345. detail = _response_body_snippet(response)
  346. detail_suffix = f" | body={detail}" if detail else ""
  347. raise RuntimeError(f"cfmail create failed: HTTP {response.status_code}{detail_suffix}")
  348. try:
  349. data = response.json() if response.content else {}
  350. except Exception as exc:
  351. raise RuntimeError(f"cfmail create invalid json: {exc}") from exc
  352. email = str(data.get("address") or "").strip()
  353. jwt = str(data.get("jwt") or "").strip()
  354. if not email or not jwt:
  355. raise RuntimeError("cfmail create returned incomplete data")
  356. self.manager.record_success(account.name)
  357. return MailboxAccount(
  358. email=email,
  359. account_id=jwt,
  360. extra={
  361. "api_base": f"https://{account.worker_domain}",
  362. "config_name": account.name,
  363. "email_domain": account.email_domain,
  364. },
  365. )
  366. except Exception as exc:
  367. self.manager.record_failure(account.name, f"new_address exception: {exc}")
  368. raise RuntimeError(str(exc or "cfmail create failed"))
  369. def get_current_ids(self, account: MailboxAccount) -> set[str]:
  370. try:
  371. response = self._request_with_retry(
  372. method="GET",
  373. url=f"{account.extra.get('api_base', '')}/api/mails",
  374. retry_label="cfmail list mails",
  375. params={"limit": self._mail_list_limit(), "offset": 0},
  376. headers=cfmail_headers(jwt=account.account_id, use_json=True),
  377. proxies=self.proxies,
  378. timeout=15,
  379. impersonate="chrome",
  380. )
  381. if response.status_code != 200:
  382. return set()
  383. data = response.json() if response.content else {}
  384. messages = data.get("results", []) if isinstance(data, dict) else []
  385. return {
  386. str(item.get("id") or item.get("createdAt") or "").strip()
  387. for item in messages
  388. if isinstance(item, dict) and (item.get("id") or item.get("createdAt"))
  389. }
  390. except Exception:
  391. return set()
  392. def wait_for_code(
  393. self,
  394. account: MailboxAccount,
  395. keyword: str = "",
  396. timeout: int = 120,
  397. before_ids: set[str] | None = None,
  398. not_before_timestamp: float | None = None,
  399. ) -> str:
  400. seen_ids = set(before_ids or [])
  401. api_base = str(account.extra.get("api_base") or "").strip()
  402. email = account.email.strip().lower()
  403. config_name = str(account.extra.get("config_name") or "").strip()
  404. mail_list_limit = self._mail_list_limit()
  405. patterns = [
  406. r"Subject:\s*Your ChatGPT code is\s*(\d{6})",
  407. r"Your ChatGPT code is\s*(\d{6})",
  408. r"temporary verification code to continue:\s*(\d{6})",
  409. r"(?<!\d)(\d{6})(?!\d)",
  410. ]
  411. start = time.time()
  412. account.extra["otp_wait_started_at"] = start
  413. diagnostics: dict[str, Any] = {
  414. "started_at": start,
  415. "poll_count": 0,
  416. "message_scan_count": 0,
  417. "first_message_seen_at": None,
  418. "matched_message_at": None,
  419. "matched_message_id": "",
  420. }
  421. self.last_wait_diagnostics = diagnostics
  422. while time.time() - start < timeout:
  423. try:
  424. abort_predicate = CFMAIL_WAIT_ABORT_PREDICATE
  425. if callable(abort_predicate):
  426. try:
  427. if bool(abort_predicate(account)):
  428. diagnostics["aborted"] = True
  429. diagnostics["abort_reason"] = "rotation_or_stoploss"
  430. self.last_wait_diagnostics = diagnostics
  431. if config_name:
  432. self.manager.record_failure(config_name, "mail polling aborted")
  433. return ""
  434. except Exception:
  435. pass
  436. diagnostics["poll_count"] = int(diagnostics.get("poll_count") or 0) + 1
  437. diagnostics["elapsed_seconds"] = max(0.0, time.time() - start)
  438. response = self._request_with_retry(
  439. method="GET",
  440. url=f"{api_base}/api/mails",
  441. retry_label="cfmail wait mails",
  442. params={"limit": mail_list_limit, "offset": 0},
  443. headers=cfmail_headers(jwt=account.account_id, use_json=True),
  444. proxies=self.proxies,
  445. timeout=15,
  446. impersonate="chrome",
  447. )
  448. if response.status_code != 200:
  449. diagnostics["elapsed_seconds"] = max(0.0, time.time() - start)
  450. progress_callback = CFMAIL_WAIT_PROGRESS_CALLBACK
  451. if callable(progress_callback):
  452. try:
  453. progress_callback(account, dict(diagnostics))
  454. except Exception:
  455. pass
  456. time.sleep(3)
  457. continue
  458. data = response.json() if response.content else {}
  459. messages = data.get("results", []) if isinstance(data, dict) else []
  460. if not isinstance(messages, list):
  461. progress_callback = CFMAIL_WAIT_PROGRESS_CALLBACK
  462. if callable(progress_callback):
  463. try:
  464. progress_callback(account, dict(diagnostics))
  465. except Exception:
  466. pass
  467. time.sleep(3)
  468. continue
  469. for message in messages:
  470. if not isinstance(message, dict):
  471. continue
  472. message_id = str(message.get("id") or message.get("createdAt") or "").strip()
  473. if not message_id or message_id in seen_ids:
  474. continue
  475. message_timestamp = _message_timestamp_seconds(message)
  476. if (
  477. not_before_timestamp is not None
  478. and message_timestamp is not None
  479. and message_timestamp < float(not_before_timestamp)
  480. ):
  481. continue
  482. diagnostics["message_scan_count"] = int(diagnostics.get("message_scan_count") or 0) + 1
  483. if diagnostics.get("first_message_seen_at") is None:
  484. diagnostics["first_message_seen_at"] = time.time()
  485. seen_ids.add(message_id)
  486. recipient = str(message.get("address") or "").strip().lower()
  487. raw = str(message.get("raw") or "")
  488. metadata_text = json.dumps(message.get("metadata") or {}, ensure_ascii=False)
  489. content = "\n".join([recipient, raw, metadata_text])
  490. if recipient and recipient != email:
  491. continue
  492. if keyword and keyword.lower() not in content.lower():
  493. continue
  494. for pattern in patterns:
  495. match = re.search(pattern, content, re.I | re.S)
  496. if match:
  497. diagnostics["matched_message_at"] = time.time()
  498. diagnostics["matched_message_id"] = message_id
  499. if config_name:
  500. self.manager.record_success(config_name)
  501. return match.group(1)
  502. diagnostics["elapsed_seconds"] = max(0.0, time.time() - start)
  503. progress_callback = CFMAIL_WAIT_PROGRESS_CALLBACK
  504. if callable(progress_callback):
  505. try:
  506. progress_callback(account, dict(diagnostics))
  507. except Exception:
  508. pass
  509. except Exception:
  510. diagnostics["elapsed_seconds"] = max(0.0, time.time() - start)
  511. progress_callback = CFMAIL_WAIT_PROGRESS_CALLBACK
  512. if callable(progress_callback):
  513. try:
  514. progress_callback(account, dict(diagnostics))
  515. except Exception:
  516. pass
  517. time.sleep(DEFAULT_CFMAIL_WAIT_POLL_INTERVAL_SECONDS)
  518. if config_name:
  519. self.manager.record_failure(config_name, "mail polling timeout")
  520. return ""
  521. DEFAULT_CFMAIL_MANAGER = CfmailAccountManager()