w
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
"""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 []
|
||||
Reference in New Issue
Block a user