hotmail_helper.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661
  1. import email
  2. import html
  3. import imaplib
  4. import json
  5. import re
  6. import time
  7. from datetime import datetime, timezone
  8. from email.header import decode_header
  9. from email.utils import parseaddr, parsedate_to_datetime
  10. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  11. from urllib.error import HTTPError, URLError
  12. from urllib.parse import urlencode
  13. from urllib.request import Request, urlopen
  14. HOST = "127.0.0.1"
  15. PORT = 17373
  16. LIVE_TOKEN_URL = "https://login.live.com/oauth20_token.srf"
  17. ENTRA_COMMON_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
  18. ENTRA_CONSUMERS_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
  19. GRAPH_API_ORIGIN = "https://graph.microsoft.com"
  20. OUTLOOK_API_ORIGIN = "https://outlook.office.com"
  21. GRAPH_SCOPES = "offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read"
  22. GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
  23. TOKEN_ENDPOINTS = {
  24. "live": {
  25. "name": "live",
  26. "url": LIVE_TOKEN_URL,
  27. "extra_data": {},
  28. },
  29. "entra-consumers-delegated": {
  30. "name": "entra-consumers-delegated",
  31. "url": ENTRA_CONSUMERS_TOKEN_URL,
  32. "extra_data": {
  33. "scope": GRAPH_SCOPES,
  34. },
  35. },
  36. "entra-common-delegated": {
  37. "name": "entra-common-delegated",
  38. "url": ENTRA_COMMON_TOKEN_URL,
  39. "extra_data": {
  40. "scope": GRAPH_SCOPES,
  41. },
  42. },
  43. "entra-common-default": {
  44. "name": "entra-common-default",
  45. "url": ENTRA_COMMON_TOKEN_URL,
  46. "extra_data": {
  47. "scope": GRAPH_DEFAULT_SCOPE,
  48. },
  49. },
  50. "entra-common-outlook": {
  51. "name": "entra-common-outlook",
  52. "url": ENTRA_COMMON_TOKEN_URL,
  53. "extra_data": {},
  54. },
  55. }
  56. IMAP_HOST = "outlook.office365.com"
  57. IMAP_PORT = 993
  58. REQUEST_TIMEOUT_SECONDS = 45
  59. FETCH_LIMIT_DEFAULT = 5
  60. def json_response(handler, status, payload):
  61. body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  62. handler.send_response(status)
  63. handler.send_header("Content-Type", "application/json; charset=utf-8")
  64. handler.send_header("Content-Length", str(len(body)))
  65. handler.send_header("Access-Control-Allow-Origin", "*")
  66. handler.send_header("Access-Control-Allow-Headers", "Content-Type")
  67. handler.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
  68. handler.end_headers()
  69. handler.wfile.write(body)
  70. def read_json_payload(handler):
  71. length = int(handler.headers.get("Content-Length", "0") or 0)
  72. raw = handler.rfile.read(length) if length > 0 else b"{}"
  73. try:
  74. return json.loads(raw.decode("utf-8"))
  75. except Exception as exc:
  76. raise RuntimeError(f"Invalid JSON payload: {exc}") from exc
  77. def post_form(url, data):
  78. encoded = urlencode(data).encode("utf-8")
  79. request = Request(url, data=encoded, headers={"Content-Type": "application/x-www-form-urlencoded"})
  80. with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
  81. return json.loads(response.read().decode("utf-8"))
  82. def get_json(url, headers=None):
  83. request = Request(url, headers=headers or {})
  84. with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
  85. return response.getcode(), json.loads(response.read().decode("utf-8"))
  86. def mask_secret(value, keep=6):
  87. raw = str(value or "")
  88. if not raw:
  89. return ""
  90. if len(raw) <= keep:
  91. return "*" * len(raw)
  92. return raw[:keep] + "..." + raw[-keep:]
  93. def compact_text(value, limit=400):
  94. text = str(value or "").replace("\r", " ").replace("\n", " ").strip()
  95. return text[:limit]
  96. def log_info(message):
  97. print(f"[HotmailHelper] {message}", flush=True)
  98. def try_refresh_access_token(endpoint, client_id, refresh_token):
  99. request_data = {
  100. "client_id": client_id,
  101. "refresh_token": refresh_token,
  102. "grant_type": "refresh_token",
  103. **(endpoint.get("extra_data") or {}),
  104. }
  105. started_at = time.monotonic()
  106. try:
  107. payload = post_form(endpoint["url"], request_data)
  108. except HTTPError as exc:
  109. detail = exc.read().decode("utf-8", errors="ignore")
  110. return {
  111. "ok": False,
  112. "endpoint": endpoint["name"],
  113. "url": endpoint["url"],
  114. "status": getattr(exc, "code", None),
  115. "error": compact_text(detail or str(exc)),
  116. "elapsed_ms": int((time.monotonic() - started_at) * 1000),
  117. }
  118. except URLError as exc:
  119. return {
  120. "ok": False,
  121. "endpoint": endpoint["name"],
  122. "url": endpoint["url"],
  123. "status": None,
  124. "error": compact_text(f"Token request failed: {exc}"),
  125. "elapsed_ms": int((time.monotonic() - started_at) * 1000),
  126. }
  127. access_token = str(payload.get("access_token") or "").strip()
  128. if not access_token:
  129. return {
  130. "ok": False,
  131. "endpoint": endpoint["name"],
  132. "url": endpoint["url"],
  133. "status": 200,
  134. "error": compact_text(payload.get("error_description") or payload.get("error") or json.dumps(payload, ensure_ascii=False)),
  135. "elapsed_ms": int((time.monotonic() - started_at) * 1000),
  136. }
  137. return {
  138. "ok": True,
  139. "endpoint": endpoint["name"],
  140. "url": endpoint["url"],
  141. "elapsed_ms": int((time.monotonic() - started_at) * 1000),
  142. "payload": {
  143. "access_token": access_token,
  144. "next_refresh_token": str(payload.get("refresh_token") or "").strip(),
  145. },
  146. }
  147. def refresh_access_token(client_id, refresh_token, strategy_names=None):
  148. errors = []
  149. selected_endpoints = [
  150. TOKEN_ENDPOINTS[name]
  151. for name in (strategy_names or ["live", "entra-consumers-delegated", "entra-common-delegated"])
  152. if name in TOKEN_ENDPOINTS
  153. ]
  154. log_info(
  155. "token refresh start "
  156. f"clientId={mask_secret(client_id)} "
  157. f"refreshToken={mask_secret(refresh_token)} "
  158. f"strategies={[item['name'] for item in selected_endpoints]}"
  159. )
  160. for endpoint in selected_endpoints:
  161. result = try_refresh_access_token(endpoint, client_id, refresh_token)
  162. if result["ok"]:
  163. log_info(
  164. "token refresh success "
  165. f"endpoint={result['endpoint']} "
  166. f"elapsedMs={result['elapsed_ms']}"
  167. )
  168. return {
  169. "access_token": result["payload"]["access_token"],
  170. "next_refresh_token": result["payload"]["next_refresh_token"],
  171. "token_endpoint": result["endpoint"],
  172. "token_url": result["url"],
  173. }
  174. errors.append(result)
  175. log_info(
  176. "token refresh failed "
  177. f"endpoint={result['endpoint']} "
  178. f"status={result['status']} "
  179. f"elapsedMs={result['elapsed_ms']} "
  180. f"detail={result['error']}"
  181. )
  182. details = " | ".join(
  183. f"{item['endpoint']}({item['status']}): {item['error']}"
  184. for item in errors
  185. )
  186. raise RuntimeError(f"Token refresh failed on all endpoints: {details}")
  187. def build_xoauth2(email_addr, access_token):
  188. return f"user={email_addr}\x01auth=Bearer {access_token}\x01\x01".encode("utf-8")
  189. def open_mailbox(email_addr, access_token):
  190. client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, timeout=REQUEST_TIMEOUT_SECONDS)
  191. client.authenticate("XOAUTH2", lambda _: build_xoauth2(email_addr, access_token))
  192. return client
  193. def decode_mime_header(value):
  194. if not value:
  195. return ""
  196. parts = []
  197. for chunk, charset in decode_header(value):
  198. if isinstance(chunk, bytes):
  199. parts.append(chunk.decode(charset or "utf-8", errors="ignore"))
  200. else:
  201. parts.append(str(chunk))
  202. return "".join(parts).strip()
  203. def extract_text_part(message):
  204. if message.is_multipart():
  205. for part in message.walk():
  206. if part.get_content_maintype() == "multipart":
  207. continue
  208. if "attachment" in str(part.get("Content-Disposition") or "").lower():
  209. continue
  210. payload = part.get_payload(decode=True) or b""
  211. charset = part.get_content_charset() or "utf-8"
  212. text = payload.decode(charset, errors="ignore").strip()
  213. if part.get_content_type() == "text/plain" and text:
  214. return text
  215. if part.get_content_type() == "text/html" and text:
  216. return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
  217. return ""
  218. payload = message.get_payload(decode=True) or b""
  219. charset = message.get_content_charset() or "utf-8"
  220. text = payload.decode(charset, errors="ignore").strip()
  221. if message.get_content_type() == "text/html":
  222. return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
  223. return text
  224. def mailbox_candidates(mailbox):
  225. normalized = str(mailbox or "INBOX").strip().lower()
  226. if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
  227. return ["Junk", "Junk Email", "Junk E-Mail"]
  228. return ["INBOX"]
  229. def normalize_mailbox_label(mailbox):
  230. normalized = str(mailbox or "INBOX").strip().lower()
  231. if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
  232. return "Junk"
  233. return "INBOX"
  234. def normalize_mailbox_id(mailbox):
  235. normalized = str(mailbox or "INBOX").strip().lower()
  236. if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
  237. return "junkemail"
  238. return "inbox"
  239. def select_mailbox(client, mailbox):
  240. for candidate in mailbox_candidates(mailbox):
  241. status, _ = client.select(candidate)
  242. if status == "OK":
  243. return candidate
  244. raise RuntimeError(f"Mailbox not found: {mailbox}")
  245. def to_timestamp_ms(raw_date):
  246. if not raw_date:
  247. return 0
  248. try:
  249. parsed = parsedate_to_datetime(raw_date)
  250. if parsed.tzinfo is None:
  251. parsed = parsed.replace(tzinfo=timezone.utc)
  252. return int(parsed.timestamp() * 1000)
  253. except Exception:
  254. return 0
  255. def to_iso_string(timestamp_ms):
  256. if not timestamp_ms:
  257. return ""
  258. return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
  259. def normalize_message(message_id, raw_bytes, mailbox):
  260. parsed = email.message_from_bytes(raw_bytes)
  261. sender_name, sender_addr = parseaddr(parsed.get("From", ""))
  262. subject = decode_mime_header(parsed.get("Subject", ""))
  263. body = extract_text_part(parsed)
  264. timestamp_ms = to_timestamp_ms(parsed.get("Date"))
  265. return {
  266. "id": str(message_id),
  267. "mailbox": mailbox,
  268. "subject": subject,
  269. "from": {
  270. "emailAddress": {
  271. "address": sender_addr.strip(),
  272. "name": sender_name.strip(),
  273. }
  274. },
  275. "bodyPreview": body[:500],
  276. "receivedDateTime": to_iso_string(timestamp_ms),
  277. "receivedTimestamp": timestamp_ms,
  278. }
  279. def fetch_messages(email_addr, access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
  280. client = None
  281. logical_mailbox = normalize_mailbox_label(mailbox)
  282. try:
  283. client = open_mailbox(email_addr, access_token)
  284. select_mailbox(client, mailbox)
  285. status, data = client.search(None, "ALL")
  286. if status != "OK" or not data or not data[0]:
  287. return {"mailbox": logical_mailbox, "messages": [], "count": 0}
  288. message_ids = data[0].split()
  289. selected_ids = list(reversed(message_ids[-max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)):]))
  290. messages = []
  291. for message_id in selected_ids:
  292. fetch_status, fetch_data = client.fetch(message_id, "(RFC822)")
  293. if fetch_status != "OK" or not fetch_data:
  294. continue
  295. raw_bytes = b""
  296. for item in fetch_data:
  297. if isinstance(item, tuple) and len(item) >= 2:
  298. raw_bytes = item[1]
  299. break
  300. if not raw_bytes:
  301. continue
  302. messages.append(normalize_message(message_id.decode("utf-8", errors="ignore"), raw_bytes, logical_mailbox))
  303. return {"mailbox": logical_mailbox, "messages": messages, "count": len(messages)}
  304. finally:
  305. if client is not None:
  306. try:
  307. client.logout()
  308. except Exception:
  309. pass
  310. def fetch_messages_for_mailboxes(email_addr, access_token, mailboxes, top):
  311. mailbox_results = []
  312. all_messages = []
  313. for mailbox in mailboxes or ["INBOX"]:
  314. result = fetch_messages(email_addr, access_token, mailbox=mailbox, top=top)
  315. mailbox_results.append(result)
  316. all_messages.extend(result["messages"])
  317. all_messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
  318. return {"mailboxResults": mailbox_results, "messages": all_messages}
  319. def normalize_graph_message(message, mailbox):
  320. sender = message.get("from", {}) or {}
  321. email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
  322. received = str(message.get("receivedDateTime") or "").strip()
  323. return {
  324. "id": str(message.get("id") or message.get("internetMessageId") or "").strip(),
  325. "mailbox": mailbox,
  326. "subject": str(message.get("subject") or "").strip(),
  327. "from": {
  328. "emailAddress": {
  329. "address": str(email_addr.get("address") or "").strip(),
  330. "name": str(email_addr.get("name") or "").strip(),
  331. }
  332. },
  333. "bodyPreview": str(message.get("bodyPreview") or "").strip(),
  334. "receivedDateTime": received,
  335. "receivedTimestamp": int(datetime.fromisoformat(received.replace("Z", "+00:00")).timestamp() * 1000) if received else 0,
  336. }
  337. def normalize_outlook_message(message, mailbox):
  338. sender = message.get("From", {}) or message.get("from", {}) or {}
  339. email_addr = sender.get("EmailAddress", {}) if isinstance(sender, dict) else {}
  340. if isinstance(sender, dict) and not email_addr:
  341. email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
  342. received = str(message.get("ReceivedDateTime") or message.get("receivedDateTime") or "").strip()
  343. return {
  344. "id": str(message.get("Id") or message.get("id") or "").strip(),
  345. "mailbox": mailbox,
  346. "subject": str(message.get("Subject") or message.get("subject") or "").strip(),
  347. "from": {
  348. "emailAddress": {
  349. "address": str(email_addr.get("Address") or email_addr.get("address") or "").strip(),
  350. "name": str(email_addr.get("Name") or email_addr.get("name") or "").strip(),
  351. }
  352. },
  353. "bodyPreview": str(message.get("BodyPreview") or message.get("bodyPreview") or "").strip(),
  354. "receivedDateTime": received,
  355. "receivedTimestamp": int(datetime.fromisoformat(received.replace("Z", "+00:00")).timestamp() * 1000) if received else 0,
  356. }
  357. def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
  358. mailbox_id = normalize_mailbox_id(mailbox)
  359. url = (
  360. f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages"
  361. f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}"
  362. f"&$select=id,internetMessageId,subject,from,bodyPreview,receivedDateTime"
  363. f"&$orderby=receivedDateTime desc"
  364. )
  365. try:
  366. _, payload = get_json(url, headers={
  367. "Accept": "application/json",
  368. "Authorization": f"Bearer {access_token}",
  369. })
  370. except HTTPError as exc:
  371. detail = exc.read().decode("utf-8", errors="ignore")
  372. raise RuntimeError(f"Graph request failed: {detail or exc}") from exc
  373. except URLError as exc:
  374. raise RuntimeError(f"Graph request failed: {exc}") from exc
  375. messages = [normalize_graph_message(item, normalize_mailbox_label(mailbox)) for item in (payload.get("value") or [])]
  376. return {"mailbox": normalize_mailbox_label(mailbox), "messages": messages, "count": len(messages)}
  377. def fetch_outlook_api_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
  378. mailbox_id = normalize_mailbox_id(mailbox)
  379. url = (
  380. f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages"
  381. f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}"
  382. f"&$select=Id,Subject,From,BodyPreview,ReceivedDateTime"
  383. f"&$orderby=ReceivedDateTime desc"
  384. )
  385. try:
  386. _, payload = get_json(url, headers={
  387. "Accept": "application/json",
  388. "Authorization": f"Bearer {access_token}",
  389. })
  390. except HTTPError as exc:
  391. detail = exc.read().decode("utf-8", errors="ignore")
  392. raise RuntimeError(f"Outlook API request failed: {detail or exc}") from exc
  393. except URLError as exc:
  394. raise RuntimeError(f"Outlook API request failed: {exc}") from exc
  395. messages = [normalize_outlook_message(item, normalize_mailbox_label(mailbox)) for item in (payload.get("value") or [])]
  396. return {"mailbox": normalize_mailbox_label(mailbox), "messages": messages, "count": len(messages)}
  397. def collect_imap_messages(email_addr, client_id, refresh_token, mailboxes, top):
  398. token_payload = refresh_access_token(client_id, refresh_token, [
  399. "live",
  400. "entra-consumers-delegated",
  401. "entra-common-delegated",
  402. ])
  403. result = fetch_messages_for_mailboxes(email_addr, token_payload["access_token"], mailboxes, top)
  404. result["transport"] = "imap"
  405. result["token_payload"] = token_payload
  406. return result
  407. def collect_graph_messages(email_addr, client_id, refresh_token, mailboxes, top):
  408. token_payload = refresh_access_token(client_id, refresh_token, [
  409. "entra-common-delegated",
  410. "entra-consumers-delegated",
  411. "entra-common-default",
  412. ])
  413. mailbox_results = [fetch_graph_messages(token_payload["access_token"], mailbox=mailbox, top=top) for mailbox in mailboxes]
  414. messages = []
  415. for item in mailbox_results:
  416. messages.extend(item["messages"])
  417. messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
  418. return {
  419. "transport": "graph",
  420. "token_payload": token_payload,
  421. "mailboxResults": mailbox_results,
  422. "messages": messages,
  423. }
  424. def collect_outlook_messages(email_addr, client_id, refresh_token, mailboxes, top):
  425. token_payload = refresh_access_token(client_id, refresh_token, [
  426. "entra-common-outlook",
  427. "entra-common-delegated",
  428. ])
  429. mailbox_results = [fetch_outlook_api_messages(token_payload["access_token"], mailbox=mailbox, top=top) for mailbox in mailboxes]
  430. messages = []
  431. for item in mailbox_results:
  432. messages.extend(item["messages"])
  433. messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
  434. return {
  435. "transport": "outlook",
  436. "token_payload": token_payload,
  437. "mailboxResults": mailbox_results,
  438. "messages": messages,
  439. }
  440. def collect_messages(email_addr, client_id, refresh_token, mailboxes, top):
  441. errors = []
  442. collectors = [
  443. ("imap", collect_imap_messages),
  444. ("graph", collect_graph_messages),
  445. ("outlook", collect_outlook_messages),
  446. ]
  447. for transport_name, collector in collectors:
  448. try:
  449. log_info(f"message collection start transport={transport_name}")
  450. result = collector(email_addr, client_id, refresh_token, mailboxes, top)
  451. log_info(
  452. f"message collection success transport={transport_name} "
  453. f"tokenEndpoint={result['token_payload'].get('token_endpoint', '')}"
  454. )
  455. return result
  456. except Exception as exc:
  457. message = compact_text(str(exc), 600)
  458. errors.append(f"{transport_name}: {message}")
  459. log_info(f"message collection failed transport={transport_name} detail={message}")
  460. raise RuntimeError(f"Message collection failed on all transports: {' | '.join(errors)}")
  461. def extract_code(text):
  462. source = str(text or "")
  463. patterns = [
  464. r"(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})",
  465. r"code(?:\s+is|[\s:])+(\d{6})",
  466. r"\b(\d{6})\b",
  467. ]
  468. for pattern in patterns:
  469. match = re.search(pattern, source, flags=re.IGNORECASE)
  470. if match:
  471. return match.group(1)
  472. return ""
  473. def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp):
  474. sender_keywords = [str(item).strip().lower() for item in sender_filters or [] if str(item).strip()]
  475. subject_keywords = [str(item).strip().lower() for item in subject_filters or [] if str(item).strip()]
  476. excluded = {str(item).strip() for item in exclude_codes or [] if str(item).strip()}
  477. def match_message(message, apply_time_filter):
  478. timestamp = int(message.get("receivedTimestamp") or 0)
  479. if apply_time_filter and filter_after_timestamp and timestamp and timestamp < int(filter_after_timestamp):
  480. return None
  481. sender = str(message.get("from", {}).get("emailAddress", {}).get("address", "")).lower()
  482. subject = str(message.get("subject", ""))
  483. preview = str(message.get("bodyPreview", ""))
  484. combined = " ".join([sender, subject.lower(), preview.lower()])
  485. code = extract_code(" ".join([subject, preview, sender]))
  486. if not code or code in excluded:
  487. return None
  488. sender_ok = not sender_keywords or any(keyword in combined for keyword in sender_keywords)
  489. subject_ok = not subject_keywords or any(keyword in combined for keyword in subject_keywords)
  490. if not sender_ok and not subject_ok:
  491. return None
  492. return {"code": code, "message": message}
  493. for use_time_fallback in [False, True]:
  494. matched = []
  495. for message in messages:
  496. result = match_message(message, apply_time_filter=not use_time_fallback)
  497. if result:
  498. matched.append(result)
  499. if matched:
  500. matched.sort(key=lambda item: int(item["message"].get("receivedTimestamp") or 0), reverse=True)
  501. best = matched[0]
  502. return {
  503. "code": best["code"],
  504. "message": best["message"],
  505. "usedTimeFallback": use_time_fallback,
  506. }
  507. return {"code": "", "message": None, "usedTimeFallback": False}
  508. class HotmailHelperHandler(BaseHTTPRequestHandler):
  509. def do_OPTIONS(self):
  510. self.send_response(204)
  511. self.send_header("Access-Control-Allow-Origin", "*")
  512. self.send_header("Access-Control-Allow-Headers", "Content-Type")
  513. self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
  514. self.end_headers()
  515. def do_POST(self):
  516. try:
  517. payload = read_json_payload(self)
  518. email_addr = str(payload.get("email") or "").strip()
  519. client_id = str(payload.get("clientId") or "").strip()
  520. refresh_token = str(payload.get("refreshToken") or "").strip()
  521. if not email_addr or not client_id or not refresh_token:
  522. raise RuntimeError("Missing email/clientId/refreshToken")
  523. top = max(1, min(int(payload.get("top") or FETCH_LIMIT_DEFAULT), 30))
  524. mailboxes = payload.get("mailboxes") if isinstance(payload.get("mailboxes"), list) else [payload.get("mailbox") or "INBOX"]
  525. if self.path == "/messages":
  526. result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
  527. json_response(self, 200, {
  528. "ok": True,
  529. "messages": result["messages"],
  530. "mailboxResults": result["mailboxResults"],
  531. "nextRefreshToken": result["token_payload"].get("next_refresh_token") or "",
  532. "tokenEndpoint": result["token_payload"].get("token_endpoint") or "",
  533. "transport": result.get("transport") or "",
  534. })
  535. return
  536. if self.path == "/code":
  537. result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
  538. selected = select_latest_code(
  539. result["messages"],
  540. payload.get("senderFilters") or [],
  541. payload.get("subjectFilters") or [],
  542. payload.get("excludeCodes") or [],
  543. int(payload.get("filterAfterTimestamp") or 0),
  544. )
  545. json_response(self, 200, {
  546. "ok": True,
  547. "code": selected["code"],
  548. "message": selected["message"],
  549. "usedTimeFallback": selected["usedTimeFallback"],
  550. "nextRefreshToken": result["token_payload"].get("next_refresh_token") or "",
  551. "tokenEndpoint": result["token_payload"].get("token_endpoint") or "",
  552. "transport": result.get("transport") or "",
  553. })
  554. return
  555. json_response(self, 404, {"ok": False, "error": f"Unsupported path: {self.path}"})
  556. except Exception as exc:
  557. json_response(self, 500, {"ok": False, "error": str(exc)})
  558. def main():
  559. server = ThreadingHTTPServer((HOST, PORT), HotmailHelperHandler)
  560. print(f"Hotmail helper listening on http://{HOST}:{PORT}", flush=True)
  561. try:
  562. server.serve_forever()
  563. except KeyboardInterrupt:
  564. pass
  565. finally:
  566. server.server_close()
  567. if __name__ == "__main__":
  568. main()