mail_provider.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. """邮件 provider:a4sky 邮箱生成 + 通过 hotmail-helper 协议拉验证码。
  2. helper 协议(Chrome 扩展原版):
  3. POST {helper_url}/imap-code
  4. body: {
  5. targetEmail, mailbox: "INBOX", top: 60,
  6. senderFilters, subjectFilters, excludeCodes, filterAfterTimestamp
  7. }
  8. 返回: { ok, code, message: { id, mailbox, subject, receivedTimestamp, ... }, usedTimeFallback, transport }
  9. helper 自己读 IMAP 配置(data/a4sky-imap.local.json),客户端不传账号密码。
  10. """
  11. from __future__ import annotations
  12. import json
  13. import time
  14. import urllib.error
  15. import urllib.request
  16. from datetime import datetime
  17. from typing import Callable
  18. DEFAULT_SENDER_FILTERS = ["openai", "noreply", "verify", "auth", "duckduckgo", "forward", "chatgpt"]
  19. DEFAULT_SUBJECT_FILTERS = ["verify", "verification", "code", "验证码", "confirm"]
  20. def build_a4sky_email(domain: str = "edu.a4sky.com") -> str:
  21. """格式:n<YYYYMMDDHHMMSS>@<domain>,与 Chrome 扩展一致。"""
  22. ts = datetime.now().strftime("%Y%m%d%H%M%S")
  23. return f"n{ts}@{domain}"
  24. def _post_json(url: str, body: dict, timeout: int = 30) -> dict:
  25. data = json.dumps(body).encode("utf-8")
  26. req = urllib.request.Request(url, data=data, method="POST")
  27. req.add_header("Content-Type", "application/json")
  28. req.add_header("Accept", "application/json")
  29. with urllib.request.urlopen(req, timeout=timeout) as resp:
  30. text = resp.read().decode("utf-8", errors="replace")
  31. if not text:
  32. return {}
  33. return json.loads(text)
  34. def request_imap_code(
  35. helper_url: str,
  36. target_email: str,
  37. *,
  38. filter_after_ts_ms: int = 0,
  39. exclude_codes: list[str] | None = None,
  40. sender_filters: list[str] | None = None,
  41. subject_filters: list[str] | None = None,
  42. top: int = 60,
  43. timeout: int = 30,
  44. ) -> dict:
  45. """单次请求 helper /imap-code。返回原始响应。"""
  46. base = (helper_url or "").rstrip("/")
  47. if not base:
  48. raise RuntimeError("未配置邮件助手地址")
  49. url = f"{base}/imap-code"
  50. payload = {
  51. "targetEmail": (target_email or "").strip().lower(),
  52. "mailbox": "INBOX",
  53. "top": top,
  54. "senderFilters": sender_filters or DEFAULT_SENDER_FILTERS,
  55. "subjectFilters": subject_filters or DEFAULT_SUBJECT_FILTERS,
  56. "excludeCodes": exclude_codes or [],
  57. "filterAfterTimestamp": int(filter_after_ts_ms or 0),
  58. }
  59. return _post_json(url, payload, timeout=timeout)
  60. def poll_signup_code(
  61. helper_url: str,
  62. target_email: str,
  63. *,
  64. started_at_ms: int,
  65. interval_sec: int = 4,
  66. max_attempts: int = 60,
  67. exclude_codes: list[str] | None = None,
  68. log: Callable[[str], None] = print,
  69. ) -> str:
  70. """轮询邮箱直到拿到验证码。返回 6 位 code 字符串。"""
  71. if not target_email:
  72. raise RuntimeError("未指定目标邮箱")
  73. log(f"[mail] 开始轮询验证码 helper={helper_url} email={target_email} interval={interval_sec}s max_attempts={max_attempts}")
  74. last_err = None
  75. for attempt in range(1, max_attempts + 1):
  76. try:
  77. resp = request_imap_code(
  78. helper_url,
  79. target_email,
  80. filter_after_ts_ms=started_at_ms,
  81. exclude_codes=exclude_codes,
  82. )
  83. except urllib.error.HTTPError as exc:
  84. try:
  85. body = exc.read().decode("utf-8", errors="replace")
  86. except Exception:
  87. body = ""
  88. last_err = f"HTTP {exc.code} {body[:200]}"
  89. log(f"[mail] 第{attempt}次轮询请求 HTTPError: {last_err}")
  90. except Exception as exc:
  91. last_err = repr(exc)
  92. log(f"[mail] 第{attempt}次轮询异常: {last_err}")
  93. else:
  94. ok = bool(resp.get("ok"))
  95. code = str(resp.get("code") or "").strip()
  96. msg = resp.get("message") or {}
  97. subj = (msg or {}).get("subject", "")
  98. ts = (msg or {}).get("receivedTimestamp", 0)
  99. transport = resp.get("transport", "")
  100. if ok and code:
  101. log(f"[mail] 第{attempt}次轮询命中 code={code} subject={subj!r} ts={ts} transport={transport}")
  102. return code
  103. log(f"[mail] 第{attempt}次轮询未命中 ok={ok} code={code!r} usedTimeFallback={resp.get('usedTimeFallback')} subject={subj!r}")
  104. if attempt < max_attempts:
  105. time.sleep(interval_sec)
  106. raise TimeoutError(f"轮询验证码超时({max_attempts} 次),最后错误:{last_err}")