"""Centralized OWA HTTP client for Exchange API. Extracts and unifies the duplicated request/cookie/session patterns from all standalone scripts into a single reusable client. Uses the "x-owa-urlpostdata" request pattern: JSON payload is URL-encoded and sent in the ``x-owa-urlpostdata`` header with an empty POST body, mirroring what the OWA web frontend sends. """ import json import os import time from urllib.parse import quote, urlparse from pathlib import Path import requests class SessionExpiredError(Exception): """Raised when the OWA session has expired (HTTP 401/440 or HTML redirect).""" pass # Map common folder names (English + Russian) to OWA distinguished folder IDs DISTINGUISHED_FOLDERS = { "inbox": "inbox", "входящие": "inbox", "sent": "sentitems", "отправленные": "sentitems", "drafts": "drafts", "черновики": "drafts", "deleted": "deleteditems", "удаленные": "deleteditems", "junk": "junkemail", "нежелательная почта": "junkemail", "outbox": "outbox", "исходящие": "outbox", "calendar": "calendar", "календарь": "calendar", } class OWAClient: """HTTP client for OWA (Outlook Web Access) JSON API. Handles cookie loading, CSRF token extraction, request construction, session expiry detection, and automatic cookie reload on first failure. """ def __init__( self, cookie_file: str | None = None, owa_url: str | None = None, ): self.cookie_file = Path( cookie_file or os.environ.get("EXCHANGE_COOKIE_FILE", "") or str(Path(__file__).parent.parent / "session-cookies.txt") ) self.owa_url = (owa_url or "").rstrip("/") if not self.owa_url: raise ValueError( "OWA URL must be provided via --owa-url or owa_url=…" ) self._session = requests.Session() self._loaded = False self.user_email: str = "" # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @staticmethod def _action_name(action: str) -> str: """Convert e.g. 'GetFolder' → 'GetFolderAction'.""" return action + "Action" def _fresh_headers(self, action: str) -> dict: """Build the full set of OWA headers required by Exchange 2013. Mirrors the headers the OWA web frontend sends for every POST. """ now_ms = int(time.time() * 1000) client_req_id = f"{self._client_id}_{now_ms}" now_str = time.strftime( "%Y-%m-%dT%H:%M:%S.", time.gmtime(now_ms / 1000) ) + f"{now_ms % 1000:03d}" return { # Basic "accept": "*/*", "accept-language": "en-US,en;q=0.9,ru;q=0.8", "cache-control": "no-cache", "client-request-id": client_req_id, "content-length": "0", "content-type": "application/json; charset=UTF-8", "origin": self.owa_url, "pragma": "no-cache", "priority": "u=1, i", "user-agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/131.0.0.0 Safari/537.36" ), # OWA-specific "action": action, "sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not)A;Brand";v="24"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "x-owa-actionid": "-1", "x-owa-actionname": self._action_name(action), "x-owa-attempt": "1", "x-owa-clientbegin": now_str, "x-owa-clientbuildversion": "15.2.1748.39", "x-owa-correlationid": client_req_id, "x-requested-with": "XMLHttpRequest", } def _add_canary_header(self, headers: dict) -> dict: """Extract X-OWA-CANARY from session cookies and add to headers.""" canary = next( (c.value for c in self._session.cookies if c.name == "X-OWA-CANARY"), "" ) if canary: headers["x-owa-canary"] = canary return headers @property def _client_id(self) -> str: """Extract ClientId from session cookies.""" cid = next( (c.value for c in self._session.cookies if c.name == "ClientId"), "" ) return cid if cid else "00000000000000000000000000000000" # ------------------------------------------------------------------ # Cookie / session helpers # ------------------------------------------------------------------ def _load_cookies(self) -> None: """Read session-cookies.txt (name=value per line) and load into session.""" if not self.cookie_file.exists(): raise SessionExpiredError( f"Cookie file not found: {self.cookie_file}. Use the login MCP tool to authenticate." ) raw = self.cookie_file.read_text().strip() cookies: dict[str, str] = {} for line in raw.split("\n"): if "=" in line: name, value = line.split("=", 1) cookies[name] = value if not cookies: raise SessionExpiredError("Cookie file is empty. Use the login MCP tool to authenticate.") self._session = requests.Session() self._session.cookies.update(cookies) self._loaded = True def _save_cookies(self) -> None: """Write the current session cookie jar to the cookie file. Called automatically after every successful request so that the on-disk cookies always reflect the freshest ``Set-Cookie`` values returned by the server. """ lines = [f"{c.name}={c.value}" for c in self._session.cookies] self.cookie_file.write_text("\n".join(lines)) self._loaded = True def _ensure_loaded(self) -> None: """Load cookies on first use and refresh session from the main page.""" if not self._loaded: self._load_cookies() self._refresh_session() def load_cookies_from_string(self, cookies_str: str) -> None: """Load cookies from a name=value string (one per line) into memory.""" cookies: dict[str, str] = {} for line in cookies_str.strip().split("\n"): if "=" in line: name, value = line.split("=", 1) cookies[name] = value if not cookies: raise SessionExpiredError("No cookies in provided data.") self._session = requests.Session() self._session.cookies.update(cookies) self._loaded = True # ------------------------------------------------------------------ # Core request method # ------------------------------------------------------------------ def request(self, action: str, payload: dict, *, timeout: int = 30) -> dict: """POST to /owa/service.svc?action={action}&EP=1&ID=-1&AC=1. On session expiry (401, 440, or text/html response), reloads cookies once and retries. If that also fails, raises SessionExpiredError. Returns the parsed JSON response dict. """ self._ensure_loaded() try: data = self._do_request(action, payload, timeout=timeout) return data except SessionExpiredError: raise def _refresh_session(self) -> None: """Hit the main OWA page to refresh session cookies (e.g. X-OWA-CANARY).""" try: resp = self._session.get( f"{self.owa_url}/owa/", headers={ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/131.0.0.0 Safari/537.36" ) }, timeout=10, ) # Extract X-OWA-CANARY from Set-Cookie if present set_cookie = resp.headers.get("Set-Cookie", "") if "X-OWA-CANARY=" in set_cookie: import re m = re.search(r'X-OWA-CANARY=([^;]+)', set_cookie) if m: host = urlparse(self.owa_url).hostname or "owa.b1.ru" self._session.cookies.set( "X-OWA-CANARY", m.group(1), domain=host ) # Persist any cookie changes self._save_cookies() except Exception: pass # non-critical def _do_request(self, action: str, payload: dict, *, timeout: int = 30) -> dict: """Execute a single OWA API request. Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload is URL-encoded and placed in the ``x-owa-urlpostdata`` header with an empty POST body, plus a full set of OWA-specific headers extracted from the browser's actual network traffic. """ from urllib.parse import urlparse url = f"{self.owa_url}/owa/service.svc?action={action}&EP=1&ID=-1&AC=1" # URL-encode the JSON payload for the x-owa-urlpostdata header url_post_data = quote(json.dumps(payload, separators=(",", ":"))) # Build headers — start with OWA-required headers headers = self._fresh_headers(action) headers["x-owa-urlpostdata"] = url_post_data # Add canary from cookies headers = self._add_canary_header(headers) # Add extra cookies that the OWA frontend always sends host = urlparse(self.owa_url).hostname or "owa.b1.ru" for name, val in [("PrivateComputer", "true"), ("PBack", "0")]: self._session.cookies.set(name, val, domain=host) try: resp = self._session.post(url, data="", headers=headers, timeout=timeout) except requests.exceptions.RequestException as exc: raise SessionExpiredError(f"Request failed: {exc}") from exc # Detect session expiry if resp.status_code in (401, 440): raise SessionExpiredError("Session expired (HTTP {}).".format(resp.status_code)) if "text/html" in resp.headers.get("Content-Type", ""): body_snippet = resp.text[:300] if resp.text else "" raise SessionExpiredError( f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). " f"Snippet: {body_snippet}" ) # Parse JSON try: data = resp.json() except (ValueError, requests.exceptions.JSONDecodeError) as exc: raise SessionExpiredError( f"Unexpected response (HTTP {resp.status_code}). " "Session may have expired." ) from exc # Persist any Set-Cookie updates back to disk so the next request # (or a server restart) picks up the freshest session tokens. self._save_cookies() return data def request_header_payload( self, action: str, payload: dict, *, timeout: int = 30 ) -> dict: """POST with payload in x-owa-urlpostdata header (empty body). Alias for :meth:`request` — both methods now use the same ``x-owa-urlpostdata`` format that mirrors the OWA web frontend. """ return self.request(action, payload, timeout=timeout) # ------------------------------------------------------------------ # File download (attachments) # ------------------------------------------------------------------ def download_file( self, attachment_id: str, *, timeout: int = 60 ) -> tuple[bytes, str, str]: """Download a file attachment by its AttachmentId. Uses the OWA GetFileAttachment endpoint (direct GET). Returns: (content_bytes, filename, content_type) """ self._ensure_loaded() from urllib.parse import quote url = ( f"{self.owa_url}/owa/service.svc/s/GetFileAttachment" f"?id={quote(attachment_id)}" ) try: resp = self._session.get(url, timeout=timeout) except requests.exceptions.RequestException as exc: raise SessionExpiredError(f"Download failed: {exc}") from exc if resp.status_code in (401, 440): raise SessionExpiredError( f"Session expired (HTTP {resp.status_code})." ) if "text/html" in resp.headers.get("Content-Type", ""): raise SessionExpiredError( "Session expired (HTML response on attachment download)." ) # Persist fresh cookies from the download response self._save_cookies() # Parse filename from Content-Disposition header filename = "attachment" cd = resp.headers.get("Content-Disposition", "") if cd: import re as _re from urllib.parse import unquote # Try filename*= (RFC 5987) first, then filename= match = _re.search(r"filename\*=(?:UTF-8''|utf-8'')(.+?)(?:;|$)", cd) if match: filename = unquote(match.group(1).strip()) else: match = _re.search(r'filename="?([^";]+)"?', cd) if match: filename = unquote(match.group(1).strip()) content_type = resp.headers.get("Content-Type", "application/octet-stream") return resp.content, filename, content_type # ------------------------------------------------------------------ # Convenience: extract response items # ------------------------------------------------------------------ @staticmethod def extract_items(data: dict) -> list[dict]: """Extract Items from standard OWA response envelope. Response shape: data["Body"]["ResponseMessages"]["Items"] """ try: return data["Body"]["ResponseMessages"]["Items"] except (KeyError, TypeError): return [] # ------------------------------------------------------------------ # Folder helpers # ------------------------------------------------------------------ def get_folder_id(self, folder_name: str) -> str | None: """Resolve a folder name to its Exchange folder ID. Supports distinguished folder names (inbox, sentitems, drafts, etc.) in both English and Russian, plus custom folder names looked up via FindFolder on msgfolderroot. """ folder_lower = folder_name.lower() # Check distinguished folders first distinguished_id = DISTINGUISHED_FOLDERS.get(folder_lower) if distinguished_id: payload = { "__type": "GetFolderJsonRequest:#Exchange", "Header": { "__type": "JsonRequestHeaders:#Exchange", "RequestServerVersion": "Exchange2013", }, "Body": { "__type": "GetFolderRequest:#Exchange", "FolderShape": { "__type": "FolderResponseShape:#Exchange", "BaseShape": "IdOnly", }, "FolderIds": [ { "__type": "DistinguishedFolderId:#Exchange", "Id": distinguished_id, } ], }, } data = self.request("GetFolder", payload) for msg in self.extract_items(data): if "Folders" in msg: for f in msg["Folders"]: fid = f.get("FolderId", {}).get("Id") if fid: return fid # Fall back to searching custom folders by name payload = { "__type": "FindFolderJsonRequest:#Exchange", "Header": { "__type": "JsonRequestHeaders:#Exchange", "RequestServerVersion": "Exchange2013", }, "Body": { "__type": "FindFolderRequest:#Exchange", "FolderShape": { "__type": "FolderResponseShape:#Exchange", "BaseShape": "Default", }, "ParentFolderIds": [ { "__type": "DistinguishedFolderId:#Exchange", "Id": "msgfolderroot", } ], "Traversal": "Shallow", "Paging": { "__type": "IndexedPageView:#Exchange", "BasePoint": "Beginning", "Offset": 0, "MaxEntriesReturned": 200, }, }, } data = self.request("FindFolder", payload) for msg in self.extract_items(data): if "RootFolder" in msg and "Folders" in msg["RootFolder"]: for f in msg["RootFolder"]["Folders"]: if f.get("DisplayName", "").lower() == folder_lower: return f.get("FolderId", {}).get("Id") return None # ------------------------------------------------------------------ # ResolveNames (directory search / attendee resolution) # ------------------------------------------------------------------ def resolve_names( self, query: str, *, full_contact: bool = True ) -> list[dict]: """Call ResolveNames to search the directory. Returns the list of Resolution dicts from the API, each containing Mailbox and optionally Contact data. """ payload = { "__type": "ResolveNamesJsonRequest:#Exchange", "Header": { "__type": "JsonRequestHeaders:#Exchange", "RequestServerVersion": "Exchange2013", }, "Body": { "__type": "ResolveNamesRequest:#Exchange", "UnresolvedEntry": query, "ReturnFullContactData": full_contact, "SearchScope": "ActiveDirectoryContacts", "ContactDataShape": "AllProperties" if full_contact else "Default", }, } data = self.request("ResolveNames", payload) for msg in self.extract_items(data): if "ResolutionSet" in msg and "Resolutions" in msg["ResolutionSet"]: return msg["ResolutionSet"]["Resolutions"] return []