import GuJumpgate snapshot from GitHub

This commit is contained in:
2026-05-20 20:33:51 +08:00
commit f8903dcd14
335 changed files with 179790 additions and 0 deletions
+424
View File
@@ -0,0 +1,424 @@
#!/usr/bin/env python3
"""Local macOS SMS OTP helper for GPC GoPay flows.
Run this on the Mac that receives forwarded iPhone SMS messages. The helper
reads a copy of the macOS Messages database and exposes the latest matching OTP
on a localhost HTTP endpoint for the Chrome extension.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import shutil
import sqlite3
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Optional
from urllib.parse import parse_qs, urlparse
HOST = "127.0.0.1"
PORT = 18767
DEFAULT_DB = "~/Library/Messages/chat.db"
MAC_ABSOLUTE_EPOCH = dt.datetime(2001, 1, 1, tzinfo=dt.timezone.utc)
OTP_PATTERNS = (
re.compile(r"(?i)\bOTP\s*[:]?\s*([0-9]{4,8})\b"),
re.compile(r"#([0-9]{4,8})\b"),
re.compile(r"(?<!\d)([0-9]{6})(?!\d)"),
)
KEYWORDS = ("gojek", "gopay", "openai llc", "openai")
STATE_LOCK = threading.Lock()
STATE = {
"started_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"db_path": "",
"last_scan_at": "",
"last_error": "",
"last_rowid": 0,
"last_otp": None,
"otps": [],
}
def is_macos() -> bool:
override = os.environ.get("GPC_SMS_HELPER_ALLOW_NON_MAC", "").strip().lower()
return sys.platform == "darwin" or override in {"1", "true", "yes"}
def require_macos() -> None:
if not is_macos():
raise RuntimeError(
"GPC 本地 SMS Helper 仅支持 macOS:需要读取 ~/Library/Messages/chat.db。"
"请确认接收验证码的 iPhone 已开启短信转发,并能在 Mac 信息 app 中看到短信。"
)
def extract_gopay_otp(text: str, require_keywords: bool = True) -> Optional[str]:
raw = text or ""
lowered = raw.lower()
if require_keywords and not any(keyword in lowered for keyword in KEYWORDS):
return None
for pattern in OTP_PATTERNS:
match = pattern.search(raw)
if match:
return match.group(1)
return None
def normalize_phone_key(value: object) -> str:
digits = re.sub(r"\D+", "", str(value or ""))
return f"+{digits}" if digits else ""
def mac_message_time_to_datetime(value: int | float | None) -> dt.datetime:
if not value:
return dt.datetime.now(dt.timezone.utc)
seconds = float(value)
if seconds > 10_000_000_000:
seconds = seconds / 1_000_000_000
return MAC_ABSOLUTE_EPOCH + dt.timedelta(seconds=seconds)
def update_state(**updates: object) -> None:
with STATE_LOCK:
STATE.update(updates)
def get_state() -> dict:
with STATE_LOCK:
return dict(STATE)
def copy_messages_db(db_path: Path) -> Path:
if not db_path.exists():
raise FileNotFoundError(f"Messages 数据库不存在:{db_path}")
tmpdir = Path(tempfile.mkdtemp(prefix="gpc_messages_"))
copied = tmpdir / "chat.db"
shutil.copy2(db_path, copied)
wal = db_path.with_name(db_path.name + "-wal")
shm = db_path.with_name(db_path.name + "-shm")
if wal.exists():
shutil.copy2(wal, copied.with_name("chat.db-wal"))
if shm.exists():
shutil.copy2(shm, copied.with_name("chat.db-shm"))
return copied
def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) -> list[dict]:
copied = copy_messages_db(db_path)
try:
conn = sqlite3.connect(str(copied))
conn.row_factory = sqlite3.Row
params = (max(0, int(after_rowid or 0)), max(1, int(limit or 80)))
try:
rows = conn.execute(
"""
SELECT
message.ROWID AS rowid,
message.guid AS guid,
message.text AS text,
message.date AS date,
message.service AS service,
message.destination_caller_id AS destination_caller_id,
message.account AS account,
handle.id AS handle,
chat.last_addressed_handle AS last_addressed_handle,
chat.account_login AS account_login
FROM message
LEFT JOIN handle ON message.handle_id = handle.ROWID
LEFT JOIN chat_message_join ON chat_message_join.message_id = message.ROWID
LEFT JOIN chat ON chat.ROWID = chat_message_join.chat_id
WHERE message.ROWID > ?
AND message.text IS NOT NULL
ORDER BY message.ROWID DESC
LIMIT ?
""",
params,
).fetchall()
except sqlite3.OperationalError:
rows = conn.execute(
"""
SELECT
message.ROWID AS rowid,
message.guid AS guid,
message.text AS text,
message.date AS date,
message.service AS service,
message.account AS account,
handle.id AS handle
FROM message
LEFT JOIN handle ON message.handle_id = handle.ROWID
WHERE message.ROWID > ?
AND message.text IS NOT NULL
ORDER BY message.ROWID DESC
LIMIT ?
""",
params,
).fetchall()
return [dict(row) for row in rows]
finally:
try:
conn.close() # type: ignore[name-defined]
except Exception:
pass
shutil.rmtree(copied.parent, ignore_errors=True)
def get_record_phone(row: dict) -> str:
for key in ("destination_caller_id", "last_addressed_handle", "account_login", "account"):
phone = normalize_phone_key(row.get(key))
if phone:
return phone
return ""
def make_otp_record(row: dict, otp: str) -> dict:
received_at = mac_message_time_to_datetime(row.get("date")).isoformat()
phone_e164 = get_record_phone(row)
return {
"otp": otp,
"code": otp,
"message_id": str(row.get("guid") or row.get("rowid") or ""),
"rowid": int(row.get("rowid") or 0),
"sender": str(row.get("handle") or ""),
"service": str(row.get("service") or ""),
"account_phone": phone_e164,
"phone_e164": phone_e164,
"received_at": received_at,
"message_text": str(row.get("text") or ""),
}
def append_otp(record: dict, max_records: int = 30) -> None:
with STATE_LOCK:
records = [item for item in STATE.get("otps", []) if item.get("message_id") != record.get("message_id")]
records.insert(0, record)
STATE["otps"] = records[:max_records]
STATE["last_otp"] = record
STATE["last_rowid"] = max(int(STATE.get("last_rowid") or 0), int(record.get("rowid") or 0))
def parse_timestamp_ms(value: object) -> int:
if value is None:
return 0
if isinstance(value, (int, float)):
numeric = float(value)
else:
raw = str(value).strip()
if not raw:
return 0
try:
numeric = float(raw)
except ValueError:
try:
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=dt.timezone.utc)
return int(parsed.timestamp() * 1000)
except ValueError:
return 0
if numeric <= 0:
return 0
if numeric < 100_000_000_000:
numeric *= 1000
return int(numeric)
def record_matches_phone(record: dict, phone: str = "") -> bool:
wanted = normalize_phone_key(phone)
if not wanted:
return True
return normalize_phone_key(record.get("phone_e164") or record.get("account_phone")) == wanted
def select_otp_record(state: dict, after_ms: int = 0, phone: str = "") -> Optional[dict]:
records = state.get("otps")
if not isinstance(records, list):
records = []
if after_ms > 0:
for record in records:
if (
isinstance(record, dict)
and record_matches_phone(record, phone)
and parse_timestamp_ms(record.get("received_at")) >= after_ms
):
return record
return None
record = state.get("last_otp") or None
if isinstance(record, dict) and record_matches_phone(record, phone):
return record
for record in records:
if isinstance(record, dict) and record_matches_phone(record, phone):
return record
return None
def consume_otp_record(phone: str = "", record: Optional[dict] = None) -> None:
wanted = normalize_phone_key(phone)
consumed_message_id = str((record or {}).get("message_id") or "").strip()
consumed_rowid = int((record or {}).get("rowid") or 0)
def is_consumed_record(item: object) -> bool:
if not isinstance(item, dict):
return False
if consumed_message_id and str(item.get("message_id") or "").strip() == consumed_message_id:
return True
if consumed_rowid and int(item.get("rowid") or 0) == consumed_rowid:
return True
return False
def is_same_record(left: object, right: object) -> bool:
if not isinstance(left, dict) or not isinstance(right, dict):
return False
left_message_id = str(left.get("message_id") or "").strip()
right_message_id = str(right.get("message_id") or "").strip()
if left_message_id and right_message_id and left_message_id == right_message_id:
return True
left_rowid = int(left.get("rowid") or 0)
right_rowid = int(right.get("rowid") or 0)
if left_rowid > 0 and right_rowid > 0 and left_rowid == right_rowid:
return True
return left == right
with STATE_LOCK:
records = STATE.get("otps")
if not isinstance(records, list):
records = []
if consumed_message_id or consumed_rowid:
next_records = [item for item in records if isinstance(item, dict) and not is_consumed_record(item)]
elif wanted:
removed_once = False
next_records = []
for item in records:
if not isinstance(item, dict):
continue
if not removed_once and record_matches_phone(item, wanted):
removed_once = True
continue
next_records.append(item)
else:
next_records = []
STATE["otps"] = next_records
last_otp = STATE.get("last_otp")
if isinstance(last_otp, dict) and not any(is_same_record(last_otp, item) for item in STATE["otps"]):
STATE["last_otp"] = STATE["otps"][0] if STATE["otps"] else None
def scan_once(db_path: Path, require_keywords: bool = True) -> None:
state = get_state()
after_rowid = int(state.get("last_rowid") or 0)
rows = read_recent_messages(db_path, after_rowid=after_rowid)
max_rowid = after_rowid
for row in reversed(rows):
max_rowid = max(max_rowid, int(row.get("rowid") or 0))
otp = extract_gopay_otp(row.get("text") or "", require_keywords=require_keywords)
if otp:
record = make_otp_record(row, otp)
append_otp(record)
print(f"captured OTP {otp} from message {record['message_id']} at {record['received_at']}", flush=True)
update_state(last_rowid=max_rowid, last_scan_at=dt.datetime.now(dt.timezone.utc).isoformat(), last_error="")
def scan_loop(db_path: Path, interval_seconds: float, require_keywords: bool) -> None:
update_state(db_path=str(db_path))
while True:
try:
scan_once(db_path, require_keywords=require_keywords)
except Exception as exc:
update_state(last_error=str(exc), last_scan_at=dt.datetime.now(dt.timezone.utc).isoformat())
print(f"scan error: {exc}", file=sys.stderr, flush=True)
time.sleep(max(0.5, float(interval_seconds or 2)))
def write_json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Access-Control-Allow-Origin", "*")
handler.send_header("Cache-Control", "no-store")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(body)
class HelperHandler(BaseHTTPRequestHandler):
server_version = "GpcSmsHelper/1.0"
def log_message(self, fmt: str, *args: object) -> None:
print(f"{self.address_string()} - {fmt % args}", flush=True)
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path == "/health":
state = get_state()
write_json(self, 200, {
"ok": True,
"status": "ok",
"has_otp": bool(state.get("last_otp")),
"last_scan_at": state.get("last_scan_at"),
"last_error": state.get("last_error"),
})
return
if parsed.path in ("/otp", "/latest-otp"):
query = parse_qs(parsed.query)
consume = str(query.get("consume", ["0"])[0]).strip().lower() in {"1", "true", "yes"}
after_ms = parse_timestamp_ms(query.get("after_ms", query.get("after", ["0"]))[0])
phone = str((query.get("phone") or query.get("phone_e164") or query.get("phone_number") or [""])[0]).strip()
state = get_state()
record = select_otp_record(state, after_ms=after_ms, phone=phone)
if not record:
write_json(self, 200, {"ok": True, "otp": "", "code": "", "status": "waiting", "message": "未查询到验证码"})
return
payload = {"ok": True, "status": "found", **record}
if consume:
consume_otp_record(phone=phone, record=record)
write_json(self, 200, payload)
return
write_json(self, 404, {"ok": False, "error": "not_found"})
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run a local macOS Messages HTTP helper for GPC SMS OTP.")
parser.add_argument("--host", default=HOST, help="Bind host, default 127.0.0.1")
parser.add_argument("--port", type=int, default=PORT, help="Bind port, default 18767")
parser.add_argument("--db", default=DEFAULT_DB, help="Path to macOS Messages chat.db")
parser.add_argument("--interval", type=float, default=2.0, help="Message scan interval in seconds")
parser.add_argument("--no-keywords", action="store_true", help="Accept any numeric OTP without GPC/OpenAI keywords")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
require_macos()
db_path = Path(os.path.expanduser(args.db)).resolve()
scanner = threading.Thread(
target=scan_loop,
args=(db_path, args.interval, not args.no_keywords),
daemon=True,
)
scanner.start()
server = ThreadingHTTPServer((args.host, int(args.port)), HelperHandler)
print(f"GPC SMS Helper listening on http://{args.host}:{int(args.port)}", flush=True)
print("请确认 iPhone 短信已转发到本机 Messages。", flush=True)
server.serve_forever()
return 0
except KeyboardInterrupt:
return 0
except Exception as exc:
print(str(exc), file=sys.stderr, flush=True)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+966
View File
@@ -0,0 +1,966 @@
import argparse
import email
import html
import imaplib
import json
import os
import re
import threading
import time
import traceback
from datetime import datetime, timezone
from email.header import decode_header
from email.utils import parseaddr, parsedate_to_datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse, urlencode
from urllib.request import Request, urlopen
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 17373
LIVE_TOKEN_URL = "https://login.live.com/oauth20_token.srf"
ENTRA_COMMON_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
ENTRA_CONSUMERS_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
GRAPH_API_ORIGIN = "https://graph.microsoft.com"
OUTLOOK_API_ORIGIN = "https://outlook.office.com"
GRAPH_SCOPES = "offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read"
GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
TOKEN_ENDPOINTS = {
"live": {
"name": "live",
"url": LIVE_TOKEN_URL,
"extra_data": {},
},
"entra-consumers-delegated": {
"name": "entra-consumers-delegated",
"url": ENTRA_CONSUMERS_TOKEN_URL,
"extra_data": {
"scope": GRAPH_SCOPES,
},
},
"entra-common-delegated": {
"name": "entra-common-delegated",
"url": ENTRA_COMMON_TOKEN_URL,
"extra_data": {
"scope": GRAPH_SCOPES,
},
},
"entra-common-default": {
"name": "entra-common-default",
"url": ENTRA_COMMON_TOKEN_URL,
"extra_data": {
"scope": GRAPH_DEFAULT_SCOPE,
},
},
"entra-common-outlook": {
"name": "entra-common-outlook",
"url": ENTRA_COMMON_TOKEN_URL,
"extra_data": {},
},
}
IMAP_HOST = "outlook.office365.com"
IMAP_PORT = 993
REQUEST_TIMEOUT_SECONDS = 45
FETCH_LIMIT_DEFAULT = 5
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
ACCOUNT_LOG_PATH = os.path.join(BASE_DIR, "data", "account-run-history.txt")
ACCOUNT_RECORDS_SNAPSHOT_PATH = os.path.join(BASE_DIR, "data", "account-run-history.json")
ACCOUNT_RECORDS_LOCK = threading.Lock()
def normalize_server_port(raw_value, default=DEFAULT_PORT):
candidate = default if raw_value is None or str(raw_value).strip() == "" else raw_value
try:
port = int(str(candidate).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"Invalid helper port: {raw_value}") from exc
if port < 1 or port > 65535:
raise ValueError(f"Helper port out of range: {port}")
return port
def resolve_server_config(argv=None, environ=None):
runtime_environ = environ if environ is not None else os.environ
parser = argparse.ArgumentParser(description="Start the local Hotmail helper service.")
parser.add_argument(
"--host",
default=str(runtime_environ.get("HOTMAIL_HELPER_HOST") or DEFAULT_HOST).strip() or DEFAULT_HOST,
help="Server host. Defaults to HOTMAIL_HELPER_HOST or 127.0.0.1.",
)
parser.add_argument(
"--port",
default=runtime_environ.get("HOTMAIL_HELPER_PORT"),
help="Server port. Defaults to HOTMAIL_HELPER_PORT or 17373.",
)
args = parser.parse_args(argv)
host = str(args.host or DEFAULT_HOST).strip() or DEFAULT_HOST
try:
port = normalize_server_port(args.port, default=DEFAULT_PORT)
except ValueError as exc:
parser.error(str(exc))
return {
"host": host,
"port": port,
}
def json_response(handler, status, payload):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Access-Control-Allow-Origin", "*")
handler.send_header("Access-Control-Allow-Headers", "Content-Type")
handler.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
try:
handler.end_headers()
handler.wfile.write(body)
except (BrokenPipeError, ConnectionResetError) as exc:
log_info(f"response aborted by client status={status} detail={compact_text(exc)}")
def read_json_payload(handler):
length = int(handler.headers.get("Content-Length", "0") or 0)
raw = handler.rfile.read(length) if length > 0 else b"{}"
try:
return json.loads(raw.decode("utf-8"))
except Exception as exc:
raise RuntimeError(f"Invalid JSON payload: {exc}") from exc
def post_form(url, data):
encoded = urlencode(data).encode("utf-8")
request = Request(url, data=encoded, headers={"Content-Type": "application/x-www-form-urlencoded"})
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
return json.loads(response.read().decode("utf-8"))
def get_json(url, headers=None):
request = Request(url, headers=headers or {})
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
return response.getcode(), json.loads(response.read().decode("utf-8"))
def mask_secret(value, keep=6):
raw = str(value or "")
if not raw:
return ""
if len(raw) <= keep:
return "*" * len(raw)
return raw[:keep] + "..." + raw[-keep:]
def compact_text(value, limit=400):
text = str(value or "").replace("\r", " ").replace("\n", " ").strip()
return text[:limit]
def log_info(message):
print(f"[HotmailHelper] {message}", flush=True)
def get_message_body_content(message):
body = message.get("body") or {}
if not isinstance(body, dict):
return ""
return str(body.get("content") or "").strip()
def get_proxy_debug_context():
names = ["all_proxy", "http_proxy", "https_proxy", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY"]
parts = []
for name in names:
value = str(os.environ.get(name) or "").strip()
if value:
parts.append(f"{name}={value}")
return ",".join(parts) if parts else "direct"
def classify_token_refresh_failure(result):
detail = str(result.get("error") or "").strip().lower()
if "invalid_grant" in detail or "aadsts70000" in detail:
return "invalid_grant"
if "proxy authentication required" in detail:
return "proxy_auth_failed"
if "connection refused" in detail:
return "proxy_connect_failed" if get_proxy_debug_context() != "direct" else "connection_refused"
if "eof occurred in violation of protocol" in detail or "wrong version number" in detail:
return "proxy_tls_failed" if get_proxy_debug_context() != "direct" else "tls_failed"
if "timed out" in detail or "timeout" in detail:
return "network_timeout"
return "request_failed"
def log_token_refresh_failure_diagnosis(result):
category = classify_token_refresh_failure(result)
message = (
"token refresh diagnosis "
f"endpoint={result['endpoint']} "
f"category={category}"
)
if category.startswith("proxy_"):
message += f" proxy={get_proxy_debug_context()}"
elif category == "invalid_grant":
message += " hint=refresh_token_or_scope_invalid"
log_info(message)
def append_account_log(email_addr, password, status, recorded_at="", reason=""):
normalized_email = str(email_addr or "").strip()
normalized_password = str(password or "").strip()
normalized_status = str(status or "").strip().lower()
normalized_recorded_at = str(recorded_at or "").strip() or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
normalized_reason = str(reason or "").strip().replace("\r", " ").replace("\n", " ")
if not normalized_email or not normalized_password or not normalized_status:
raise RuntimeError("Missing email/password/status for account log append")
os.makedirs(os.path.dirname(ACCOUNT_LOG_PATH), exist_ok=True)
line = f"{normalized_recorded_at}\t{normalized_email}\t{normalized_password}\t{normalized_status}\t{normalized_reason}\n"
with ACCOUNT_RECORDS_LOCK:
with open(ACCOUNT_LOG_PATH, "a", encoding="utf-8") as handle:
handle.write(line)
return ACCOUNT_LOG_PATH
def save_local_cpa_json(file_path, content, directory_path=""):
target_path = Path(str(file_path or "").strip()).expanduser()
if not str(target_path):
raise RuntimeError("Missing filePath")
if not target_path.is_absolute():
raise RuntimeError("filePath must be absolute")
if directory_path:
Path(str(directory_path).strip()).expanduser().mkdir(parents=True, exist_ok=True)
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(str(content or ""), encoding="utf-8")
return str(target_path)
def normalize_account_run_snapshot_record(record):
if not isinstance(record, dict):
return None
email_addr = str(record.get("email") or "").strip()
password = str(record.get("password") or "").strip()
final_status = str(record.get("finalStatus") or "").strip().lower()
if not email_addr or not password or final_status not in {"success", "failed", "stopped", "running"}:
return None
finished_at = str(record.get("finishedAt") or "").strip() or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
retry_count = max(0, int(record.get("retryCount") or 0))
failed_step_raw = record.get("failedStep")
try:
failed_step = int(failed_step_raw)
except (TypeError, ValueError):
failed_step = None
if failed_step is not None and failed_step <= 0:
failed_step = None
auto_run_context = record.get("autoRunContext") if isinstance(record.get("autoRunContext"), dict) else None
normalized_auto_run_context = None
if auto_run_context:
normalized_auto_run_context = {
"currentRun": max(0, int(auto_run_context.get("currentRun") or 0)),
"totalRuns": max(0, int(auto_run_context.get("totalRuns") or 0)),
"attemptRun": max(0, int(auto_run_context.get("attemptRun") or 0)),
}
if not any(normalized_auto_run_context.values()):
normalized_auto_run_context = None
source = "auto" if str(record.get("source") or "").strip().lower() == "auto" else "manual"
return {
"recordId": str(record.get("recordId") or email_addr).strip() or email_addr,
"email": email_addr,
"password": password,
"finalStatus": final_status,
"finishedAt": finished_at,
"retryCount": retry_count,
"failureLabel": str(record.get("failureLabel") or "").strip(),
"failureDetail": str(record.get("failureDetail") or "").strip(),
"failedStep": failed_step,
"source": source,
"autoRunContext": normalized_auto_run_context,
}
def summarize_account_run_snapshot(records):
summary = {
"total": 0,
"success": 0,
"running": 0,
"failed": 0,
"stopped": 0,
"retryTotal": 0,
}
for item in records:
summary["total"] += 1
if item.get("finalStatus") == "success":
summary["success"] += 1
elif item.get("finalStatus") == "running":
summary["running"] += 1
elif item.get("finalStatus") == "failed":
summary["failed"] += 1
elif item.get("finalStatus") == "stopped":
summary["stopped"] += 1
summary["retryTotal"] += max(0, int(item.get("retryCount") or 0))
return summary
def normalize_account_run_snapshot_payload(payload):
if not isinstance(payload, dict):
raise RuntimeError("Invalid account run snapshot payload")
normalized_records = []
for item in payload.get("records") if isinstance(payload.get("records"), list) else []:
normalized = normalize_account_run_snapshot_record(item)
if normalized:
normalized_records.append(normalized)
return {
"generatedAt": str(payload.get("generatedAt") or "").strip() or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"summary": summarize_account_run_snapshot(normalized_records),
"records": normalized_records,
}
def sync_account_run_records(payload):
normalized_payload = normalize_account_run_snapshot_payload(payload)
os.makedirs(os.path.dirname(ACCOUNT_RECORDS_SNAPSHOT_PATH), exist_ok=True)
with ACCOUNT_RECORDS_LOCK:
with open(ACCOUNT_RECORDS_SNAPSHOT_PATH, "w", encoding="utf-8") as handle:
json.dump(normalized_payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
return ACCOUNT_RECORDS_SNAPSHOT_PATH
def try_refresh_access_token(endpoint, client_id, refresh_token):
request_data = {
"client_id": client_id,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
**(endpoint.get("extra_data") or {}),
}
started_at = time.monotonic()
try:
payload = post_form(endpoint["url"], request_data)
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
return {
"ok": False,
"endpoint": endpoint["name"],
"url": endpoint["url"],
"status": getattr(exc, "code", None),
"error": compact_text(detail or str(exc)),
"elapsed_ms": int((time.monotonic() - started_at) * 1000),
}
except URLError as exc:
return {
"ok": False,
"endpoint": endpoint["name"],
"url": endpoint["url"],
"status": None,
"error": compact_text(f"Token request failed: {exc}"),
"elapsed_ms": int((time.monotonic() - started_at) * 1000),
}
access_token = str(payload.get("access_token") or "").strip()
if not access_token:
return {
"ok": False,
"endpoint": endpoint["name"],
"url": endpoint["url"],
"status": 200,
"error": compact_text(payload.get("error_description") or payload.get("error") or json.dumps(payload, ensure_ascii=False)),
"elapsed_ms": int((time.monotonic() - started_at) * 1000),
}
return {
"ok": True,
"endpoint": endpoint["name"],
"url": endpoint["url"],
"elapsed_ms": int((time.monotonic() - started_at) * 1000),
"payload": {
"access_token": access_token,
"next_refresh_token": str(payload.get("refresh_token") or "").strip(),
},
}
def refresh_access_token(client_id, refresh_token, strategy_names=None):
errors = []
selected_endpoints = [
TOKEN_ENDPOINTS[name]
for name in (strategy_names or ["live", "entra-consumers-delegated", "entra-common-delegated"])
if name in TOKEN_ENDPOINTS
]
log_info(
"token refresh start "
f"clientId={mask_secret(client_id)} "
f"refreshToken={mask_secret(refresh_token)} "
f"strategies={[item['name'] for item in selected_endpoints]}"
)
for endpoint in selected_endpoints:
result = try_refresh_access_token(endpoint, client_id, refresh_token)
if result["ok"]:
log_info(
"token refresh success "
f"endpoint={result['endpoint']} "
f"elapsedMs={result['elapsed_ms']}"
)
return {
"access_token": result["payload"]["access_token"],
"next_refresh_token": result["payload"]["next_refresh_token"],
"token_endpoint": result["endpoint"],
"token_url": result["url"],
}
errors.append(result)
log_info(
"token refresh failed "
f"endpoint={result['endpoint']} "
f"status={result['status']} "
f"elapsedMs={result['elapsed_ms']} "
f"detail={result['error']}"
)
log_token_refresh_failure_diagnosis(result)
details = " | ".join(
f"{item['endpoint']}({item['status']}): {item['error']}"
for item in errors
)
raise RuntimeError(f"Token refresh failed on all endpoints: {details}")
def build_xoauth2(email_addr, access_token):
return f"user={email_addr}\x01auth=Bearer {access_token}\x01\x01".encode("utf-8")
def open_mailbox(email_addr, access_token):
client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, timeout=REQUEST_TIMEOUT_SECONDS)
client.authenticate("XOAUTH2", lambda _: build_xoauth2(email_addr, access_token))
return client
def decode_mime_header(value):
if not value:
return ""
parts = []
for chunk, charset in decode_header(value):
if isinstance(chunk, bytes):
parts.append(chunk.decode(charset or "utf-8", errors="ignore"))
else:
parts.append(str(chunk))
return "".join(parts).strip()
def extract_text_part(message):
if message.is_multipart():
for part in message.walk():
if part.get_content_maintype() == "multipart":
continue
if "attachment" in str(part.get("Content-Disposition") or "").lower():
continue
payload = part.get_payload(decode=True) or b""
charset = part.get_content_charset() or "utf-8"
text = payload.decode(charset, errors="ignore").strip()
if part.get_content_type() == "text/plain" and text:
return text
if part.get_content_type() == "text/html" and text:
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
return ""
payload = message.get_payload(decode=True) or b""
charset = message.get_content_charset() or "utf-8"
text = payload.decode(charset, errors="ignore").strip()
if message.get_content_type() == "text/html":
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
return text
def mailbox_candidates(mailbox):
normalized = str(mailbox or "INBOX").strip().lower()
if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
return ["Junk", "Junk Email", "Junk E-Mail"]
return ["INBOX"]
def normalize_mailbox_label(mailbox):
normalized = str(mailbox or "INBOX").strip().lower()
if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
return "Junk"
return "INBOX"
def normalize_mailbox_id(mailbox):
normalized = str(mailbox or "INBOX").strip().lower()
if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
return "junkemail"
return "inbox"
def select_mailbox(client, mailbox):
for candidate in mailbox_candidates(mailbox):
status, _ = client.select(candidate)
if status == "OK":
return candidate
raise RuntimeError(f"Mailbox not found: {mailbox}")
def to_timestamp_ms(raw_date):
if not raw_date:
return 0
try:
parsed = parsedate_to_datetime(raw_date)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return int(parsed.timestamp() * 1000)
except Exception:
return 0
def to_iso_string(timestamp_ms):
if not timestamp_ms:
return ""
return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
def normalize_message(message_id, raw_bytes, mailbox):
parsed = email.message_from_bytes(raw_bytes)
sender_name, sender_addr = parseaddr(parsed.get("From", ""))
subject = decode_mime_header(parsed.get("Subject", ""))
body = extract_text_part(parsed)
timestamp_ms = to_timestamp_ms(parsed.get("Date"))
return {
"id": str(message_id),
"mailbox": mailbox,
"subject": subject,
"from": {
"emailAddress": {
"address": sender_addr.strip(),
"name": sender_name.strip(),
}
},
"bodyPreview": body[:500],
"body": {
"content": body,
},
"receivedDateTime": to_iso_string(timestamp_ms),
"receivedTimestamp": timestamp_ms,
}
def fetch_messages(email_addr, access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
client = None
logical_mailbox = normalize_mailbox_label(mailbox)
try:
client = open_mailbox(email_addr, access_token)
select_mailbox(client, mailbox)
status, data = client.search(None, "ALL")
if status != "OK" or not data or not data[0]:
return {"mailbox": logical_mailbox, "messages": [], "count": 0}
message_ids = data[0].split()
selected_ids = list(reversed(message_ids[-max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)):]))
messages = []
for message_id in selected_ids:
fetch_status, fetch_data = client.fetch(message_id, "(RFC822)")
if fetch_status != "OK" or not fetch_data:
continue
raw_bytes = b""
for item in fetch_data:
if isinstance(item, tuple) and len(item) >= 2:
raw_bytes = item[1]
break
if not raw_bytes:
continue
messages.append(normalize_message(message_id.decode("utf-8", errors="ignore"), raw_bytes, logical_mailbox))
return {"mailbox": logical_mailbox, "messages": messages, "count": len(messages)}
finally:
if client is not None:
try:
client.logout()
except Exception:
pass
def fetch_messages_for_mailboxes(email_addr, access_token, mailboxes, top):
mailbox_results = []
all_messages = []
for mailbox in mailboxes or ["INBOX"]:
result = fetch_messages(email_addr, access_token, mailbox=mailbox, top=top)
mailbox_results.append(result)
all_messages.extend(result["messages"])
all_messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
return {"mailboxResults": mailbox_results, "messages": all_messages}
def normalize_graph_message(message, mailbox):
sender = message.get("from", {}) or {}
email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
received = str(message.get("receivedDateTime") or "").strip()
return {
"id": str(message.get("id") or message.get("internetMessageId") or "").strip(),
"mailbox": mailbox,
"subject": str(message.get("subject") or "").strip(),
"from": {
"emailAddress": {
"address": str(email_addr.get("address") or "").strip(),
"name": str(email_addr.get("name") or "").strip(),
}
},
"bodyPreview": str(message.get("bodyPreview") or "").strip(),
"receivedDateTime": received,
"receivedTimestamp": int(datetime.fromisoformat(received.replace("Z", "+00:00")).timestamp() * 1000) if received else 0,
}
def normalize_outlook_message(message, mailbox):
sender = message.get("From", {}) or message.get("from", {}) or {}
email_addr = sender.get("EmailAddress", {}) if isinstance(sender, dict) else {}
if isinstance(sender, dict) and not email_addr:
email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
received = str(message.get("ReceivedDateTime") or message.get("receivedDateTime") or "").strip()
return {
"id": str(message.get("Id") or message.get("id") or "").strip(),
"mailbox": mailbox,
"subject": str(message.get("Subject") or message.get("subject") or "").strip(),
"from": {
"emailAddress": {
"address": str(email_addr.get("Address") or email_addr.get("address") or "").strip(),
"name": str(email_addr.get("Name") or email_addr.get("name") or "").strip(),
}
},
"bodyPreview": str(message.get("BodyPreview") or message.get("bodyPreview") or "").strip(),
"receivedDateTime": received,
"receivedTimestamp": int(datetime.fromisoformat(received.replace("Z", "+00:00")).timestamp() * 1000) if received else 0,
}
def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
mailbox_id = normalize_mailbox_id(mailbox)
query = urlencode({
"$top": max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)),
"$select": "id,internetMessageId,subject,from,bodyPreview,receivedDateTime",
"$orderby": "receivedDateTime desc",
})
url = f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages?{query}"
try:
_, payload = get_json(url, headers={
"Accept": "application/json",
"Authorization": f"Bearer {access_token}",
})
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"Graph request failed: {detail or exc}") from exc
except URLError as exc:
raise RuntimeError(f"Graph request failed: {exc}") from exc
messages = [normalize_graph_message(item, normalize_mailbox_label(mailbox)) for item in (payload.get("value") or [])]
return {"mailbox": normalize_mailbox_label(mailbox), "messages": messages, "count": len(messages)}
def fetch_outlook_api_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
mailbox_id = normalize_mailbox_id(mailbox)
query = urlencode({
"$top": max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)),
"$select": "Id,Subject,From,BodyPreview,ReceivedDateTime",
"$orderby": "ReceivedDateTime desc",
})
url = f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages?{query}"
try:
_, payload = get_json(url, headers={
"Accept": "application/json",
"Authorization": f"Bearer {access_token}",
})
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"Outlook API request failed: {detail or exc}") from exc
except URLError as exc:
raise RuntimeError(f"Outlook API request failed: {exc}") from exc
messages = [normalize_outlook_message(item, normalize_mailbox_label(mailbox)) for item in (payload.get("value") or [])]
return {"mailbox": normalize_mailbox_label(mailbox), "messages": messages, "count": len(messages)}
def collect_imap_messages(email_addr, client_id, refresh_token, mailboxes, top):
token_payload = refresh_access_token(client_id, refresh_token, [
"live",
"entra-consumers-delegated",
"entra-common-delegated",
])
result = fetch_messages_for_mailboxes(email_addr, token_payload["access_token"], mailboxes, top)
result["transport"] = "imap"
result["token_payload"] = token_payload
return result
def collect_graph_messages(email_addr, client_id, refresh_token, mailboxes, top):
token_payload = refresh_access_token(client_id, refresh_token, [
"entra-common-delegated",
"entra-consumers-delegated",
"entra-common-default",
])
mailbox_results = [fetch_graph_messages(token_payload["access_token"], mailbox=mailbox, top=top) for mailbox in mailboxes]
messages = []
for item in mailbox_results:
messages.extend(item["messages"])
messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
return {
"transport": "graph",
"token_payload": token_payload,
"mailboxResults": mailbox_results,
"messages": messages,
}
def collect_outlook_messages(email_addr, client_id, refresh_token, mailboxes, top):
token_payload = refresh_access_token(client_id, refresh_token, [
"entra-common-outlook",
"entra-common-delegated",
])
mailbox_results = [fetch_outlook_api_messages(token_payload["access_token"], mailbox=mailbox, top=top) for mailbox in mailboxes]
messages = []
for item in mailbox_results:
messages.extend(item["messages"])
messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
return {
"transport": "outlook",
"token_payload": token_payload,
"mailboxResults": mailbox_results,
"messages": messages,
}
def collect_messages(email_addr, client_id, refresh_token, mailboxes, top):
errors = []
collectors = [
("imap", collect_imap_messages),
("graph", collect_graph_messages),
("outlook", collect_outlook_messages),
]
for transport_name, collector in collectors:
try:
log_info(f"message collection start transport={transport_name}")
result = collector(email_addr, client_id, refresh_token, mailboxes, top)
log_info(
f"message collection success transport={transport_name} "
f"tokenEndpoint={result['token_payload'].get('token_endpoint', '')}"
)
return result
except Exception as exc:
message = compact_text(str(exc), 600)
errors.append(f"{transport_name}: {message}")
log_info(f"message collection failed transport={transport_name} detail={message}")
raise RuntimeError(f"Message collection failed on all transports: {' | '.join(errors)}")
def extract_code(text, code_patterns=None):
source = str(text or "")
for pattern in code_patterns or []:
try:
source_pattern = str((pattern or {}).get("source") or "").strip()
if not source_pattern:
continue
flags = str((pattern or {}).get("flags") or "").lower()
re_flags = 0
if "i" in flags:
re_flags |= re.IGNORECASE
if "m" in flags:
re_flags |= re.MULTILINE
if "s" in flags:
re_flags |= re.DOTALL
match = re.search(source_pattern, source, flags=re_flags)
if not match:
continue
if match.lastindex:
for group_index in range(1, match.lastindex + 1):
candidate = str(match.group(group_index) or "").strip()
if candidate:
return candidate
candidate = str(match.group(0) or "").strip()
if candidate:
return candidate
except re.error:
continue
patterns = [
r"(?:代码为|验证码[^0-9]*?)[\s:]*(\d{6})",
r"(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})",
r"code(?:\s+is|[\s:])+(\d{6})",
r"\b(\d{6})\b",
]
for pattern in patterns:
match = re.search(pattern, source, flags=re.IGNORECASE)
if match:
return match.group(1)
return ""
def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp, required_keywords=None, code_patterns=None):
sender_keywords = [str(item).strip().lower() for item in sender_filters or [] if str(item).strip()]
subject_keywords = [str(item).strip().lower() for item in subject_filters or [] if str(item).strip()]
required_keyword_hints = [str(item).strip().lower() for item in required_keywords or [] if str(item).strip()]
excluded = {str(item).strip() for item in exclude_codes or [] if str(item).strip()}
def match_message(message, apply_time_filter):
timestamp = int(message.get("receivedTimestamp") or 0)
if apply_time_filter and filter_after_timestamp and timestamp and timestamp < int(filter_after_timestamp):
return None
sender = str(message.get("from", {}).get("emailAddress", {}).get("address", "")).lower()
subject = str(message.get("subject", ""))
preview = str(message.get("bodyPreview", ""))
combined = " ".join([sender, subject.lower(), preview.lower()])
code = extract_code(" ".join([subject, preview, sender]), code_patterns=code_patterns)
if not code:
body_content = get_message_body_content(message)
if body_content:
code = extract_code(" ".join([subject, body_content, sender]), code_patterns=code_patterns)
if not code or code in excluded:
return None
sender_ok = bool(sender_keywords) and any(keyword in combined for keyword in sender_keywords)
subject_ok = bool(subject_keywords) and any(keyword in combined for keyword in subject_keywords)
keyword_ok = bool(required_keyword_hints) and any(keyword in combined for keyword in required_keyword_hints)
if (sender_keywords or subject_keywords or required_keyword_hints) and not sender_ok and not subject_ok and not keyword_ok:
return None
return {"code": code, "message": message}
for use_time_fallback in [False, True]:
matched = []
for message in messages:
result = match_message(message, apply_time_filter=not use_time_fallback)
if result:
matched.append(result)
if matched:
matched.sort(key=lambda item: int(item["message"].get("receivedTimestamp") or 0), reverse=True)
best = matched[0]
return {
"code": best["code"],
"message": best["message"],
"usedTimeFallback": use_time_fallback,
}
return {"code": "", "message": None, "usedTimeFallback": False}
class HotmailHelperHandler(BaseHTTPRequestHandler):
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.end_headers()
def do_POST(self):
try:
payload = read_json_payload(self)
request_path = urlparse(self.path).path
if request_path == "/sync-account-run-records":
file_path = sync_account_run_records(payload)
json_response(self, 200, {
"ok": True,
"filePath": file_path,
})
return
if request_path == "/append-account-log":
file_path = append_account_log(
payload.get("email"),
payload.get("password"),
payload.get("status"),
payload.get("recordedAt"),
payload.get("reason"),
)
json_response(self, 200, {
"ok": True,
"filePath": file_path,
})
return
if request_path == "/save-auth-json":
file_path = save_local_cpa_json(
payload.get("filePath"),
payload.get("content"),
payload.get("directoryPath"),
)
json_response(self, 200, {
"ok": True,
"filePath": file_path,
})
return
email_addr = str(payload.get("email") or "").strip()
client_id = str(payload.get("clientId") or "").strip()
refresh_token = str(payload.get("refreshToken") or "").strip()
if not email_addr or not client_id or not refresh_token:
raise RuntimeError("Missing email/clientId/refreshToken")
top = max(1, min(int(payload.get("top") or FETCH_LIMIT_DEFAULT), 30))
mailboxes = payload.get("mailboxes") if isinstance(payload.get("mailboxes"), list) else [payload.get("mailbox") or "INBOX"]
if request_path == "/messages":
result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
json_response(self, 200, {
"ok": True,
"messages": result["messages"],
"mailboxResults": result["mailboxResults"],
"nextRefreshToken": result["token_payload"].get("next_refresh_token") or "",
"tokenEndpoint": result["token_payload"].get("token_endpoint") or "",
"transport": result.get("transport") or "",
})
return
if request_path == "/code":
result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
selected = select_latest_code(
result["messages"],
payload.get("senderFilters") or [],
payload.get("subjectFilters") or [],
payload.get("excludeCodes") or [],
int(payload.get("filterAfterTimestamp") or 0),
payload.get("requiredKeywords") or [],
payload.get("codePatterns") or [],
)
json_response(self, 200, {
"ok": True,
"code": selected["code"],
"message": selected["message"],
"usedTimeFallback": selected["usedTimeFallback"],
"nextRefreshToken": result["token_payload"].get("next_refresh_token") or "",
"tokenEndpoint": result["token_payload"].get("token_endpoint") or "",
"transport": result.get("transport") or "",
})
return
json_response(self, 404, {"ok": False, "error": f"Unsupported path: {self.path}"})
except Exception as exc:
traceback.print_exc()
json_response(self, 500, {"ok": False, "error": str(exc)})
def main(argv=None):
config = resolve_server_config(argv)
host = config["host"]
port = config["port"]
server = ThreadingHTTPServer((host, port), HotmailHelperHandler)
print(f"Hotmail helper listening on http://{host}:{port}", flush=True)
print(f"Account log file: {ACCOUNT_LOG_PATH}", flush=True)
print(f"Account snapshot file: {ACCOUNT_RECORDS_SNAPSHOT_PATH}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()