503 lines
18 KiB
Python
503 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 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 []
|