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}"],
"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
View File
@@ -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
View File
@@ -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(
+4 -3
View File
@@ -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
+6 -2
View File
@@ -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
+13 -9
View File
@@ -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
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:
"""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)
+4 -3
View File
@@ -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(
+3 -1
View File
@@ -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
View File
@@ -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)