Files
owa-mcp/owa_mcp/owa_client.py
T
2026-07-06 01:58:46 +03:00

501 lines
18 KiB
Python

"""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 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 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 []