hotmail_helper.py 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027
  1. import email
  2. import html
  3. import imaplib
  4. import json
  5. import os
  6. import re
  7. import threading
  8. import time
  9. import traceback
  10. from datetime import datetime, timezone
  11. from email.header import decode_header
  12. from email.utils import getaddresses, parseaddr, parsedate_to_datetime
  13. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  14. from urllib.error import HTTPError, URLError
  15. from urllib.parse import urlencode
  16. from urllib.request import Request, urlopen
  17. HOST = os.environ.get("HOTMAIL_HELPER_HOST", "127.0.0.1").strip() or "127.0.0.1"
  18. try:
  19. PORT = int(os.environ.get("HOTMAIL_HELPER_PORT", "17373") or 17373)
  20. except Exception:
  21. PORT = 17373
  22. LIVE_TOKEN_URL = "https://login.live.com/oauth20_token.srf"
  23. ENTRA_COMMON_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
  24. ENTRA_CONSUMERS_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
  25. GRAPH_API_ORIGIN = "https://graph.microsoft.com"
  26. OUTLOOK_API_ORIGIN = "https://outlook.office.com"
  27. GRAPH_SCOPES = "offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read"
  28. GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
  29. TOKEN_ENDPOINTS = {
  30. "live": {
  31. "name": "live",
  32. "url": LIVE_TOKEN_URL,
  33. "extra_data": {},
  34. },
  35. "entra-consumers-delegated": {
  36. "name": "entra-consumers-delegated",
  37. "url": ENTRA_CONSUMERS_TOKEN_URL,
  38. "extra_data": {
  39. "scope": GRAPH_SCOPES,
  40. },
  41. },
  42. "entra-common-delegated": {
  43. "name": "entra-common-delegated",
  44. "url": ENTRA_COMMON_TOKEN_URL,
  45. "extra_data": {
  46. "scope": GRAPH_SCOPES,
  47. },
  48. },
  49. "entra-common-default": {
  50. "name": "entra-common-default",
  51. "url": ENTRA_COMMON_TOKEN_URL,
  52. "extra_data": {
  53. "scope": GRAPH_DEFAULT_SCOPE,
  54. },
  55. },
  56. "entra-common-outlook": {
  57. "name": "entra-common-outlook",
  58. "url": ENTRA_COMMON_TOKEN_URL,
  59. "extra_data": {},
  60. },
  61. }
  62. IMAP_HOST = "outlook.office365.com"
  63. IMAP_PORT = 993
  64. REQUEST_TIMEOUT_SECONDS = 45
  65. FETCH_LIMIT_DEFAULT = 5
  66. FETCH_LIMIT_MAX = 120
  67. BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
  68. ACCOUNT_LOG_PATH = os.path.join(BASE_DIR, "data", "account-run-history.txt")
  69. ACCOUNT_RECORDS_SNAPSHOT_PATH = os.path.join(BASE_DIR, "data", "account-run-history.json")
  70. A4SKY_IMAP_CONFIG_PATH = os.path.join(BASE_DIR, "data", "a4sky-imap.local.json")
  71. ACCOUNT_RECORDS_LOCK = threading.Lock()
  72. def json_response(handler, status, payload):
  73. body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  74. handler.send_response(status)
  75. handler.send_header("Content-Type", "application/json; charset=utf-8")
  76. handler.send_header("Content-Length", str(len(body)))
  77. handler.send_header("Access-Control-Allow-Origin", "*")
  78. handler.send_header("Access-Control-Allow-Headers", "Content-Type")
  79. handler.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
  80. handler.end_headers()
  81. handler.wfile.write(body)
  82. def read_json_payload(handler):
  83. length = int(handler.headers.get("Content-Length", "0") or 0)
  84. raw = handler.rfile.read(length) if length > 0 else b"{}"
  85. try:
  86. return json.loads(raw.decode("utf-8"))
  87. except Exception as exc:
  88. raise RuntimeError(f"Invalid JSON payload: {exc}") from exc
  89. def post_form(url, data):
  90. encoded = urlencode(data).encode("utf-8")
  91. request = Request(url, data=encoded, headers={"Content-Type": "application/x-www-form-urlencoded"})
  92. with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
  93. return json.loads(response.read().decode("utf-8"))
  94. def get_json(url, headers=None):
  95. request = Request(url, headers=headers or {})
  96. with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
  97. return response.getcode(), json.loads(response.read().decode("utf-8"))
  98. def mask_secret(value, keep=6):
  99. raw = str(value or "")
  100. if not raw:
  101. return ""
  102. if len(raw) <= keep:
  103. return "*" * len(raw)
  104. return raw[:keep] + "..." + raw[-keep:]
  105. def compact_text(value, limit=400):
  106. text = str(value or "").replace("\r", " ").replace("\n", " ").strip()
  107. return text[:limit]
  108. def log_info(message):
  109. print(f"[HotmailHelper] {message}", flush=True)
  110. def append_account_log(email_addr, password, status, recorded_at="", reason=""):
  111. normalized_email = str(email_addr or "").strip()
  112. normalized_password = str(password or "").strip()
  113. normalized_status = str(status or "").strip().lower()
  114. normalized_recorded_at = str(recorded_at or "").strip() or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
  115. normalized_reason = str(reason or "").strip().replace("\r", " ").replace("\n", " ")
  116. if not normalized_email or not normalized_password or not normalized_status:
  117. raise RuntimeError("Missing email/password/status for account log append")
  118. os.makedirs(os.path.dirname(ACCOUNT_LOG_PATH), exist_ok=True)
  119. line = f"{normalized_recorded_at}\t{normalized_email}\t{normalized_password}\t{normalized_status}\t{normalized_reason}\n"
  120. with ACCOUNT_RECORDS_LOCK:
  121. with open(ACCOUNT_LOG_PATH, "a", encoding="utf-8") as handle:
  122. handle.write(line)
  123. return ACCOUNT_LOG_PATH
  124. def normalize_account_run_snapshot_record(record):
  125. if not isinstance(record, dict):
  126. return None
  127. email_addr = str(record.get("email") or "").strip()
  128. password = str(record.get("password") or "").strip()
  129. final_status = str(record.get("finalStatus") or "").strip().lower()
  130. if not email_addr or not password or final_status not in {"success", "failed", "stopped"}:
  131. return None
  132. finished_at = str(record.get("finishedAt") or "").strip() or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
  133. retry_count = max(0, int(record.get("retryCount") or 0))
  134. failed_step_raw = record.get("failedStep")
  135. try:
  136. failed_step = int(failed_step_raw)
  137. except (TypeError, ValueError):
  138. failed_step = None
  139. if failed_step is not None and failed_step <= 0:
  140. failed_step = None
  141. auto_run_context = record.get("autoRunContext") if isinstance(record.get("autoRunContext"), dict) else None
  142. normalized_auto_run_context = None
  143. if auto_run_context:
  144. normalized_auto_run_context = {
  145. "currentRun": max(0, int(auto_run_context.get("currentRun") or 0)),
  146. "totalRuns": max(0, int(auto_run_context.get("totalRuns") or 0)),
  147. "attemptRun": max(0, int(auto_run_context.get("attemptRun") or 0)),
  148. }
  149. if not any(normalized_auto_run_context.values()):
  150. normalized_auto_run_context = None
  151. source = "auto" if str(record.get("source") or "").strip().lower() == "auto" else "manual"
  152. return {
  153. "recordId": str(record.get("recordId") or email_addr).strip() or email_addr,
  154. "email": email_addr,
  155. "password": password,
  156. "finalStatus": final_status,
  157. "finishedAt": finished_at,
  158. "retryCount": retry_count,
  159. "failureLabel": str(record.get("failureLabel") or "").strip(),
  160. "failureDetail": str(record.get("failureDetail") or "").strip(),
  161. "failedStep": failed_step,
  162. "source": source,
  163. "autoRunContext": normalized_auto_run_context,
  164. }
  165. def summarize_account_run_snapshot(records):
  166. summary = {
  167. "total": 0,
  168. "success": 0,
  169. "failed": 0,
  170. "retryTotal": 0,
  171. }
  172. for item in records:
  173. summary["total"] += 1
  174. if item.get("finalStatus") == "success":
  175. summary["success"] += 1
  176. elif item.get("finalStatus") == "failed":
  177. summary["failed"] += 1
  178. summary["retryTotal"] += max(0, int(item.get("retryCount") or 0))
  179. return summary
  180. def normalize_account_run_snapshot_payload(payload):
  181. if not isinstance(payload, dict):
  182. raise RuntimeError("Invalid account run snapshot payload")
  183. normalized_records = []
  184. for item in payload.get("records") if isinstance(payload.get("records"), list) else []:
  185. normalized = normalize_account_run_snapshot_record(item)
  186. if normalized:
  187. normalized_records.append(normalized)
  188. return {
  189. "generatedAt": str(payload.get("generatedAt") or "").strip() or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
  190. "summary": summarize_account_run_snapshot(normalized_records),
  191. "records": normalized_records,
  192. }
  193. def sync_account_run_records(payload):
  194. normalized_payload = normalize_account_run_snapshot_payload(payload)
  195. os.makedirs(os.path.dirname(ACCOUNT_RECORDS_SNAPSHOT_PATH), exist_ok=True)
  196. with ACCOUNT_RECORDS_LOCK:
  197. with open(ACCOUNT_RECORDS_SNAPSHOT_PATH, "w", encoding="utf-8") as handle:
  198. json.dump(normalized_payload, handle, ensure_ascii=False, indent=2)
  199. handle.write("\n")
  200. return ACCOUNT_RECORDS_SNAPSHOT_PATH
  201. def try_refresh_access_token(endpoint, client_id, refresh_token):
  202. request_data = {
  203. "client_id": client_id,
  204. "refresh_token": refresh_token,
  205. "grant_type": "refresh_token",
  206. **(endpoint.get("extra_data") or {}),
  207. }
  208. started_at = time.monotonic()
  209. try:
  210. payload = post_form(endpoint["url"], request_data)
  211. except HTTPError as exc:
  212. detail = exc.read().decode("utf-8", errors="ignore")
  213. return {
  214. "ok": False,
  215. "endpoint": endpoint["name"],
  216. "url": endpoint["url"],
  217. "status": getattr(exc, "code", None),
  218. "error": compact_text(detail or str(exc)),
  219. "elapsed_ms": int((time.monotonic() - started_at) * 1000),
  220. }
  221. except URLError as exc:
  222. return {
  223. "ok": False,
  224. "endpoint": endpoint["name"],
  225. "url": endpoint["url"],
  226. "status": None,
  227. "error": compact_text(f"Token request failed: {exc}"),
  228. "elapsed_ms": int((time.monotonic() - started_at) * 1000),
  229. }
  230. access_token = str(payload.get("access_token") or "").strip()
  231. if not access_token:
  232. return {
  233. "ok": False,
  234. "endpoint": endpoint["name"],
  235. "url": endpoint["url"],
  236. "status": 200,
  237. "error": compact_text(payload.get("error_description") or payload.get("error") or json.dumps(payload, ensure_ascii=False)),
  238. "elapsed_ms": int((time.monotonic() - started_at) * 1000),
  239. }
  240. return {
  241. "ok": True,
  242. "endpoint": endpoint["name"],
  243. "url": endpoint["url"],
  244. "elapsed_ms": int((time.monotonic() - started_at) * 1000),
  245. "payload": {
  246. "access_token": access_token,
  247. "next_refresh_token": str(payload.get("refresh_token") or "").strip(),
  248. },
  249. }
  250. def refresh_access_token(client_id, refresh_token, strategy_names=None):
  251. errors = []
  252. selected_endpoints = [
  253. TOKEN_ENDPOINTS[name]
  254. for name in (strategy_names or ["live", "entra-consumers-delegated", "entra-common-delegated"])
  255. if name in TOKEN_ENDPOINTS
  256. ]
  257. log_info(
  258. "token refresh start "
  259. f"clientId={mask_secret(client_id)} "
  260. f"refreshToken={mask_secret(refresh_token)} "
  261. f"strategies={[item['name'] for item in selected_endpoints]}"
  262. )
  263. for endpoint in selected_endpoints:
  264. result = try_refresh_access_token(endpoint, client_id, refresh_token)
  265. if result["ok"]:
  266. log_info(
  267. "token refresh success "
  268. f"endpoint={result['endpoint']} "
  269. f"elapsedMs={result['elapsed_ms']}"
  270. )
  271. return {
  272. "access_token": result["payload"]["access_token"],
  273. "next_refresh_token": result["payload"]["next_refresh_token"],
  274. "token_endpoint": result["endpoint"],
  275. "token_url": result["url"],
  276. }
  277. errors.append(result)
  278. log_info(
  279. "token refresh failed "
  280. f"endpoint={result['endpoint']} "
  281. f"status={result['status']} "
  282. f"elapsedMs={result['elapsed_ms']} "
  283. f"detail={result['error']}"
  284. )
  285. details = " | ".join(
  286. f"{item['endpoint']}({item['status']}): {item['error']}"
  287. for item in errors
  288. )
  289. raise RuntimeError(f"Token refresh failed on all endpoints: {details}")
  290. def load_local_imap_config():
  291. if not os.path.exists(A4SKY_IMAP_CONFIG_PATH):
  292. return {}
  293. try:
  294. with open(A4SKY_IMAP_CONFIG_PATH, "r", encoding="utf-8") as handle:
  295. payload = json.load(handle)
  296. return payload if isinstance(payload, dict) else {}
  297. except Exception as exc:
  298. raise RuntimeError(f"Invalid local IMAP config: {exc}") from exc
  299. def resolve_basic_imap_settings(payload):
  300. local_config = load_local_imap_config()
  301. host = str(payload.get("host") or local_config.get("host") or "").strip()
  302. username = str(payload.get("username") or local_config.get("username") or "").strip()
  303. password = str(payload.get("password") or local_config.get("password") or "").strip()
  304. port_raw = payload.get("port") if payload.get("port") is not None else local_config.get("port")
  305. try:
  306. port = int(port_raw or 993)
  307. except Exception as exc:
  308. raise RuntimeError(f"Invalid IMAP port: {exc}") from exc
  309. if not host or not username or not password:
  310. raise RuntimeError("Missing IMAP host/username/password. Please fill data/a4sky-imap.local.json or pass credentials explicitly.")
  311. return {
  312. "host": host,
  313. "port": max(1, port),
  314. "username": username,
  315. "password": password,
  316. }
  317. def open_basic_imap_mailbox(host, port, username, password):
  318. client = imaplib.IMAP4_SSL(host, port)
  319. client.login(username, password)
  320. return client
  321. def build_xoauth2(email_addr, access_token):
  322. return f"user={email_addr}\x01auth=Bearer {access_token}\x01\x01".encode("utf-8")
  323. def open_mailbox(email_addr, access_token):
  324. client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
  325. client.authenticate("XOAUTH2", lambda _: build_xoauth2(email_addr, access_token))
  326. return client
  327. def decode_mime_header(value):
  328. if not value:
  329. return ""
  330. parts = []
  331. for chunk, charset in decode_header(value):
  332. if isinstance(chunk, bytes):
  333. parts.append(chunk.decode(charset or "utf-8", errors="ignore"))
  334. else:
  335. parts.append(str(chunk))
  336. return "".join(parts).strip()
  337. def extract_text_part(message):
  338. if message.is_multipart():
  339. for part in message.walk():
  340. if part.get_content_maintype() == "multipart":
  341. continue
  342. if "attachment" in str(part.get("Content-Disposition") or "").lower():
  343. continue
  344. payload = part.get_payload(decode=True) or b""
  345. charset = part.get_content_charset() or "utf-8"
  346. text = payload.decode(charset, errors="ignore").strip()
  347. if part.get_content_type() == "text/plain" and text:
  348. return text
  349. if part.get_content_type() == "text/html" and text:
  350. return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
  351. return ""
  352. payload = message.get_payload(decode=True) or b""
  353. charset = message.get_content_charset() or "utf-8"
  354. text = payload.decode(charset, errors="ignore").strip()
  355. if message.get_content_type() == "text/html":
  356. return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
  357. return text
  358. def mailbox_candidates(mailbox):
  359. normalized = str(mailbox or "INBOX").strip().lower()
  360. if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
  361. return ["Junk", "Junk Email", "Junk E-Mail"]
  362. return ["INBOX"]
  363. def normalize_mailbox_label(mailbox):
  364. normalized = str(mailbox or "INBOX").strip().lower()
  365. if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
  366. return "Junk"
  367. return "INBOX"
  368. def normalize_mailbox_id(mailbox):
  369. normalized = str(mailbox or "INBOX").strip().lower()
  370. if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
  371. return "junkemail"
  372. return "inbox"
  373. def select_mailbox(client, mailbox):
  374. for candidate in mailbox_candidates(mailbox):
  375. status, _ = client.select(candidate)
  376. if status == "OK":
  377. return candidate
  378. raise RuntimeError(f"Mailbox not found: {mailbox}")
  379. def to_timestamp_ms(raw_date):
  380. if not raw_date:
  381. return 0
  382. try:
  383. parsed = parsedate_to_datetime(raw_date)
  384. if parsed.tzinfo is None:
  385. parsed = parsed.replace(tzinfo=timezone.utc)
  386. return int(parsed.timestamp() * 1000)
  387. except Exception:
  388. return 0
  389. def to_iso_string(timestamp_ms):
  390. if not timestamp_ms:
  391. return ""
  392. return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
  393. def normalize_message(message_id, raw_bytes, mailbox):
  394. parsed = email.message_from_bytes(raw_bytes)
  395. sender_name, sender_addr = parseaddr(parsed.get("From", ""))
  396. subject = decode_mime_header(parsed.get("Subject", ""))
  397. body = extract_text_part(parsed)
  398. timestamp_ms = to_timestamp_ms(parsed.get("Date"))
  399. recipient_headers = []
  400. for header_name in ["To", "Delivered-To", "Envelope-To", "X-Original-To", "Cc"]:
  401. recipient_headers.extend(parsed.get_all(header_name, []))
  402. recipient_items = []
  403. recipient_addresses = []
  404. for recipient_name, recipient_addr in getaddresses(recipient_headers):
  405. normalized_addr = str(recipient_addr or "").strip().lower()
  406. if not normalized_addr:
  407. continue
  408. recipient_addresses.append(normalized_addr)
  409. recipient_items.append({
  410. "emailAddress": {
  411. "address": normalized_addr,
  412. "name": str(recipient_name or "").strip(),
  413. }
  414. })
  415. return {
  416. "id": str(message_id),
  417. "mailbox": mailbox,
  418. "subject": subject,
  419. "from": {
  420. "emailAddress": {
  421. "address": sender_addr.strip(),
  422. "name": sender_name.strip(),
  423. }
  424. },
  425. "toRecipients": recipient_items,
  426. "recipientAddresses": recipient_addresses,
  427. "bodyPreview": body[:500],
  428. "receivedDateTime": to_iso_string(timestamp_ms),
  429. "receivedTimestamp": timestamp_ms,
  430. }
  431. def normalize_fetch_limit(top):
  432. try:
  433. numeric = int(top or FETCH_LIMIT_DEFAULT)
  434. except Exception:
  435. numeric = FETCH_LIMIT_DEFAULT
  436. return max(1, min(numeric, FETCH_LIMIT_MAX))
  437. def search_imap_message_ids(client, target_email=""):
  438. normalized_target = str(target_email or "").strip().lower()
  439. if not normalized_target:
  440. return []
  441. matched_ids = []
  442. seen_ids = set()
  443. for header_name in ["To", "Delivered-To", "Envelope-To", "X-Original-To", "Cc"]:
  444. try:
  445. status, data = client.search(None, "HEADER", header_name, f'"{normalized_target}"')
  446. except Exception:
  447. continue
  448. if status != "OK" or not data or not data[0]:
  449. continue
  450. for message_id in data[0].split():
  451. if not message_id or message_id in seen_ids:
  452. continue
  453. seen_ids.add(message_id)
  454. matched_ids.append(message_id)
  455. matched_ids.sort(key=lambda item: int(item) if item.isdigit() else 0)
  456. return matched_ids
  457. def load_selected_message_ids(client, top, target_email=""):
  458. limit = normalize_fetch_limit(top)
  459. target_ids = search_imap_message_ids(client, target_email)
  460. if target_ids:
  461. return list(reversed(target_ids[-limit:]))
  462. status, data = client.search(None, "ALL")
  463. if status != "OK" or not data or not data[0]:
  464. return []
  465. message_ids = data[0].split()
  466. return list(reversed(message_ids[-limit:]))
  467. def fetch_messages(email_addr, access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
  468. client = None
  469. logical_mailbox = normalize_mailbox_label(mailbox)
  470. try:
  471. client = open_mailbox(email_addr, access_token)
  472. select_mailbox(client, mailbox)
  473. selected_ids = load_selected_message_ids(client, top)
  474. if not selected_ids:
  475. return {"mailbox": logical_mailbox, "messages": [], "count": 0}
  476. messages = []
  477. for message_id in selected_ids:
  478. fetch_status, fetch_data = client.fetch(message_id, "(RFC822)")
  479. if fetch_status != "OK" or not fetch_data:
  480. continue
  481. raw_bytes = b""
  482. for item in fetch_data:
  483. if isinstance(item, tuple) and len(item) >= 2:
  484. raw_bytes = item[1]
  485. break
  486. if not raw_bytes:
  487. continue
  488. messages.append(normalize_message(message_id.decode("utf-8", errors="ignore"), raw_bytes, logical_mailbox))
  489. return {"mailbox": logical_mailbox, "messages": messages, "count": len(messages)}
  490. finally:
  491. if client is not None:
  492. try:
  493. client.logout()
  494. except Exception:
  495. pass
  496. def fetch_messages_for_mailboxes(email_addr, access_token, mailboxes, top):
  497. mailbox_results = []
  498. all_messages = []
  499. for mailbox in mailboxes or ["INBOX"]:
  500. result = fetch_messages(email_addr, access_token, mailbox=mailbox, top=top)
  501. mailbox_results.append(result)
  502. all_messages.extend(result["messages"])
  503. all_messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
  504. return {"mailboxResults": mailbox_results, "messages": all_messages}
  505. def fetch_basic_imap_messages(host, port, username, password, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT, target_email=""):
  506. client = None
  507. logical_mailbox = normalize_mailbox_label(mailbox)
  508. try:
  509. client = open_basic_imap_mailbox(host, port, username, password)
  510. select_mailbox(client, mailbox)
  511. selected_ids = load_selected_message_ids(client, top, target_email)
  512. if not selected_ids:
  513. return {"mailbox": logical_mailbox, "messages": [], "count": 0}
  514. messages = []
  515. for message_id in selected_ids:
  516. fetch_status, fetch_data = client.fetch(message_id, "(RFC822)")
  517. if fetch_status != "OK" or not fetch_data:
  518. continue
  519. raw_bytes = b""
  520. for item in fetch_data:
  521. if isinstance(item, tuple) and len(item) >= 2:
  522. raw_bytes = item[1]
  523. break
  524. if not raw_bytes:
  525. continue
  526. messages.append(normalize_message(message_id.decode("utf-8", errors="ignore"), raw_bytes, logical_mailbox))
  527. return {"mailbox": logical_mailbox, "messages": messages, "count": len(messages)}
  528. finally:
  529. if client is not None:
  530. try:
  531. client.logout()
  532. except Exception:
  533. pass
  534. def fetch_basic_imap_messages_for_mailboxes(host, port, username, password, mailboxes, top, target_email=""):
  535. mailbox_results = []
  536. all_messages = []
  537. for mailbox in mailboxes or ["INBOX"]:
  538. result = fetch_basic_imap_messages(
  539. host,
  540. port,
  541. username,
  542. password,
  543. mailbox=mailbox,
  544. top=top,
  545. target_email=target_email,
  546. )
  547. mailbox_results.append(result)
  548. all_messages.extend(result["messages"])
  549. all_messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
  550. return {"mailboxResults": mailbox_results, "messages": all_messages}
  551. def collect_basic_imap_messages(payload, mailboxes, top):
  552. settings = resolve_basic_imap_settings(payload)
  553. target_email = str(payload.get("targetEmail") or payload.get("email") or "").strip().lower()
  554. result = fetch_basic_imap_messages_for_mailboxes(
  555. settings["host"],
  556. settings["port"],
  557. settings["username"],
  558. settings["password"],
  559. mailboxes,
  560. top,
  561. target_email=target_email,
  562. )
  563. result["transport"] = "imap-basic"
  564. result["settings"] = {
  565. "host": settings["host"],
  566. "port": settings["port"],
  567. "username": settings["username"],
  568. }
  569. return result
  570. def normalize_graph_message(message, mailbox):
  571. sender = message.get("from", {}) or {}
  572. email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
  573. received = str(message.get("receivedDateTime") or "").strip()
  574. return {
  575. "id": str(message.get("id") or message.get("internetMessageId") or "").strip(),
  576. "mailbox": mailbox,
  577. "subject": str(message.get("subject") or "").strip(),
  578. "from": {
  579. "emailAddress": {
  580. "address": str(email_addr.get("address") or "").strip(),
  581. "name": str(email_addr.get("name") or "").strip(),
  582. }
  583. },
  584. "bodyPreview": str(message.get("bodyPreview") or "").strip(),
  585. "receivedDateTime": received,
  586. "receivedTimestamp": int(datetime.fromisoformat(received.replace("Z", "+00:00")).timestamp() * 1000) if received else 0,
  587. }
  588. def normalize_outlook_message(message, mailbox):
  589. sender = message.get("From", {}) or message.get("from", {}) or {}
  590. email_addr = sender.get("EmailAddress", {}) if isinstance(sender, dict) else {}
  591. if isinstance(sender, dict) and not email_addr:
  592. email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
  593. received = str(message.get("ReceivedDateTime") or message.get("receivedDateTime") or "").strip()
  594. return {
  595. "id": str(message.get("Id") or message.get("id") or "").strip(),
  596. "mailbox": mailbox,
  597. "subject": str(message.get("Subject") or message.get("subject") or "").strip(),
  598. "from": {
  599. "emailAddress": {
  600. "address": str(email_addr.get("Address") or email_addr.get("address") or "").strip(),
  601. "name": str(email_addr.get("Name") or email_addr.get("name") or "").strip(),
  602. }
  603. },
  604. "bodyPreview": str(message.get("BodyPreview") or message.get("bodyPreview") or "").strip(),
  605. "receivedDateTime": received,
  606. "receivedTimestamp": int(datetime.fromisoformat(received.replace("Z", "+00:00")).timestamp() * 1000) if received else 0,
  607. }
  608. def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
  609. mailbox_id = normalize_mailbox_id(mailbox)
  610. url = (
  611. f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages"
  612. f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}"
  613. f"&$select=id,internetMessageId,subject,from,bodyPreview,receivedDateTime"
  614. f"&$orderby=receivedDateTime desc"
  615. )
  616. try:
  617. _, payload = get_json(url, headers={
  618. "Accept": "application/json",
  619. "Authorization": f"Bearer {access_token}",
  620. })
  621. except HTTPError as exc:
  622. detail = exc.read().decode("utf-8", errors="ignore")
  623. raise RuntimeError(f"Graph request failed: {detail or exc}") from exc
  624. except URLError as exc:
  625. raise RuntimeError(f"Graph request failed: {exc}") from exc
  626. messages = [normalize_graph_message(item, normalize_mailbox_label(mailbox)) for item in (payload.get("value") or [])]
  627. return {"mailbox": normalize_mailbox_label(mailbox), "messages": messages, "count": len(messages)}
  628. def fetch_outlook_api_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
  629. mailbox_id = normalize_mailbox_id(mailbox)
  630. url = (
  631. f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages"
  632. f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}"
  633. f"&$select=Id,Subject,From,BodyPreview,ReceivedDateTime"
  634. f"&$orderby=ReceivedDateTime desc"
  635. )
  636. try:
  637. _, payload = get_json(url, headers={
  638. "Accept": "application/json",
  639. "Authorization": f"Bearer {access_token}",
  640. })
  641. except HTTPError as exc:
  642. detail = exc.read().decode("utf-8", errors="ignore")
  643. raise RuntimeError(f"Outlook API request failed: {detail or exc}") from exc
  644. except URLError as exc:
  645. raise RuntimeError(f"Outlook API request failed: {exc}") from exc
  646. messages = [normalize_outlook_message(item, normalize_mailbox_label(mailbox)) for item in (payload.get("value") or [])]
  647. return {"mailbox": normalize_mailbox_label(mailbox), "messages": messages, "count": len(messages)}
  648. def collect_imap_messages(email_addr, client_id, refresh_token, mailboxes, top):
  649. token_payload = refresh_access_token(client_id, refresh_token, [
  650. "live",
  651. "entra-consumers-delegated",
  652. "entra-common-delegated",
  653. ])
  654. result = fetch_messages_for_mailboxes(email_addr, token_payload["access_token"], mailboxes, top)
  655. result["transport"] = "imap"
  656. result["token_payload"] = token_payload
  657. return result
  658. def collect_graph_messages(email_addr, client_id, refresh_token, mailboxes, top):
  659. token_payload = refresh_access_token(client_id, refresh_token, [
  660. "entra-common-delegated",
  661. "entra-consumers-delegated",
  662. "entra-common-default",
  663. ])
  664. mailbox_results = [fetch_graph_messages(token_payload["access_token"], mailbox=mailbox, top=top) for mailbox in mailboxes]
  665. messages = []
  666. for item in mailbox_results:
  667. messages.extend(item["messages"])
  668. messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
  669. return {
  670. "transport": "graph",
  671. "token_payload": token_payload,
  672. "mailboxResults": mailbox_results,
  673. "messages": messages,
  674. }
  675. def collect_outlook_messages(email_addr, client_id, refresh_token, mailboxes, top):
  676. token_payload = refresh_access_token(client_id, refresh_token, [
  677. "entra-common-outlook",
  678. "entra-common-delegated",
  679. ])
  680. mailbox_results = [fetch_outlook_api_messages(token_payload["access_token"], mailbox=mailbox, top=top) for mailbox in mailboxes]
  681. messages = []
  682. for item in mailbox_results:
  683. messages.extend(item["messages"])
  684. messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
  685. return {
  686. "transport": "outlook",
  687. "token_payload": token_payload,
  688. "mailboxResults": mailbox_results,
  689. "messages": messages,
  690. }
  691. def collect_messages(email_addr, client_id, refresh_token, mailboxes, top):
  692. errors = []
  693. collectors = [
  694. ("imap", collect_imap_messages),
  695. ("graph", collect_graph_messages),
  696. ("outlook", collect_outlook_messages),
  697. ]
  698. for transport_name, collector in collectors:
  699. try:
  700. log_info(f"message collection start transport={transport_name}")
  701. result = collector(email_addr, client_id, refresh_token, mailboxes, top)
  702. log_info(
  703. f"message collection success transport={transport_name} "
  704. f"tokenEndpoint={result['token_payload'].get('token_endpoint', '')}"
  705. )
  706. return result
  707. except Exception as exc:
  708. message = compact_text(str(exc), 600)
  709. errors.append(f"{transport_name}: {message}")
  710. log_info(f"message collection failed transport={transport_name} detail={message}")
  711. raise RuntimeError(f"Message collection failed on all transports: {' | '.join(errors)}")
  712. def extract_code(text):
  713. source = str(text or "")
  714. patterns = [
  715. r"(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})",
  716. r"code(?:\s+is|[\s:])+(\d{6})",
  717. r"\b(\d{6})\b",
  718. ]
  719. for pattern in patterns:
  720. match = re.search(pattern, source, flags=re.IGNORECASE)
  721. if match:
  722. return match.group(1)
  723. return ""
  724. def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp, target_email=""):
  725. sender_keywords = [str(item).strip().lower() for item in sender_filters or [] if str(item).strip()]
  726. subject_keywords = [str(item).strip().lower() for item in subject_filters or [] if str(item).strip()]
  727. excluded = {str(item).strip() for item in exclude_codes or [] if str(item).strip()}
  728. normalized_target_email = str(target_email or "").strip().lower()
  729. def match_message(message, apply_time_filter):
  730. timestamp = int(message.get("receivedTimestamp") or 0)
  731. if apply_time_filter and filter_after_timestamp and timestamp and timestamp < int(filter_after_timestamp):
  732. return None
  733. sender = str(message.get("from", {}).get("emailAddress", {}).get("address", "")).lower()
  734. subject = str(message.get("subject", ""))
  735. preview = str(message.get("bodyPreview", ""))
  736. recipient_addresses = [
  737. str(item or "").strip().lower()
  738. for item in message.get("recipientAddresses", [])
  739. if str(item or "").strip()
  740. ]
  741. recipient_text = " ".join(recipient_addresses)
  742. combined = " ".join([sender, subject.lower(), preview.lower(), recipient_text])
  743. if normalized_target_email and recipient_addresses and normalized_target_email not in recipient_addresses:
  744. return None
  745. code = extract_code(" ".join([subject, preview, sender]))
  746. if not code or code in excluded:
  747. return None
  748. sender_ok = not sender_keywords or any(keyword in combined for keyword in sender_keywords)
  749. subject_ok = not subject_keywords or any(keyword in combined for keyword in subject_keywords)
  750. if not sender_ok and not subject_ok:
  751. return None
  752. return {"code": code, "message": message}
  753. for use_time_fallback in [False, True]:
  754. matched = []
  755. for message in messages:
  756. result = match_message(message, apply_time_filter=not use_time_fallback)
  757. if result:
  758. matched.append(result)
  759. if matched:
  760. matched.sort(key=lambda item: int(item["message"].get("receivedTimestamp") or 0), reverse=True)
  761. best = matched[0]
  762. return {
  763. "code": best["code"],
  764. "message": best["message"],
  765. "usedTimeFallback": use_time_fallback,
  766. }
  767. return {"code": "", "message": None, "usedTimeFallback": False}
  768. class HotmailHelperHandler(BaseHTTPRequestHandler):
  769. def do_OPTIONS(self):
  770. self.send_response(204)
  771. self.send_header("Access-Control-Allow-Origin", "*")
  772. self.send_header("Access-Control-Allow-Headers", "Content-Type")
  773. self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
  774. self.end_headers()
  775. def do_POST(self):
  776. try:
  777. payload = read_json_payload(self)
  778. if self.path == "/sync-account-run-records":
  779. file_path = sync_account_run_records(payload)
  780. json_response(self, 200, {
  781. "ok": True,
  782. "filePath": file_path,
  783. })
  784. return
  785. if self.path == "/append-account-log":
  786. file_path = append_account_log(
  787. payload.get("email"),
  788. payload.get("password"),
  789. payload.get("status"),
  790. payload.get("recordedAt"),
  791. payload.get("reason"),
  792. )
  793. json_response(self, 200, {
  794. "ok": True,
  795. "filePath": file_path,
  796. })
  797. return
  798. top = max(1, min(int(payload.get("top") or FETCH_LIMIT_DEFAULT), 30))
  799. mailboxes = payload.get("mailboxes") if isinstance(payload.get("mailboxes"), list) else [payload.get("mailbox") or "INBOX"]
  800. if self.path == "/imap-messages":
  801. result = collect_basic_imap_messages(payload, mailboxes, top)
  802. json_response(self, 200, {
  803. "ok": True,
  804. "messages": result["messages"],
  805. "mailboxResults": result["mailboxResults"],
  806. "transport": result.get("transport") or "",
  807. "settings": result.get("settings") or {},
  808. })
  809. return
  810. if self.path == "/imap-code":
  811. result = collect_basic_imap_messages(payload, mailboxes, top)
  812. selected = select_latest_code(
  813. result["messages"],
  814. payload.get("senderFilters") or [],
  815. payload.get("subjectFilters") or [],
  816. payload.get("excludeCodes") or [],
  817. int(payload.get("filterAfterTimestamp") or 0),
  818. payload.get("targetEmail") or payload.get("email") or "",
  819. )
  820. json_response(self, 200, {
  821. "ok": True,
  822. "code": selected["code"],
  823. "message": selected["message"],
  824. "usedTimeFallback": selected["usedTimeFallback"],
  825. "transport": result.get("transport") or "",
  826. "settings": result.get("settings") or {},
  827. })
  828. return
  829. email_addr = str(payload.get("email") or "").strip()
  830. client_id = str(payload.get("clientId") or "").strip()
  831. refresh_token = str(payload.get("refreshToken") or "").strip()
  832. if not email_addr or not client_id or not refresh_token:
  833. raise RuntimeError("Missing email/clientId/refreshToken")
  834. if self.path == "/messages":
  835. result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
  836. json_response(self, 200, {
  837. "ok": True,
  838. "messages": result["messages"],
  839. "mailboxResults": result["mailboxResults"],
  840. "nextRefreshToken": result["token_payload"].get("next_refresh_token") or "",
  841. "tokenEndpoint": result["token_payload"].get("token_endpoint") or "",
  842. "transport": result.get("transport") or "",
  843. })
  844. return
  845. if self.path == "/code":
  846. result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
  847. selected = select_latest_code(
  848. result["messages"],
  849. payload.get("senderFilters") or [],
  850. payload.get("subjectFilters") or [],
  851. payload.get("excludeCodes") or [],
  852. int(payload.get("filterAfterTimestamp") or 0),
  853. payload.get("targetEmail") or "",
  854. )
  855. json_response(self, 200, {
  856. "ok": True,
  857. "code": selected["code"],
  858. "message": selected["message"],
  859. "usedTimeFallback": selected["usedTimeFallback"],
  860. "nextRefreshToken": result["token_payload"].get("next_refresh_token") or "",
  861. "tokenEndpoint": result["token_payload"].get("token_endpoint") or "",
  862. "transport": result.get("transport") or "",
  863. })
  864. return
  865. json_response(self, 404, {"ok": False, "error": f"Unsupported path: {self.path}"})
  866. except Exception as exc:
  867. traceback.print_exc()
  868. json_response(self, 500, {"ok": False, "error": str(exc)})
  869. def main():
  870. server = ThreadingHTTPServer((HOST, PORT), HotmailHelperHandler)
  871. print(f"Hotmail helper listening on http://{HOST}:{PORT}", flush=True)
  872. print(f"Account log file: {ACCOUNT_LOG_PATH}", flush=True)
  873. print(f"Account snapshot file: {ACCOUNT_RECORDS_SNAPSHOT_PATH}", flush=True)
  874. try:
  875. server.serve_forever()
  876. except KeyboardInterrupt:
  877. pass
  878. finally:
  879. server.server_close()
  880. if __name__ == "__main__":
  881. main()