w
This commit is contained in:
+1
-4
@@ -3,11 +3,8 @@
|
|||||||
"includes": ["**/*.{md,json,py}"],
|
"includes": ["**/*.{md,json,py}"],
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
"lineWidth": 80,
|
"lineWidth": 80,
|
||||||
"json": {},
|
|
||||||
"markdown": {},
|
|
||||||
"ruff": {
|
"ruff": {
|
||||||
"lineLength": 88,
|
"lineLength": 80,
|
||||||
"targetVersion": "py310"
|
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"https://plugins.dprint.dev/json-0.22.0.wasm",
|
"https://plugins.dprint.dev/json-0.22.0.wasm",
|
||||||
|
|||||||
+15
-20
@@ -95,9 +95,7 @@ class OWAClient:
|
|||||||
"pragma": "no-cache",
|
"pragma": "no-cache",
|
||||||
"priority": "u=1, i",
|
"priority": "u=1, i",
|
||||||
"user-agent": (
|
"user-agent": (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/131.0.0.0 Safari/537.36"
|
|
||||||
),
|
),
|
||||||
# OWA-specific
|
# OWA-specific
|
||||||
"action": action,
|
"action": action,
|
||||||
@@ -128,7 +126,9 @@ class OWAClient:
|
|||||||
@property
|
@property
|
||||||
def _client_id(self) -> str:
|
def _client_id(self) -> str:
|
||||||
"""Extract ClientId from session cookies."""
|
"""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"
|
return cid if cid else "00000000000000000000000000000000"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -217,9 +217,7 @@ class OWAClient:
|
|||||||
f"{self.owa_url}/owa/",
|
f"{self.owa_url}/owa/",
|
||||||
headers={
|
headers={
|
||||||
"User-Agent": (
|
"User-Agent": (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/131.0.0.0 Safari/537.36"
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
@@ -238,7 +236,9 @@ class OWAClient:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # non-critical
|
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.
|
"""Execute a single OWA API request.
|
||||||
|
|
||||||
Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload
|
Uses the Exchange 2013 "x-owa-urlpostdata" pattern: the JSON payload
|
||||||
@@ -271,15 +271,12 @@ class OWAClient:
|
|||||||
|
|
||||||
# Detect session expiry
|
# Detect session expiry
|
||||||
if resp.status_code in (401, 440):
|
if resp.status_code in (401, 440):
|
||||||
raise SessionExpiredError(
|
raise SessionExpiredError(f"Session expired (HTTP {resp.status_code}).")
|
||||||
f"Session expired (HTTP {resp.status_code})."
|
|
||||||
)
|
|
||||||
|
|
||||||
if "text/html" in resp.headers.get("Content-Type", ""):
|
if "text/html" in resp.headers.get("Content-Type", ""):
|
||||||
body_snippet = resp.text[:300] if resp.text else ""
|
body_snippet = resp.text[:300] if resp.text else ""
|
||||||
raise SessionExpiredError(
|
raise SessionExpiredError(
|
||||||
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). "
|
f"Session expired or invalid action (HTML response, HTTP {resp.status_code}). Snippet: {body_snippet}"
|
||||||
f"Snippet: {body_snippet}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse JSON
|
# Parse JSON
|
||||||
@@ -287,8 +284,7 @@ class OWAClient:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
|
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
|
||||||
raise SessionExpiredError(
|
raise SessionExpiredError(
|
||||||
f"Unexpected response (HTTP {resp.status_code}). "
|
f"Unexpected response (HTTP {resp.status_code}). Session may have expired."
|
||||||
"Session may have expired."
|
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# Persist any Set-Cookie updates back to disk so the next request
|
# Persist any Set-Cookie updates back to disk so the next request
|
||||||
@@ -324,10 +320,7 @@ class OWAClient:
|
|||||||
|
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
url = (
|
url = f"{self.owa_url}/owa/service.svc/s/GetFileAttachment?id={quote(attachment_id)}"
|
||||||
f"{self.owa_url}/owa/service.svc/s/GetFileAttachment"
|
|
||||||
f"?id={quote(attachment_id)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = self._session.get(url, timeout=timeout)
|
resp = self._session.get(url, timeout=timeout)
|
||||||
@@ -467,7 +460,9 @@ class OWAClient:
|
|||||||
# ResolveNames (directory search / attendee resolution)
|
# 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.
|
"""Call ResolveNames to search the directory.
|
||||||
|
|
||||||
Returns the list of Resolution dicts from the API, each containing
|
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",
|
"--token-hash",
|
||||||
default=None,
|
default=None,
|
||||||
help=(
|
help=(
|
||||||
"SHA-256 hash of the bearer token for HTTP transport auth. "
|
"SHA-256 hash of the bearer token for HTTP transport auth. Compute with: echo -n 'my-token' | sha256sum"
|
||||||
"Compute with: echo -n 'my-token' | sha256sum"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ logger = logging.getLogger(__name__)
|
|||||||
# ── Client factory ───────────────────────────────────────────────────────
|
# ── 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`.
|
"""Create and return a fully configured :class:`OWAClient`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -94,8 +96,7 @@ class _LazyClient:
|
|||||||
def resolved(self) -> OWAClient:
|
def resolved(self) -> OWAClient:
|
||||||
if self._client is None:
|
if self._client is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"OWA client is not initialised. "
|
"OWA client is not initialised. Check --owa-url and --cookie-file arguments."
|
||||||
"Check --owa-url and --cookie-file arguments."
|
|
||||||
)
|
)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ def get_meeting_stats(
|
|||||||
|
|
||||||
emails = [email for _, email in resolved if email]
|
emails = [email for _, email in resolved if email]
|
||||||
if not emails:
|
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
|
# Query availability
|
||||||
avail = _get_availability_events(client, emails, sd, ed)
|
avail = _get_availability_events(client, emails, sd, ed)
|
||||||
@@ -347,7 +349,9 @@ def get_meeting_contacts(
|
|||||||
if not items:
|
if not items:
|
||||||
break
|
break
|
||||||
folder_items = items[0].get("RootFolder", {}).get("Items", [])
|
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:
|
if not folder_items:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -64,10 +64,16 @@ def _merge_busy_periods(all_busy: list) -> list[tuple]:
|
|||||||
|
|
||||||
|
|
||||||
def _find_free_slots(
|
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]:
|
) -> list[tuple]:
|
||||||
"""Find free slots on a given date within working hours."""
|
"""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))
|
day_end = datetime.combine(date, datetime.min.time().replace(hour=end_hour))
|
||||||
|
|
||||||
# Filter busy periods to this day
|
# Filter busy periods to this day
|
||||||
@@ -320,7 +326,9 @@ def _get_calendar_events(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if there are more items
|
# 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:
|
if is_last:
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -377,9 +385,7 @@ def find_free_time(
|
|||||||
folder_id = _get_calendar_folder_id(client)
|
folder_id = _get_calendar_folder_id(client)
|
||||||
if not folder_id:
|
if not folder_id:
|
||||||
return json.dumps(
|
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)
|
all_busy = _get_calendar_events(client, folder_id, sd, ed)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -582,9 +588,7 @@ def find_meeting_time(
|
|||||||
end_str = event.get("EndTime", "")
|
end_str = event.get("EndTime", "")
|
||||||
if start_str and end_str:
|
if start_str and end_str:
|
||||||
try:
|
try:
|
||||||
start = datetime.fromisoformat(
|
start = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
|
||||||
start_str.replace("Z", "+00:00")
|
|
||||||
)
|
|
||||||
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
|
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
|
||||||
all_busy.append((start, end))
|
all_busy.append((start, end))
|
||||||
except Exception:
|
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:
|
def _build_html_body(description: str | None) -> str:
|
||||||
"""Build the HTML body for a calendar item, matching create-meeting.py."""
|
"""Build the HTML body for a calendar item, matching create-meeting.py."""
|
||||||
body = (
|
body = '<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"></head><body dir="ltr">'
|
||||||
'<html><head><meta http-equiv="Content-Type" '
|
|
||||||
'content="text/html; charset=UTF-8"></head><body dir="ltr">'
|
|
||||||
)
|
|
||||||
if description:
|
if description:
|
||||||
desc_escaped = html_mod.escape(description).replace("\n", "<br>")
|
desc_escaped = html_mod.escape(description).replace("\n", "<br>")
|
||||||
body += (
|
body += f'<div style="font-size:12pt;color:#000000;font-family:Calibri,Helvetica,sans-serif;">{desc_escaped}</div>'
|
||||||
'<div style="font-size:12pt;color:#000000;'
|
|
||||||
f'font-family:Calibri,Helvetica,sans-serif;">{desc_escaped}</div>'
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
body += (
|
body += '<div style="font-size:12pt;color:#000000;font-family:Calibri,Helvetica,sans-serif;"><p><br></p></div>'
|
||||||
'<div style="font-size:12pt;color:#000000;'
|
|
||||||
'font-family:Calibri,Helvetica,sans-serif;"><p><br></p></div>'
|
|
||||||
)
|
|
||||||
body += "</body></html>"
|
body += "</body></html>"
|
||||||
return body
|
return body
|
||||||
|
|
||||||
@@ -385,9 +376,7 @@ def _get_expanded_events(
|
|||||||
subject = details.get("Subject", "") if details else ""
|
subject = details.get("Subject", "") if details else ""
|
||||||
location = details.get("Location", "") if details else ""
|
location = details.get("Location", "") if details else ""
|
||||||
is_meeting = details.get("IsMeeting", False) if details else False
|
is_meeting = details.get("IsMeeting", False) if details else False
|
||||||
is_recurring = (
|
is_recurring = details.get("IsRecurring", False) if details else False
|
||||||
details.get("IsRecurring", False) if details else False
|
|
||||||
)
|
|
||||||
|
|
||||||
expanded.append(
|
expanded.append(
|
||||||
{
|
{
|
||||||
@@ -807,9 +796,9 @@ def update_meeting(
|
|||||||
orig_start = datetime.fromisoformat(
|
orig_start = datetime.fromisoformat(
|
||||||
orig_start_str.replace("Z", "+00:00")
|
orig_start_str.replace("Z", "+00:00")
|
||||||
).replace(tzinfo=None)
|
).replace(tzinfo=None)
|
||||||
orig_end = datetime.fromisoformat(orig_end_str.replace("Z", "+00:00")).replace(
|
orig_end = datetime.fromisoformat(
|
||||||
tzinfo=None
|
orig_end_str.replace("Z", "+00:00")
|
||||||
)
|
).replace(tzinfo=None)
|
||||||
orig_duration = int((orig_end - orig_start).total_seconds() / 60)
|
orig_duration = int((orig_end - orig_start).total_seconds() / 60)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
orig_start = None
|
orig_start = None
|
||||||
@@ -877,7 +866,9 @@ def update_meeting(
|
|||||||
return json.dumps({"error": f"Failed to cancel original meeting: {e}"})
|
return json.dumps({"error": f"Failed to cancel original meeting: {e}"})
|
||||||
|
|
||||||
# Step 4: Create the new meeting
|
# 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:
|
if description is not None:
|
||||||
new_body = _build_html_body(description)
|
new_body = _build_html_body(description)
|
||||||
|
|
||||||
|
|||||||
@@ -553,7 +553,9 @@ def reply_email(
|
|||||||
if not change_key:
|
if not change_key:
|
||||||
return json.dumps({"error": "Could not resolve item ChangeKey."})
|
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 = {
|
reply_item = {
|
||||||
"__type": item_type,
|
"__type": item_type,
|
||||||
@@ -1098,8 +1100,7 @@ def _build_search_restriction(query: str, scope: str) -> dict | None:
|
|||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
name="search_emails",
|
name="search_emails",
|
||||||
description=(
|
description=(
|
||||||
"Search emails by text across one or all folders. "
|
"Search emails by text across one or all folders. Supports searching by subject, body, sender, or all fields combined."
|
||||||
"Supports searching by subject, body, sender, or all fields combined."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async def search_emails(
|
async def search_emails(
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ def check_session(ctx: Context = None) -> str:
|
|||||||
"__type": "FolderResponseShape:#Exchange",
|
"__type": "FolderResponseShape:#Exchange",
|
||||||
"BaseShape": "Default",
|
"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:
|
if not html_content:
|
||||||
return ""
|
return ""
|
||||||
text = re.sub(
|
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"<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)
|
||||||
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