feat: basics #1

Merged
albnnc merged 8 commits from itchy-gayal into main 2026-07-06 07:55:40 +00:00
13 changed files with 4425 additions and 4426 deletions
Showing only changes of commit 5ec3cb4ab4 - Show all commits
+1 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+4 -3
View File
@@ -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
+6 -2
View File
@@ -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
+13 -9
View File
@@ -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
View File
@@ -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)
+4 -3
View File
@@ -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(
+3 -1
View File
@@ -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
View File
@@ -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)