"""Outlook sync provider — bidirectional sync with Microsoft Graph API (U7). Uses ``httpx.AsyncClient`` for all Graph API calls. Conflict resolution is last-write-wins based on ``last_modified``; conflicts emit a ``calendar_sync_conflict`` WS notification via the injectable ``notify_callback`` (G4). ponytail: browser OAuth flow (auth-code grant + redirect) is deferred to U12 settings UI. This provider assumes tokens are already stored in ``ExternalCalendarConfig.credentials``. Upgrade: add an ``OutlookOAuthFlow`` helper that performs the device-code or auth-code flow and writes tokens. """ from __future__ import annotations import json import logging import uuid from collections.abc import Awaitable, Callable from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from urllib.parse import parse_qs, quote, urlparse import httpx from agentkit.calendar.db import ( DEFAULT_CALENDAR_DB_PATH, get_event_by_external_id, insert_event, update_event, update_external_config, ) from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso from agentkit.calendar.sync.base import AbstractSyncProvider logger = logging.getLogger(__name__) # Async callback signature: (event_type: str, payload: dict) -> None NotifyCallback = Callable[[str, dict[str, Any]], Awaitable[None]] GRAPH_BASE = "https://graph.microsoft.com/v1.0" TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" DEFAULT_SCOPE = "https://graph.microsoft.com/Calendars.ReadWrite offline_access" _DAY_MAP = { "monday": "MO", "tuesday": "TU", "wednesday": "WE", "thursday": "TH", "friday": "FR", "saturday": "SA", "sunday": "SU", } _DAY_MAP_REVERSE = {v: k for k, v in _DAY_MAP.items()} _FREQ_MAP = { "daily": "DAILY", "weekly": "WEEKLY", "absoluteMonthly": "MONTHLY", "absoluteYearly": "YEARLY", } _FREQ_MAP_REVERSE = {v: k for k, v in _FREQ_MAP.items()} def _parse_iso(dt_str: str) -> datetime: """Parse ISO 8601 string to UTC-aware datetime.""" dt = datetime.fromisoformat(dt_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) def _outlook_dt_to_iso(dt_obj: dict[str, Any]) -> str: """Convert Outlook dateTimeTimeZone to ISO 8601 UTC. ponytail: assumes Graph returns UTC (no ``Prefer: outlook.timezone`` header is sent). If a non-UTC timezone is returned, it's treated as UTC. Upgrade: use ``zoneinfo`` with a Windows→IANA timezone mapping for correct conversion. """ if not dt_obj: return "" dt_str = dt_obj.get("dateTime", "") if not dt_str: return "" if "T" in dt_str: dt = datetime.fromisoformat(dt_str) else: # Date only (all-day event) dt = datetime.fromisoformat(dt_str + "T00:00:00") if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc).isoformat() def _iso_to_outlook_dt(iso_str: str, is_all_day: bool) -> dict[str, str]: """Convert ISO 8601 UTC to Outlook dateTimeTimeZone.""" if not iso_str: return {"dateTime": "", "timeZone": "UTC"} dt = _parse_iso(iso_str) if is_all_day: return {"dateTime": dt.strftime("%Y-%m-%d"), "timeZone": "UTC"} return {"dateTime": dt.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "UTC"} def _outlook_recurrence_to_rrule(recurrence: dict[str, Any] | None) -> str | None: """Convert Outlook recurrence pattern to RRULE string.""" if not recurrence: return None pattern = recurrence.get("pattern", {}) or {} range_obj = recurrence.get("range", {}) or {} parts: list[str] = [] freq = _FREQ_MAP.get(pattern.get("type", "")) if freq: parts.append(f"FREQ={freq}") interval = pattern.get("interval") if interval and interval > 1: parts.append(f"INTERVAL={interval}") days = pattern.get("daysOfWeek", []) if days: bydays = [_DAY_MAP[d] for d in days if d in _DAY_MAP] if bydays: parts.append(f"BYDAY={','.join(bydays)}") count = pattern.get("numberOfOccurrences") if count: parts.append(f"COUNT={count}") elif range_obj.get("type") == "endDate" and range_obj.get("endDate"): end_date = range_obj["endDate"] # "2026-12-31" parts.append(f"UNTIL={end_date.replace('-', '')}T235959Z") return ";".join(parts) if parts else None def _rrule_to_outlook_recurrence(rrule: str, start_date: str) -> dict[str, Any] | None: """Convert RRULE string to Outlook recurrence pattern. ``start_date`` is the event's start date in ``YYYY-MM-DD`` format (required by Graph API for the ``range.startDate`` field). """ parts: dict[str, str] = {} for part in rrule.split(";"): if "=" in part: k, v = part.split("=", 1) parts[k.upper()] = v freq = parts.get("FREQ", "").upper() pattern_type = _FREQ_MAP_REVERSE.get(freq) if not pattern_type: return None pattern: dict[str, Any] = {"type": pattern_type} interval = parts.get("INTERVAL") pattern["interval"] = int(interval) if interval else 1 byday = parts.get("BYDAY") if byday: pattern["daysOfWeek"] = [ _DAY_MAP_REVERSE[d] for d in byday.split(",") if d in _DAY_MAP_REVERSE ] count = parts.get("COUNT") until = parts.get("UNTIL") if count: pattern["numberOfOccurrences"] = int(count) range_obj: dict[str, Any] = { "type": "numbered", "startDate": start_date, "numberOfOccurrences": int(count), } elif until: # UNTIL=20261231T235959Z → "2026-12-31" date_str = until[:8] end_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" range_obj = {"type": "endDate", "startDate": start_date, "endDate": end_date} else: range_obj = {"type": "noEnd", "startDate": start_date} return {"pattern": pattern, "range": range_obj} def _extract_delta_token(delta_link: str) -> str | None: """Extract ``$deltaToken`` from a Graph delta link URL.""" parsed = urlparse(delta_link) params = parse_qs(parsed.query) values = params.get("$deltaToken", []) return values[0] if values else None class OutlookSyncProvider(AbstractSyncProvider): """Bidirectional Outlook sync provider via Microsoft Graph REST API. The ``client_factory`` parameter allows tests to inject a mock ``httpx.AsyncClient`` without making real HTTP calls. When ``None``, a real ``httpx.AsyncClient`` is constructed per-operation. """ def __init__( self, db_path: str | Path | None = None, client_factory: Callable[[], Any] | None = None, notify_callback: NotifyCallback | None = None, ) -> None: self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH self._client_factory = client_factory self._notify = notify_callback # ponytail: conflicts list is in-memory only; if the process restarts # before a sync completes, conflict history is lost. Upgrade: persist # to a calendar_sync_conflicts table. # ------------------------------------------------------------------ # Client / auth # ------------------------------------------------------------------ def _get_client(self) -> Any: """Return an httpx.AsyncClient (real or mock from factory).""" if self._client_factory is not None: return self._client_factory() return httpx.AsyncClient(timeout=30.0) def _load_creds(self, config: ExternalCalendarConfig) -> dict[str, Any]: return json.loads(config.credentials) if config.credentials else {} def _save_creds(self, config: ExternalCalendarConfig, creds: dict[str, Any]) -> None: config.credentials = json.dumps(creds) async def _refresh_token(self, client: Any, config: ExternalCalendarConfig) -> dict[str, Any]: """Refresh the access token using the refresh_token grant. Posts to the Azure AD token endpoint, updates ``config.credentials`` in-memory, and persists the new credentials to the DB. """ creds = self._load_creds(config) resp = await client.request( "POST", TOKEN_URL, data={ "client_id": creds.get("client_id", ""), "grant_type": "refresh_token", "refresh_token": creds.get("refresh_token", ""), "scope": DEFAULT_SCOPE, }, ) if resp.status_code in (400, 401): logger.error("Outlook refresh token expired or revoked (status=%s)", resp.status_code) if self._notify is not None: await self._notify( "calendar_sync_error", { "config_id": config.id, "message": "Outlook authentication expired, please re-authenticate", }, ) # ponytail: re-auth UI is in U12. Upgrade: trigger OAuth re-flow automatically. raise RuntimeError("Outlook refresh token expired — re-authentication required") resp.raise_for_status() payload = resp.json() creds["access_token"] = payload["access_token"] if "refresh_token" in payload: creds["refresh_token"] = payload["refresh_token"] creds["expires_at"] = ( datetime.now(timezone.utc) + timedelta(seconds=payload.get("expires_in", 3600)) ).isoformat() self._save_creds(config, creds) await update_external_config(config.id, {"credentials": config.credentials}, self.db_path) return creds async def _request( self, client: Any, config: ExternalCalendarConfig, method: str, url: str, *, json_body: dict[str, Any] | None = None, ) -> dict[str, Any]: """Make an authenticated Graph API request with 401 auto-refresh + retry.""" creds = self._load_creds(config) headers = {"Authorization": f"Bearer {creds.get('access_token', '')}"} resp = await client.request(method, url, headers=headers, json=json_body) if resp.status_code == 401: creds = await self._refresh_token(client, config) headers = {"Authorization": f"Bearer {creds.get('access_token', '')}"} resp = await client.request(method, url, headers=headers, json=json_body) resp.raise_for_status() return resp.json() if resp.text else {} # ------------------------------------------------------------------ # Pull # ------------------------------------------------------------------ async def pull_changes( self, config: ExternalCalendarConfig, since: str | None = None ) -> list[CalendarEvent]: """Pull remote Outlook events via delta query. First call (no ``sync_token``) is a full sync within a date range. Subsequent calls use the stored delta token for incremental sync. Returns pulled/updated events. """ client = self._get_client() try: remote_events, delta_token = await self._pull_delta(client, config) finally: await client.aclose() # Persist delta token for next incremental sync if delta_token: config.sync_token = delta_token await update_external_config(config.id, {"sync_token": delta_token}, self.db_path) result: list[CalendarEvent] = [] for remote in remote_events: local = await get_event_by_external_id( remote.external_id, "outlook", config.user_id, self.db_path ) if local is None: # New remote event → create local await insert_event(remote, self.db_path) result.append(remote) else: # Existing → check for conflict resolved = await self._resolve_pull_conflict(local, remote) if resolved is not None: result.append(resolved) return result async def _pull_delta( self, client: Any, config: ExternalCalendarConfig ) -> tuple[list[CalendarEvent], str | None]: """Call /me/calendarView/delta. Returns (events, delta_token).""" url = self._build_delta_url(config) # ponytail: single-page fetch; pagination via @odata.nextLink is not # followed. Upgrade: loop on nextLink until exhausted, then read # deltaLink from the final page. payload = await self._request(client, config, "GET", url) events: list[CalendarEvent] = [] for raw in payload.get("value", []): parsed = self._parse_outlook_event(raw, config.user_id) if parsed is not None: events.append(parsed) delta_link = payload.get("@odata.deltaLink") delta_token = _extract_delta_token(delta_link) if delta_link else None return events, delta_token def _build_delta_url(self, config: ExternalCalendarConfig) -> str: """Build the delta query URL. Uses sync_token if present (incremental).""" if config.sync_token: return f"{GRAPH_BASE}/me/calendarView/delta?$deltaToken={quote(config.sync_token, safe='')}" # Initial sync — use date range to scope the fetch start = (datetime.now(timezone.utc) - timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ") end = (datetime.now(timezone.utc) + timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ") return f"{GRAPH_BASE}/me/calendarView/delta?startDateTime={start}&endDateTime={end}" def _parse_outlook_event(self, raw: dict[str, Any], user_id: str) -> CalendarEvent | None: """Convert a Graph event JSON to a CalendarEvent dataclass.""" eid = raw.get("id") if not eid: return None title = raw.get("subject") or "" if not title: return None start_str = _outlook_dt_to_iso(raw.get("start", {})) end_str = _outlook_dt_to_iso(raw.get("end", {})) or start_str is_all_day = bool(raw.get("isAllDay", False)) body = raw.get("body", {}) or {} description = body.get("content", "") or "" location_obj = raw.get("location", {}) or {} location = location_obj.get("displayName", "") or "" rrule = _outlook_recurrence_to_rrule(raw.get("recurrence")) last_modified = raw.get("lastModifiedDateTime", "") or _now_iso() now = _now_iso() return CalendarEvent( id=uuid.uuid4().hex, user_id=user_id, title=title, description=description, start_time=start_str, end_time=end_str, is_all_day=is_all_day, location=location, rrule=rrule, source="manual", external_id=eid, external_provider="outlook", last_modified=last_modified, created_at=now, ) async def _resolve_pull_conflict( self, local: CalendarEvent, remote: CalendarEvent ) -> CalendarEvent | None: """Resolve conflict when both local and remote exist (last-write-wins). If remote is newer → update local. If local is newer → conflict (last-write-wins keeps local, but log + notify). If equal → no-op. Returns the winning event (or None if local kept unchanged). """ local_lm = ( _parse_iso(local.last_modified) if local.last_modified else datetime.min.replace(tzinfo=timezone.utc) ) remote_lm = ( _parse_iso(remote.last_modified) if remote.last_modified else datetime.min.replace(tzinfo=timezone.utc) ) if remote_lm > local_lm: # Remote wins → update local await self._notify_conflict(local, remote, winner="remote") fields = { "title": remote.title, "description": remote.description, "start_time": remote.start_time, "end_time": remote.end_time, "is_all_day": remote.is_all_day, "location": remote.location, "rrule": remote.rrule, "last_modified": remote.last_modified, } await update_event(local.id, fields, self.db_path) return remote if local_lm > remote_lm: # Local wins → conflict, keep local, notify await self._notify_conflict(local, remote, winner="local") return None # Equal timestamps → no change needed return None # ------------------------------------------------------------------ # Push # ------------------------------------------------------------------ async def push_changes( self, config: ExternalCalendarConfig, events: list[CalendarEvent] ) -> list[CalendarEvent]: """Push local events to Outlook. Returns events with external_id set.""" client = self._get_client() try: result: list[CalendarEvent] = [] for event in events: updated = await self._push_single(client, config, event) result.append(updated) finally: await client.aclose() return result async def _push_single( self, client: Any, config: ExternalCalendarConfig, event: CalendarEvent ) -> CalendarEvent: """Push a single event to Outlook, return event with external_id set.""" body = self._event_to_outlook(event) if event.external_id: # Update existing remote event url = f"{GRAPH_BASE}/me/events/{event.external_id}" await self._request(client, config, "PATCH", url, json_body=body) fields = {"last_modified": _now_iso()} await update_event(event.id, fields, self.db_path) return event # Create new remote event url = f"{GRAPH_BASE}/me/events" payload = await self._request(client, config, "POST", url, json_body=body) new_id = payload.get("id") if new_id: fields = { "external_id": new_id, "external_provider": "outlook", "last_modified": _now_iso(), } await update_event(event.id, fields, self.db_path) event.external_id = new_id event.external_provider = "outlook" return event def _event_to_outlook(self, event: CalendarEvent) -> dict[str, Any]: """Convert CalendarEvent to Outlook Graph event JSON.""" body: dict[str, Any] = { "subject": event.title, "start": _iso_to_outlook_dt(event.start_time, event.is_all_day), "end": _iso_to_outlook_dt(event.end_time, event.is_all_day), "isAllDay": event.is_all_day, } if event.description: body["body"] = {"contentType": "Text", "content": event.description} if event.location: body["location"] = {"displayName": event.location} if event.rrule: start_date = event.start_time[:10] if event.start_time else "2026-01-01" rec = _rrule_to_outlook_recurrence(event.rrule, start_date) if rec is not None: body["recurrence"] = rec return body # ------------------------------------------------------------------ # Test connection # ------------------------------------------------------------------ async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: """Test Outlook connectivity via GET /me. Returns (ok, error_msg).""" client = self._get_client() try: try: await self._request(client, config, "GET", f"{GRAPH_BASE}/me") ok, msg = True, "" except Exception as e: ok, msg = False, str(e) finally: await client.aclose() return ok, msg # ------------------------------------------------------------------ # Conflict notification # ------------------------------------------------------------------ async def _notify_conflict( self, local: CalendarEvent, remote: CalendarEvent, winner: str ) -> None: """Log conflict and send WS notification via callback (G4).""" logger.info( "Sync conflict for event %s (external_id=%s): local_lm=%s remote_lm=%s winner=%s", local.id, local.external_id, local.last_modified, remote.last_modified, winner, ) if self._notify is not None: payload = { "event_id": local.id, "title": local.title, "external_id": local.external_id, "local_last_modified": local.last_modified, "remote_last_modified": remote.last_modified, "winner": winner, } await self._notify("calendar_sync_conflict", payload)