Spaces:
Running
Running
| from __future__ import annotations | |
| import argparse | |
| import imaplib | |
| import json | |
| import re | |
| import sys | |
| import urllib.error | |
| import urllib.parse | |
| import urllib.request | |
| from dataclasses import dataclass | |
| from email import message_from_bytes, policy | |
| from email.header import decode_header, make_header | |
| from email.message import Message | |
| from email.utils import parsedate_to_datetime | |
| from pathlib import Path | |
| from typing import Any | |
| TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" | |
| GRAPH_MESSAGES_URL = "https://graph.microsoft.com/v1.0/me/messages" | |
| GRAPH_SCOPE = "offline_access https://graph.microsoft.com/Mail.Read" | |
| IMAP_SCOPE = "offline_access https://outlook.office.com/IMAP.AccessAsUser.All" | |
| DEFAULT_IMAP_HOST = "outlook.office365.com" | |
| class OutlookCredential: | |
| email: str | |
| password: str | |
| client_id: str | |
| refresh_token: str | |
| line_number: int | |
| class HttpResponse: | |
| status_code: int | |
| text: str | |
| def json(self) -> Any: | |
| return json.loads(self.text) | |
| def _clean(value: str) -> str: | |
| return value.replace("\ufeff", "").replace("\u00a0", " ").strip() | |
| def _redact_email(email: str) -> str: | |
| local, sep, domain = email.partition("@") | |
| if not sep: | |
| return "***" | |
| if len(local) <= 2: | |
| masked = local[:1] + "***" | |
| else: | |
| masked = local[:2] + "***" + local[-1:] | |
| return f"{masked}@{domain}" | |
| def parse_credentials(path: Path) -> list[OutlookCredential]: | |
| credentials: list[OutlookCredential] = [] | |
| for line_number, raw_line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), start=1): | |
| line = _clean(raw_line) | |
| if not line or "----" not in line: | |
| continue | |
| parts = [_clean(part) for part in line.split("----", 3)] | |
| if len(parts) != 4: | |
| continue | |
| email, password, client_id, refresh_token = parts | |
| if "@" not in email or not client_id or not refresh_token: | |
| continue | |
| credentials.append( | |
| OutlookCredential( | |
| email=email, | |
| password=password, | |
| client_id=client_id, | |
| refresh_token=refresh_token, | |
| line_number=line_number, | |
| ) | |
| ) | |
| return credentials | |
| def _http_request( | |
| method: str, | |
| url: str, | |
| *, | |
| headers: dict[str, str] | None = None, | |
| data: dict[str, str] | None = None, | |
| params: dict[str, Any] | None = None, | |
| timeout: float = 30, | |
| ) -> HttpResponse: | |
| target = url | |
| if params: | |
| query = urllib.parse.urlencode(params) | |
| target = f"{url}?{query}" | |
| body: bytes | None = None | |
| request_headers = dict(headers or {}) | |
| if data is not None: | |
| body = urllib.parse.urlencode(data).encode("utf-8") | |
| request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded") | |
| request = urllib.request.Request(target, data=body, headers=request_headers, method=method.upper()) | |
| try: | |
| with urllib.request.urlopen(request, timeout=timeout) as response: | |
| return HttpResponse( | |
| status_code=int(response.status), | |
| text=response.read().decode("utf-8", errors="replace"), | |
| ) | |
| except urllib.error.HTTPError as error: | |
| return HttpResponse( | |
| status_code=int(error.code), | |
| text=error.read().decode("utf-8", errors="replace"), | |
| ) | |
| def exchange_refresh_token(credential: OutlookCredential, scope: str, timeout: float) -> str: | |
| response = _http_request( | |
| "POST", | |
| TOKEN_URL, | |
| data={ | |
| "client_id": credential.client_id, | |
| "grant_type": "refresh_token", | |
| "refresh_token": credential.refresh_token, | |
| "scope": scope, | |
| }, | |
| timeout=timeout, | |
| ) | |
| try: | |
| data = response.json() | |
| except Exception: | |
| data = {} | |
| if response.status_code != 200: | |
| detail = data.get("error_description") or data.get("error") or response.text[:300] | |
| raise RuntimeError(f"token refresh failed: HTTP {response.status_code}, {detail}") | |
| access_token = str(data.get("access_token") or "").strip() | |
| if not access_token: | |
| raise RuntimeError("token refresh response did not include access_token") | |
| return access_token | |
| def _graph_sender(message: dict[str, Any]) -> str: | |
| sender = message.get("from") or {} | |
| if isinstance(sender, dict): | |
| address = sender.get("emailAddress") or {} | |
| if isinstance(address, dict): | |
| return str(address.get("address") or address.get("name") or "") | |
| return "" | |
| def read_graph_messages(access_token: str, limit: int, timeout: float) -> list[dict[str, str]]: | |
| response = _http_request( | |
| "GET", | |
| GRAPH_MESSAGES_URL, | |
| headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"}, | |
| params={ | |
| "$top": max(1, min(limit, 50)), | |
| "$orderby": "receivedDateTime desc", | |
| "$select": "subject,receivedDateTime,from,bodyPreview", | |
| }, | |
| timeout=timeout, | |
| ) | |
| try: | |
| data = response.json() | |
| except Exception: | |
| data = {} | |
| if response.status_code != 200: | |
| detail = data.get("error", {}).get("message") if isinstance(data.get("error"), dict) else response.text[:300] | |
| raise RuntimeError(f"graph messages failed: HTTP {response.status_code}, {detail}") | |
| items = data.get("value") if isinstance(data, dict) else None | |
| if not isinstance(items, list): | |
| raise RuntimeError("graph messages response did not include value[]") | |
| return [ | |
| { | |
| "received": str(item.get("receivedDateTime") or ""), | |
| "from": _graph_sender(item), | |
| "subject": str(item.get("subject") or ""), | |
| "preview": str(item.get("bodyPreview") or ""), | |
| } | |
| for item in items | |
| if isinstance(item, dict) | |
| ] | |
| def _decode_header(value: str | None) -> str: | |
| if not value: | |
| return "" | |
| try: | |
| return str(make_header(decode_header(value))) | |
| except Exception: | |
| return value | |
| def _message_text_preview(message: Message, limit: int = 240) -> str: | |
| text_parts: list[str] = [] | |
| parts = message.walk() if message.is_multipart() else [message] | |
| for part in parts: | |
| if part.get_content_maintype() == "multipart": | |
| continue | |
| content_type = part.get_content_type() | |
| if content_type not in {"text/plain", "text/html"}: | |
| continue | |
| try: | |
| payload = part.get_content() | |
| except Exception: | |
| continue | |
| if payload: | |
| text_parts.append(str(payload)) | |
| if content_type == "text/plain" and text_parts: | |
| break | |
| text = "\n".join(text_parts) | |
| text = re.sub(r"<[^>]+>", " ", text) | |
| text = re.sub(r"\s+", " ", text).strip() | |
| return text[:limit] | |
| def _parse_imap_message(raw: bytes, include_preview: bool) -> dict[str, str]: | |
| message = message_from_bytes(raw, policy=policy.default) | |
| received = "" | |
| try: | |
| parsed = parsedate_to_datetime(str(message.get("Date") or "")) | |
| received = parsed.isoformat() | |
| except Exception: | |
| received = str(message.get("Date") or "") | |
| return { | |
| "received": received, | |
| "from": _decode_header(str(message.get("From") or "")), | |
| "subject": _decode_header(str(message.get("Subject") or "")), | |
| "preview": _message_text_preview(message) if include_preview else "", | |
| } | |
| def read_imap_messages(access_token: str, email: str, host: str, limit: int, include_preview: bool) -> list[dict[str, str]]: | |
| auth_string = f"user={email}\x01auth=Bearer {access_token}\x01\x01" | |
| mailbox = imaplib.IMAP4_SSL(host) | |
| try: | |
| mailbox.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8")) | |
| status, _ = mailbox.select("INBOX", readonly=True) | |
| if status != "OK": | |
| raise RuntimeError("imap select INBOX failed") | |
| status, data = mailbox.uid("search", None, "ALL") | |
| if status != "OK" or not data or not data[0]: | |
| return [] | |
| uids = data[0].split()[-max(1, limit) :] | |
| messages: list[dict[str, str]] = [] | |
| for uid in reversed(uids): | |
| status, fetched = mailbox.uid("fetch", uid, "(RFC822)") | |
| if status != "OK": | |
| continue | |
| raw_payload = next((item[1] for item in fetched if isinstance(item, tuple) and isinstance(item[1], bytes)), b"") | |
| if raw_payload: | |
| messages.append(_parse_imap_message(raw_payload, include_preview)) | |
| return messages | |
| finally: | |
| try: | |
| mailbox.logout() | |
| except Exception: | |
| pass | |
| def print_messages(messages: list[dict[str, str]], include_preview: bool) -> None: | |
| if not messages: | |
| print(" no recent messages") | |
| return | |
| for index, message in enumerate(messages, start=1): | |
| print(f" {index}. {message['received']} | {message['from']} | {message['subject']}") | |
| if include_preview and message.get("preview"): | |
| print(f" preview: {message['preview']}") | |
| def test_credential( | |
| credential: OutlookCredential, | |
| mode: str, | |
| limit: int, | |
| timeout: float, | |
| imap_host: str, | |
| include_preview: bool, | |
| show_email: bool, | |
| ) -> bool: | |
| label = credential.email if show_email else _redact_email(credential.email) | |
| print(f"[line {credential.line_number}] {label}") | |
| errors: list[str] = [] | |
| if mode in {"graph", "auto"}: | |
| try: | |
| access_token = exchange_refresh_token(credential, GRAPH_SCOPE, timeout) | |
| messages = read_graph_messages(access_token, limit, timeout) | |
| print(" graph: ok") | |
| print_messages(messages, include_preview) | |
| return True | |
| except Exception as error: | |
| errors.append(f"graph: {error}") | |
| if mode == "graph": | |
| print(f" graph: failed - {error}") | |
| return False | |
| if mode in {"imap", "auto"}: | |
| try: | |
| access_token = exchange_refresh_token(credential, IMAP_SCOPE, timeout) | |
| messages = read_imap_messages(access_token, credential.email, imap_host, limit, include_preview) | |
| print(" imap: ok") | |
| print_messages(messages, include_preview) | |
| return True | |
| except Exception as error: | |
| errors.append(f"imap: {error}") | |
| if mode == "imap": | |
| print(f" imap: failed - {error}") | |
| return False | |
| print(" failed") | |
| for error in errors: | |
| print(f" - {error}") | |
| return False | |
| def build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser( | |
| description="Test Outlook/Hotmail mailbox access from lines formatted as email----password----client_id----refresh_token.", | |
| ) | |
| parser.add_argument("--file", default=r"D:\Desktop\yx.txt", help="Credential text file path.") | |
| parser.add_argument("--mode", choices=("auto", "graph", "imap"), default="auto", help="Mailbox read method.") | |
| parser.add_argument("--limit-accounts", type=int, default=1, help="How many accounts to test.") | |
| parser.add_argument("--message-limit", type=int, default=5, help="How many recent messages to list per account.") | |
| parser.add_argument("--timeout", type=float, default=30, help="HTTP request timeout in seconds.") | |
| parser.add_argument("--imap-host", default=DEFAULT_IMAP_HOST, help="IMAP host for XOAUTH2 mode.") | |
| parser.add_argument("--preview", action="store_true", help="Print body preview/snippet. Disabled by default.") | |
| parser.add_argument("--show-email", action="store_true", help="Print full email address. Secrets are never printed.") | |
| parser.add_argument("--json", action="store_true", help="Only parse the file and print non-secret account metadata as JSON.") | |
| return parser | |
| def main() -> int: | |
| args = build_parser().parse_args() | |
| path = Path(args.file) | |
| if not path.exists(): | |
| print(f"file not found: {path}", file=sys.stderr) | |
| return 2 | |
| credentials = parse_credentials(path) | |
| if not credentials: | |
| print(f"no valid credentials parsed from: {path}", file=sys.stderr) | |
| return 2 | |
| limit_accounts = max(1, int(args.limit_accounts or 1)) | |
| selected = credentials[:limit_accounts] | |
| if args.json: | |
| print( | |
| json.dumps( | |
| [ | |
| { | |
| "line": item.line_number, | |
| "email": item.email if args.show_email else _redact_email(item.email), | |
| "client_id": item.client_id, | |
| "has_password": bool(item.password), | |
| "has_refresh_token": bool(item.refresh_token), | |
| } | |
| for item in selected | |
| ], | |
| ensure_ascii=False, | |
| indent=2, | |
| ) | |
| ) | |
| return 0 | |
| print(f"parsed {len(credentials)} credential(s), testing {len(selected)}") | |
| success = 0 | |
| for credential in selected: | |
| if test_credential( | |
| credential=credential, | |
| mode=str(args.mode), | |
| limit=max(1, int(args.message_limit or 1)), | |
| timeout=max(1.0, float(args.timeout or 30)), | |
| imap_host=str(args.imap_host), | |
| include_preview=bool(args.preview), | |
| show_email=bool(args.show_email), | |
| ): | |
| success += 1 | |
| print(f"summary: {success}/{len(selected)} account(s) readable") | |
| return 0 if success == len(selected) else 1 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |