w
This commit is contained in:
+1
-4
@@ -3,11 +3,8 @@
|
||||
"includes": ["**/*.{md,json,py}"],
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80,
|
||||
"json": {},
|
||||
"markdown": {},
|
||||
"ruff": {
|
||||
"lineLength": 88,
|
||||
"targetVersion": "py310"
|
||||
"lineLength": 80,
|
||||
},
|
||||
"plugins": [
|
||||
"https://plugins.dprint.dev/json-0.22.0.wasm",
|
||||
|
||||
+15
-20
@@ -95,9 +95,7 @@ class OWAClient:
|
||||
"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"
|
||||
"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,
|
||||
@@ -128,7 +126,9 @@ class OWAClient:
|
||||
@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"), "")
|
||||
cid = next(
|
||||
(c.value for c in self._session.cookies if c.name == "ClientId"), ""
|
||||
)
|
||||
return cid if cid else "00000000000000000000000000000000"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -217,9 +217,7 @@ class OWAClient:
|
||||
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"
|
||||
"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,
|
||||
@@ -238,7 +236,9 @@ class OWAClient:
|
||||
except Exception:
|
||||
pass # non-critical
|
||||
|
||||
def _do_request(self, action: str, payload: dict, *, timeout: int = 30) -> dict:
|
||||
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
|
||||
@@ -271,15 +271,12 @@ class OWAClient:
|
||||
|
||||
# Detect session expiry
|
||||
if resp.status_code in (401, 440):
|
||||
raise SessionExpiredError(
|
||||
f"Session expired (HTTP {resp.status_code})."
|
||||
)
|
||||
raise SessionExpiredError(f"Session expired (HTTP {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}"
|
||||
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). Snippet: {body_snippet}"
|
||||
)
|
||||
|
||||
# Parse JSON
|
||||
@@ -287,8 +284,7 @@ class OWAClient:
|
||||
data = resp.json()
|
||||
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
|
||||
raise SessionExpiredError(
|
||||
f"Unexpected response (HTTP {resp.status_code}). "
|
||||
"Session may have expired."
|
||||
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
|
||||
@@ -324,10 +320,7 @@ class OWAClient:
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
url = (
|
||||
f"{self.owa_url}/owa/service.svc/s/GetFileAttachment"
|
||||
f"?id={quote(attachment_id)}"
|
||||
)
|
||||
url = f"{self.owa_url}/owa/service.svc/s/GetFileAttachment?id={quote(attachment_id)}"
|
||||
|
||||
try:
|
||||
resp = self._session.get(url, timeout=timeout)
|
||||
@@ -467,7 +460,9 @@ class OWAClient:
|
||||
# ResolveNames (directory search / attendee resolution)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def resolve_names(self, query: str, *, full_contact: bool = True) -> list[dict]:
|
||||
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
|
||||
|
||||
+1
-2
@@ -102,8 +102,7 @@ def _parse_args(argv=None) -> argparse.Namespace:
|
||||
"--token-hash",
|
||||
default=None,
|
||||
help=(
|
||||
"SHA-256 hash of the bearer token for HTTP transport auth. "
|
||||
"Compute with: echo -n 'my-token' | sha256sum"
|
||||
"SHA-256 hash of the bearer token for HTTP transport auth. Compute with: echo -n 'my-token' | sha256sum"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
|
||||
@@ -30,7 +30,9 @@ logger = logging.getLogger(__name__)
|
||||
# ── Client factory ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_owa_client(owa_url: str, cookie_file: str | None = None) -> OWAClient:
|
||||
def create_owa_client(
|
||||
owa_url: str, cookie_file: str | None = None
|
||||
) -> OWAClient:
|
||||
"""Create and return a fully configured :class:`OWAClient`.
|
||||
|
||||
Args:
|
||||
@@ -94,8 +96,7 @@ class _LazyClient:
|
||||
def resolved(self) -> OWAClient:
|
||||
if self._client is None:
|
||||
raise RuntimeError(
|
||||
"OWA client is not initialised. "
|
||||
"Check --owa-url and --cookie-file arguments."
|
||||
"OWA client is not initialised. Check --owa-url and --cookie-file arguments."
|
||||
)
|
||||
return self._client
|
||||
|
||||
|
||||
@@ -191,7 +191,9 @@ def get_meeting_stats(
|
||||
|
||||
emails = [email for _, email in resolved if email]
|
||||
if not emails:
|
||||
return json.dumps({"error": "Could not resolve any names to email addresses."})
|
||||
return json.dumps(
|
||||
{"error": "Could not resolve any names to email addresses."}
|
||||
)
|
||||
|
||||
# Query availability
|
||||
avail = _get_availability_events(client, emails, sd, ed)
|
||||
@@ -347,7 +349,9 @@ def get_meeting_contacts(
|
||||
if not items:
|
||||
break
|
||||
folder_items = items[0].get("RootFolder", {}).get("Items", [])
|
||||
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
||||
is_last = (
|
||||
items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
||||
)
|
||||
if not folder_items:
|
||||
break
|
||||
|
||||
|
||||
@@ -64,10 +64,16 @@ def _merge_busy_periods(all_busy: list) -> list[tuple]:
|
||||
|
||||
|
||||
def _find_free_slots(
|
||||
busy_periods: list, date, start_hour: int, end_hour: int, duration_minutes: int
|
||||
busy_periods: list,
|
||||
date,
|
||||
start_hour: int,
|
||||
end_hour: int,
|
||||
duration_minutes: int,
|
||||
) -> list[tuple]:
|
||||
"""Find free slots on a given date within working hours."""
|
||||
day_start = datetime.combine(date, datetime.min.time().replace(hour=start_hour))
|
||||
day_start = datetime.combine(
|
||||
date, datetime.min.time().replace(hour=start_hour)
|
||||
)
|
||||
day_end = datetime.combine(date, datetime.min.time().replace(hour=end_hour))
|
||||
|
||||
# Filter busy periods to this day
|
||||
@@ -320,7 +326,9 @@ def _get_calendar_events(
|
||||
continue
|
||||
|
||||
# Check if there are more items
|
||||
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
||||
is_last = (
|
||||
items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
||||
)
|
||||
if is_last:
|
||||
break
|
||||
|
||||
@@ -377,9 +385,7 @@ def find_free_time(
|
||||
folder_id = _get_calendar_folder_id(client)
|
||||
if not folder_id:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "Could not find calendar folder. Session may have expired."
|
||||
}
|
||||
{"error": "Could not find calendar folder. Session may have expired."}
|
||||
)
|
||||
all_busy = _get_calendar_events(client, folder_id, sd, ed)
|
||||
except Exception as e:
|
||||
@@ -582,9 +588,7 @@ def find_meeting_time(
|
||||
end_str = event.get("EndTime", "")
|
||||
if start_str and end_str:
|
||||
try:
|
||||
start = datetime.fromisoformat(
|
||||
start_str.replace("Z", "+00:00")
|
||||
)
|
||||
start = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
|
||||
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
|
||||
all_busy.append((start, end))
|
||||
except Exception:
|
||||
|
||||
+10
-19
@@ -287,21 +287,12 @@ def _resolve_attendee_list(client: OWAClient, emails: list[str]) -> list[dict]:
|
||||
|
||||
def _build_html_body(description: str | None) -> str:
|
||||
"""Build the HTML body for a calendar item, matching create-meeting.py."""
|
||||
body = (
|
||||
'<html><head><meta http-equiv="Content-Type" '
|
||||
'content="text/html; charset=UTF-8"></head><body dir="ltr">'
|
||||
)
|
||||
body = '<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"></head><body dir="ltr">'
|
||||
if description:
|
||||
desc_escaped = html_mod.escape(description).replace("\n", "<br>")
|
||||
body += (
|
||||
'<div style="font-size:12pt;color:#000000;'
|
||||
f'font-family:Calibri,Helvetica,sans-serif;">{desc_escaped}</div>'
|
||||
)
|
||||
body += f'<div style="font-size:12pt;color:#000000;font-family:Calibri,Helvetica,sans-serif;">{desc_escaped}</div>'
|
||||
else:
|
||||
body += (
|
||||
'<div style="font-size:12pt;color:#000000;'
|
||||
'font-family:Calibri,Helvetica,sans-serif;"><p><br></p></div>'
|
||||
)
|
||||
body += '<div style="font-size:12pt;color:#000000;font-family:Calibri,Helvetica,sans-serif;"><p><br></p></div>'
|
||||
body += "</body></html>"
|
||||
return body
|
||||
|
||||
@@ -385,9 +376,7 @@ def _get_expanded_events(
|
||||
subject = details.get("Subject", "") if details else ""
|
||||
location = details.get("Location", "") if details else ""
|
||||
is_meeting = details.get("IsMeeting", False) if details else False
|
||||
is_recurring = (
|
||||
details.get("IsRecurring", False) if details else False
|
||||
)
|
||||
is_recurring = details.get("IsRecurring", False) if details else False
|
||||
|
||||
expanded.append(
|
||||
{
|
||||
@@ -807,9 +796,9 @@ def update_meeting(
|
||||
orig_start = datetime.fromisoformat(
|
||||
orig_start_str.replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
orig_end = datetime.fromisoformat(
|
||||
orig_end_str.replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
orig_duration = int((orig_end - orig_start).total_seconds() / 60)
|
||||
except (ValueError, AttributeError):
|
||||
orig_start = None
|
||||
@@ -877,7 +866,9 @@ def update_meeting(
|
||||
return json.dumps({"error": f"Failed to cancel original meeting: {e}"})
|
||||
|
||||
# Step 4: Create the new meeting
|
||||
new_body = description if description is not None else orig.get("body_html", "")
|
||||
new_body = (
|
||||
description if description is not None else orig.get("body_html", "")
|
||||
)
|
||||
if description is not None:
|
||||
new_body = _build_html_body(description)
|
||||
|
||||
|
||||
@@ -553,7 +553,9 @@ def reply_email(
|
||||
if not change_key:
|
||||
return json.dumps({"error": "Could not resolve item ChangeKey."})
|
||||
|
||||
item_type = "ReplyAllToItem:#Exchange" if reply_all else "ReplyToItem:#Exchange"
|
||||
item_type = (
|
||||
"ReplyAllToItem:#Exchange" if reply_all else "ReplyToItem:#Exchange"
|
||||
)
|
||||
|
||||
reply_item = {
|
||||
"__type": item_type,
|
||||
@@ -1098,8 +1100,7 @@ def _build_search_restriction(query: str, scope: str) -> dict | None:
|
||||
@mcp.tool(
|
||||
name="search_emails",
|
||||
description=(
|
||||
"Search emails by text across one or all folders. "
|
||||
"Supports searching by subject, body, sender, or all fields combined."
|
||||
"Search emails by text across one or all folders. Supports searching by subject, body, sender, or all fields combined."
|
||||
),
|
||||
)
|
||||
async def search_emails(
|
||||
|
||||
@@ -77,7 +77,9 @@ def check_session(ctx: Context = None) -> str:
|
||||
"__type": "FolderResponseShape:#Exchange",
|
||||
"BaseShape": "Default",
|
||||
},
|
||||
"FolderIds": [{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}],
|
||||
"FolderIds": [
|
||||
{"__type": "DistinguishedFolderId:#Exchange", "Id": "inbox"}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+7
-2
@@ -18,9 +18,14 @@ def html_to_text(html_content: str) -> str:
|
||||
if not html_content:
|
||||
return ""
|
||||
text = re.sub(
|
||||
r"<script[^>]*>.*?</script>", "", html_content, flags=re.DOTALL | re.IGNORECASE
|
||||
r"<script[^>]*>.*?</script>",
|
||||
"",
|
||||
html_content,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
text = re.sub(
|
||||
r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<p[^>]*>", "\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
|
||||
|
||||
Reference in New Issue
Block a user