This commit is contained in:
2026-07-06 10:39:24 +03:00
parent f7619a31ec
commit 5ec3cb4ab4
13 changed files with 4425 additions and 4426 deletions
+397 -402
View File
@@ -17,480 +17,475 @@ import requests
class SessionExpiredError(Exception):
"""Raised when the OWA session has expired (HTTP 401/440 or HTML redirect)."""
"""Raised when the OWA session has expired (HTTP 401/440 or HTML redirect)."""
pass
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",
"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.
"""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.
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 __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 = ""
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
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@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"
@staticmethod
def _action_name(action: str) -> str:
"""Convert e.g. 'GetFolder''GetFolderAction'."""
return action + "Action"
# ------------------------------------------------------------------
# Cookie / session helpers
# ------------------------------------------------------------------
def _fresh_headers(self, action: str) -> dict:
"""Build the full set of OWA headers required by Exchange 2013.
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."
)
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",
}
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
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
if not cookies:
raise SessionExpiredError(
"Cookie file is empty. Use the login MCP tool to authenticate."
)
@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"
self._session = requests.Session()
self._session.cookies.update(cookies)
self._loaded = True
# ------------------------------------------------------------------
# Cookie / session helpers
# ------------------------------------------------------------------
def _save_cookies(self) -> None:
"""Write the current session cookie jar to the cookie file.
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."
)
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
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
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()
if not cookies:
raise SessionExpiredError(
"Cookie file is empty. Use the login MCP tool to authenticate."
)
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
self._session = requests.Session()
self._session.cookies.update(cookies)
self._loaded = True
if not cookies:
raise SessionExpiredError("No cookies in provided data.")
def _save_cookies(self) -> None:
"""Write the current session cookie jar to the cookie file.
self._session = requests.Session()
self._session.cookies.update(cookies)
self._loaded = True
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
# ------------------------------------------------------------------
# Core request method
# ------------------------------------------------------------------
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 request(self, action: str, payload: dict, *, timeout: int = 30) -> dict:
"""POST to /owa/service.svc?action={action}&EP=1&ID=-1&AC=1.
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
On session expiry (401, 440, or text/html response), reloads cookies
once and retries. If that also fails, raises SessionExpiredError.
if not cookies:
raise SessionExpiredError("No cookies in provided data.")
Returns the parsed JSON response dict.
"""
self._ensure_loaded()
self._session = requests.Session()
self._session.cookies.update(cookies)
self._loaded = True
try:
data = self._do_request(action, payload, timeout=timeout)
return data
except SessionExpiredError:
raise
# ------------------------------------------------------------------
# Core request method
# ------------------------------------------------------------------
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
def request(self, action: str, payload: dict, *, timeout: int = 30) -> dict:
"""POST to /owa/service.svc?action={action}&EP=1&ID=-1&AC=1.
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
On session expiry (401, 440, or text/html response), reloads cookies
once and retries. If that also fails, raises SessionExpiredError.
def _do_request(
self, action: str, payload: dict, *, timeout: int = 30
) -> dict:
"""Execute a single OWA API request.
Returns the parsed JSON response dict.
"""
self._ensure_loaded()
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
try:
data = self._do_request(action, payload, timeout=timeout)
return data
except SessionExpiredError:
raise
url = f"{self.owa_url}/owa/service.svc?action={action}&EP=1&ID=-1&AC=1"
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
# URL-encode the JSON payload for the x-owa-urlpostdata header
url_post_data = quote(json.dumps(payload, separators=(",", ":")))
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
# 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)
def _do_request(self, action: str, payload: dict, *, timeout: int = 30) -> dict:
"""Execute a single OWA API request.
# 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)
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
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
url = f"{self.owa_url}/owa/service.svc?action={action}&EP=1&ID=-1&AC=1"
# Detect session expiry
if resp.status_code in (401, 440):
raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).")
# URL-encode the JSON payload for the x-owa-urlpostdata header
url_post_data = quote(json.dumps(payload, separators=(",", ":")))
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}). Snippet: {body_snippet}"
)
# 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)
# 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
# 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)
# 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
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
def request_header_payload(
self, action: str, payload: dict, *, timeout: int = 30
) -> dict:
"""POST with payload in x-owa-urlpostdata header (empty body).
# Detect session expiry
if resp.status_code in (401, 440):
raise SessionExpiredError(
f"Session expired (HTTP {resp.status_code})."
)
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)
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}"
)
# ------------------------------------------------------------------
# File download (attachments)
# ------------------------------------------------------------------
# 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
def download_file(
self, attachment_id: str, *, timeout: int = 60
) -> tuple[bytes, str, str]:
"""Download a file attachment by its AttachmentId.
# 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
Uses the OWA GetFileAttachment endpoint (direct GET).
def request_header_payload(
self, action: str, payload: dict, *, timeout: int = 30
) -> dict:
"""POST with payload in x-owa-urlpostdata header (empty body).
Returns:
(content_bytes, filename, content_type)
"""
self._ensure_loaded()
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)
from urllib.parse import quote
# ------------------------------------------------------------------
# File download (attachments)
# ------------------------------------------------------------------
url = f"{self.owa_url}/owa/service.svc/s/GetFileAttachment?id={quote(attachment_id)}"
def download_file(
self, attachment_id: str, *, timeout: int = 60
) -> tuple[bytes, str, str]:
"""Download a file attachment by its AttachmentId.
try:
resp = self._session.get(url, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise SessionExpiredError(f"Download failed: {exc}") from exc
Uses the OWA GetFileAttachment endpoint (direct GET).
if resp.status_code in (401, 440):
raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).")
Returns:
(content_bytes, filename, content_type)
"""
self._ensure_loaded()
if "text/html" in resp.headers.get("Content-Type", ""):
raise SessionExpiredError(
"Session expired (HTML response on attachment download)."
)
from urllib.parse import quote
# Persist fresh cookies from the download response
self._save_cookies()
url = (
f"{self.owa_url}/owa/service.svc/s/GetFileAttachment"
f"?id={quote(attachment_id)}"
)
# 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:
resp = self._session.get(url, timeout=timeout)
except requests.exceptions.RequestException as exc:
raise SessionExpiredError(f"Download failed: {exc}") from exc
# 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())
if resp.status_code in (401, 440):
raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).")
content_type = resp.headers.get("Content-Type", "application/octet-stream")
if "text/html" in resp.headers.get("Content-Type", ""):
raise SessionExpiredError(
"Session expired (HTML response on attachment download)."
)
return resp.content, filename, content_type
# Persist fresh cookies from the download response
self._save_cookies()
# ------------------------------------------------------------------
# Convenience: extract response items
# ------------------------------------------------------------------
# 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
@staticmethod
def extract_items(data: dict) -> list[dict]:
"""Extract Items from standard OWA response envelope.
# 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())
Response shape: data["Body"]["ResponseMessages"]["Items"]
"""
try:
return data["Body"]["ResponseMessages"]["Items"]
except (KeyError, TypeError):
return []
content_type = resp.headers.get("Content-Type", "application/octet-stream")
# ------------------------------------------------------------------
# Folder helpers
# ------------------------------------------------------------------
return resp.content, filename, content_type
def get_folder_id(self, folder_name: str) -> str | None:
"""Resolve a folder name to its Exchange folder ID.
# ------------------------------------------------------------------
# Convenience: extract response items
# ------------------------------------------------------------------
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()
@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,
}
],
},
# 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
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,
},
},
}
# 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")
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
return None
# ------------------------------------------------------------------
# ResolveNames (directory search / attendee resolution)
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# ResolveNames (directory search / attendee resolution)
# ------------------------------------------------------------------
def resolve_names(self, query: str, *, full_contact: bool = True) -> list[dict]:
"""Call ResolveNames to search the directory.
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",
},
}
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"]
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 []
return []