"""Workspace-scoped Outlook integration helpers for the web UI.""" from __future__ import annotations import importlib import json import os import shlex import shutil import sys from contextlib import AsyncExitStack from dataclasses import dataclass from datetime import datetime, time, timedelta from pathlib import Path from typing import Any from zoneinfo import ZoneInfo import httpx from loguru import logger from nanobot.authz.client import AuthzClient from nanobot.config.schema import Config, MCPServerConfig OUTLOOK_SERVER_ID = os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp") OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8 OUTLOOK_OVERVIEW_EVENT_LIMIT = 20 OUTLOOK_MAX_PAGE_SIZE = 100 class OutlookIntegrationError(RuntimeError): """Raised when the Outlook integration backend is unavailable or misconfigured.""" @dataclass(frozen=True) class OutlookDefaults: """Default values exposed to the web setup form.""" domain: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_DOMAIN", "") service_endpoint: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_URL", "") server: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_SERVER", "") default_timezone: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_TIMEZONE", "Asia/Shanghai") autodiscover: bool = os.getenv("NANOBOT_OUTLOOK_DEFAULT_AUTODISCOVER", "0") == "1" @dataclass(frozen=True) class OutlookConnectionInput: """User-provided On-Prem Exchange connection settings.""" email: str password: str username: str | None = None domain: str | None = None service_endpoint: str | None = None server: str | None = None autodiscover: bool = False default_timezone: str = "Asia/Shanghai" @dataclass(frozen=True) class OutlookStatePaths: workspace: Path state_dir: Path config_file: Path secrets_file: Path graph_token_cache_file: Path delta_store_file: Path idempotency_db_file: Path OUTLOOK_TOOL_NAMES = [ "auth_status", "mail_list_folders", "mail_list_messages", "mail_search_messages", "mail_get_message", "mail_send_email", "mail_reply_to_message", "mail_forward_message", "mail_move_message", "mail_delta_sync", "calendar_list_events", "calendar_create_event", "calendar_update_event", "calendar_get_schedule", "calendar_find_meeting_times", "calendar_delta_sync", ] def _use_authz_mode(config: Config) -> bool: return bool( getattr(config, "authz", None) and config.authz.enabled and config.authz.base_url.strip() ) def _authz_client(config: Config) -> AuthzClient: if not _use_authz_mode(config): raise OutlookIntegrationError("AuthZ mode is not enabled.") return AuthzClient( config.authz.base_url, timeout_seconds=int(config.authz.request_timeout_seconds), ) def _require_backend_identity(config: Config) -> str: backend_id = (config.backend_identity.backend_id or "").strip() client_id = (config.backend_identity.client_id or "").strip() client_secret = (config.backend_identity.client_secret or "").strip() if not (backend_id and client_id and client_secret): raise OutlookIntegrationError("Backend is not registered with AuthZ yet.") return backend_id def _default_outlook_permissions() -> dict[str, Any]: return { "mcp": { OUTLOOK_SERVER_ID: { "enabled": True, "tools": list(OUTLOOK_TOOL_NAMES), } }, "a2a": { "enabled": False, "agents": [], }, } def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]: safe_top = max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE)) safe_skip = max(0, int(skip)) return safe_top, safe_skip def _normalize_page_payload(payload: dict[str, Any], *, top: int, skip: int) -> dict[str, Any]: items = payload.get("value", []) if isinstance(payload, dict) else [] returned = len(items) if isinstance(items, list) else 0 page = payload.get("page") if isinstance(payload, dict) else None if isinstance(page, dict): normalized = dict(payload) normalized["page"] = { "top": int(page.get("top", top)), "skip": int(page.get("skip", skip)), "returned": int(page.get("returned", returned)), "has_more": bool(page.get("has_more", False)), "next_skip": page.get("next_skip"), } return normalized return { **payload, "page": { "top": top, "skip": skip, "returned": returned, "has_more": returned >= top, "next_skip": skip + returned if returned >= top else None, }, } async def ensure_outlook_authz_permissions(config: Config) -> None: backend_id = _require_backend_identity(config) client = _authz_client(config) existing = await client.get_permissions(backend_id) mcp_settings = existing.get("mcp", {}).get(OUTLOOK_SERVER_ID, {}) if isinstance(existing, dict) else {} if isinstance(mcp_settings, dict) and mcp_settings.get("enabled"): return await client.set_permissions(backend_id, _default_outlook_permissions()) def _outlook_mcp_url(config: Config) -> str: url = (config.authz.outlook_mcp_url or "").strip() if not url: raise OutlookIntegrationError("AuthZ mode requires authz.outlook_mcp_url to be configured.") return url async def _call_outlook_mcp_tool( config: Config, tool_name: str, arguments: dict[str, Any], *, scopes: list[str] | None = None, ) -> dict[str, Any]: from mcp import ClientSession, types from mcp.client.streamable_http import streamable_http_client backend_id = _require_backend_identity(config) client = _authz_client(config) token_response = await client.issue_token( client_id=config.backend_identity.client_id, client_secret=config.backend_identity.client_secret, audience=f"mcp:{OUTLOOK_SERVER_ID}", scopes=scopes or ["list_tools", f"tool:{tool_name}"], ) access_token = str(token_response.get("access_token") or "").strip() if not access_token: raise OutlookIntegrationError("Failed to obtain an Outlook MCP access token.") async with AsyncExitStack() as stack: http_client = await stack.enter_async_context( httpx.AsyncClient( headers={"Authorization": f"Bearer {access_token}"}, follow_redirects=True, trust_env=False, ) ) read, write, _ = await stack.enter_async_context( streamable_http_client(_outlook_mcp_url(config), http_client=http_client) ) session = await stack.enter_async_context(ClientSession(read, write)) await session.initialize() result = await session.call_tool(tool_name, arguments=arguments) parts: list[str] = [] for block in result.content: if isinstance(block, types.TextContent): parts.append(block.text) else: parts.append(str(block)) output = "\n".join(parts).strip() if not output: return {} try: parsed = json.loads(output) except json.JSONDecodeError: return { "backend_id": backend_id, "text": output, } return parsed if isinstance(parsed, dict) else {"value": parsed} def _candidate_roots() -> list[Path]: roots: list[Path] = [] env_root = os.getenv("NANOBOT_OUTLOOK_MCP_ROOT", "").strip() if env_root: roots.append(Path(env_root).expanduser()) sibling_root = Path(__file__).resolve().parents[3] / "BW_Outlook_Mcp" roots.append(sibling_root) return roots def _import_outlook_modules() -> dict[str, Any]: modules = ( "bw_outlook_mcp.config", "bw_outlook_mcp.ews", "bw_outlook_mcp.logging_utils", "bw_outlook_mcp.state", ) last_error: Exception | None = None try: return {name: importlib.import_module(name) for name in modules} except ModuleNotFoundError as exc: last_error = exc for root in _candidate_roots(): package_dir = root / "bw_outlook_mcp" if not package_dir.exists(): continue root_str = str(root) if root_str not in sys.path: sys.path.insert(0, root_str) try: return {name: importlib.import_module(name) for name in modules} except ModuleNotFoundError as inner_exc: last_error = inner_exc continue detail = f" Root cause: {last_error}" if last_error else "" raise OutlookIntegrationError( "BW_Outlook_Mcp is not importable. Install the package in the backend environment " "or set NANOBOT_OUTLOOK_MCP_ROOT to the package repo path." f"{detail}" ) def _get_paths(workspace: Path): ws = workspace.expanduser().resolve() state_dir = ws / "state" / "bw_outlook_mcp" state_dir.mkdir(parents=True, exist_ok=True) return OutlookStatePaths( workspace=ws, state_dir=state_dir, config_file=state_dir / "config.json", secrets_file=state_dir / "secrets.json", graph_token_cache_file=state_dir / "graph_token_cache.bin", delta_store_file=state_dir / "delta_store.json", idempotency_db_file=state_dir / "idempotency.sqlite3", ) def _meta_file(workspace: Path) -> Path: return _get_paths(workspace).state_dir / "ui_meta.json" def _load_meta(workspace: Path) -> dict[str, Any]: path = _meta_file(workspace) if not path.exists(): return {} try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError, json.JSONDecodeError): return {} def _save_meta(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]: path = _meta_file(workspace) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") return payload def _update_meta(workspace: Path, **fields: Any) -> dict[str, Any]: payload = _load_meta(workspace) payload.update(fields) payload["updated_at"] = datetime.now().isoformat() return _save_meta(workspace, payload) def outlook_defaults() -> dict[str, Any]: return { "provider": "ews", "server_id": OUTLOOK_SERVER_ID, "mcp_command": resolve_outlook_mcp_command(), "mcp_extra_args": resolve_outlook_mcp_extra_args(), "fields": OutlookDefaults().__dict__, } def resolve_outlook_mcp_command() -> str: explicit = os.getenv("NANOBOT_OUTLOOK_MCP_COMMAND", "").strip() if explicit: return explicit for root in _candidate_roots(): candidate = root / ".venv" / "bin" / "bw-outlook-mcp" if candidate.exists(): return str(candidate) return "bw-outlook-mcp" def resolve_outlook_mcp_extra_args() -> list[str]: extra = os.getenv("NANOBOT_OUTLOOK_MCP_EXTRA_ARGS", "").strip() return shlex.split(extra) if extra else [] def _normalize_input(data: OutlookConnectionInput) -> OutlookConnectionInput: email = data.email.strip() password = data.password username = (data.username or "").strip() or email.partition("@")[0].strip() domain = (data.domain or "").strip() or None service_endpoint = (data.service_endpoint or "").strip() or None server = (data.server or "").strip() or None default_timezone = (data.default_timezone or "").strip() or OutlookDefaults.default_timezone # 对 Web 表单做容错:如果用户已经给了完整的 EWS URL,就优先用它, # 避免同时传 server + service_endpoint 触发 exchangelib 的互斥校验。 if service_endpoint: server = None if not email: raise OutlookIntegrationError("Email is required.") if not password: raise OutlookIntegrationError("Password is required.") if not username: raise OutlookIntegrationError("Username is required.") if not data.autodiscover and not service_endpoint and not server: raise OutlookIntegrationError("Provide an EWS URL, a server host, or enable autodiscover.") return OutlookConnectionInput( email=email, password=password, username=username, domain=domain, service_endpoint=service_endpoint, server=server, autodiscover=bool(data.autodiscover), default_timezone=default_timezone, ) def _build_provider(data: OutlookConnectionInput): normalized = _normalize_input(data) mods = _import_outlook_modules() config_mod = mods["bw_outlook_mcp.config"] ews_mod = mods["bw_outlook_mcp.ews"] logging_mod = mods["bw_outlook_mcp.logging_utils"] ews_config = config_mod.EwsProviderConfig( email=normalized.email, username=normalized.username, domain=normalized.domain, service_endpoint=normalized.service_endpoint, server=normalized.server, autodiscover=normalized.autodiscover, ) secrets = config_mod.AppSecrets(ews_password=normalized.password) provider = ews_mod.EWSClient( ews_config, secrets, logging_mod.get_logger("nanobot.outlook.integration"), default_timezone=normalized.default_timezone, ) return provider, normalized, mods async def test_connection(data: OutlookConnectionInput, config: Config | None = None) -> dict[str, Any]: if config is not None and _use_authz_mode(config): normalized = _normalize_input(data) return { "ok": True, "provider": "ews", "mailbox": normalized.email, "resolved_username": normalized.username or "", "resolved_domain": normalized.domain, "sample": { "folders": [], "inbox": [], "events": [], }, "warnings": [ "AuthZ mode skips local EWS validation. Credentials will be validated by the Outlook MCP service after save.", ], } provider, normalized, _mods = _build_provider(data) warnings: list[str] = [] folders = await provider.list_mail_folders(top=3) inbox: dict[str, Any] = {"value": []} now = datetime.now(ZoneInfo(normalized.default_timezone)) events: dict[str, Any] = {"value": []} try: inbox = await provider.list_messages(folder="inbox", top=1) except Exception as exc: # noqa: BLE001 warnings.append(f"inbox sample unavailable: {exc}") try: events = await provider.list_events( start_time=now.isoformat(), end_time=(now + timedelta(days=1)).isoformat(), top=1, ) except Exception as exc: # noqa: BLE001 warnings.append(f"calendar sample unavailable: {exc}") return { "ok": True, "provider": "ews", "mailbox": normalized.email, "resolved_username": normalized.username, "resolved_domain": normalized.domain, "sample": { "folders": folders.get("value", []), "inbox": inbox.get("value", []), "events": events.get("value", []), }, "warnings": warnings, } def _save_workspace_credentials(workspace: Path, data: OutlookConnectionInput) -> dict[str, Any]: provider, normalized, mods = _build_provider(data) del provider # Config persistence does not require an open provider. config_mod = mods["bw_outlook_mcp.config"] paths = _get_paths(workspace) existing_graph = None try: existing = config_mod.load_app_config(paths.config_file) existing_graph = getattr(existing, "graph", None) except FileNotFoundError: existing = None app_config = config_mod.AppConfig( provider="ews", default_timezone=normalized.default_timezone, graph=existing_graph, ews=config_mod.EwsProviderConfig( email=normalized.email, username=normalized.username, domain=normalized.domain, service_endpoint=normalized.service_endpoint, server=normalized.server, autodiscover=normalized.autodiscover, ), ) config_mod.save_app_config(paths.config_file, app_config) config_mod.save_app_secrets(paths.secrets_file, config_mod.AppSecrets(ews_password=normalized.password)) return { "config_file": str(paths.config_file), "secrets_file": str(paths.secrets_file), "state_dir": str(paths.state_dir), } def ensure_outlook_mcp_registration(config: Config) -> dict[str, Any]: if _use_authz_mode(config): url = _outlook_mcp_url(config) config.tools.mcp_servers[OUTLOOK_SERVER_ID] = MCPServerConfig( url=url, auth_mode="oauth_backend_token", auth_audience=f"mcp:{OUTLOOK_SERVER_ID}", auth_scopes=["list_tools", *[f"tool:{name}" for name in OUTLOOK_TOOL_NAMES]], sensitive=True, tool_timeout=60, ) return { "id": OUTLOOK_SERVER_ID, "url": url, "transport": "http", "auth_mode": "oauth_backend_token", "auth_audience": f"mcp:{OUTLOOK_SERVER_ID}", "auth_scopes": ["list_tools", *[f"tool:{name}" for name in OUTLOOK_TOOL_NAMES]], "sensitive": True, "tool_timeout": 60, } command = resolve_outlook_mcp_command() args = ["serve", "--workspace", str(config.workspace_path), *resolve_outlook_mcp_extra_args()] config.tools.mcp_servers[OUTLOOK_SERVER_ID] = MCPServerConfig( command=command, args=args, sensitive=True, tool_timeout=60, ) return { "id": OUTLOOK_SERVER_ID, "command": command, "args": args, "sensitive": True, "tool_timeout": 60, } async def connect_workspace(config: Config, data: OutlookConnectionInput) -> dict[str, Any]: probe = await test_connection(data, config) if _use_authz_mode(config): normalized = _normalize_input(data) backend_id = _require_backend_identity(config) client = _authz_client(config) await client.set_outlook_settings( backend_id, { "configured": True, "email": normalized.email, "username": normalized.username, "domain": normalized.domain, "service_endpoint": normalized.service_endpoint, "server": normalized.server, "autodiscover": normalized.autodiscover, "default_timezone": normalized.default_timezone, "password": normalized.password, }, ) await ensure_outlook_authz_permissions(config) saved = { "backend_id": backend_id, "configured": True, } else: saved = _save_workspace_credentials(config.workspace_path, data) mcp = ensure_outlook_mcp_registration(config) meta = _update_meta( config.workspace_path, provider="ews", mailbox=data.email.strip(), last_verified_at=datetime.now().isoformat(), last_connected_at=datetime.now().isoformat(), ) return { "ok": True, "probe": probe["sample"], "saved": saved, "mcp": mcp, "meta": meta, } async def disconnect_workspace(config: Config) -> dict[str, Any]: if _use_authz_mode(config): backend_id = _require_backend_identity(config) removed = False try: client = _authz_client(config) result = await client.delete_outlook_settings(backend_id) removed = bool(result.get("ok")) except Exception: removed = False return { "ok": True, "removed_state": removed, "removed_mcp": False, "server_id": OUTLOOK_SERVER_ID, } state_dir = _get_paths(config.workspace_path).state_dir removed_state = False if state_dir.exists(): shutil.rmtree(state_dir) removed_state = True removed_mcp = config.tools.mcp_servers.pop(OUTLOOK_SERVER_ID, None) is not None return { "ok": True, "removed_state": removed_state, "removed_mcp": removed_mcp, "server_id": OUTLOOK_SERVER_ID, } def _saved_connection_input(workspace: Path) -> OutlookConnectionInput: mods = _import_outlook_modules() config_mod = mods["bw_outlook_mcp.config"] paths = _get_paths(workspace) try: app_config = config_mod.load_app_config(paths.config_file) except FileNotFoundError as exc: raise OutlookIntegrationError("Outlook is not configured for this workspace.") from exc if getattr(app_config, "provider", "") != "ews" or getattr(app_config, "ews", None) is None: raise OutlookIntegrationError("This workspace is not configured for the EWS Outlook provider.") secrets = config_mod.load_app_secrets(paths.secrets_file) ews_cfg = app_config.ews return OutlookConnectionInput( email=ews_cfg.email, password=secrets.ews_password or "", username=ews_cfg.username, domain=ews_cfg.domain, service_endpoint=ews_cfg.service_endpoint, server=ews_cfg.server, autodiscover=bool(ews_cfg.autodiscover), default_timezone=app_config.default_timezone, ) def _sanitize_connection(data: OutlookConnectionInput) -> dict[str, Any]: return { "email": data.email, "username": data.username, "domain": data.domain, "service_endpoint": data.service_endpoint, "server": data.server, "autodiscover": data.autodiscover, "default_timezone": data.default_timezone, } async def outlook_status(config: Config) -> dict[str, Any]: if _use_authz_mode(config): client = _authz_client(config) backend_id = _require_backend_identity(config) meta = _load_meta(config.workspace_path) saved = await client.get_outlook_settings(backend_id) configured = bool(saved.get("configured")) connected = False auth_status: dict[str, Any] | None = None error: str | None = None mcp_registered = bool( OUTLOOK_SERVER_ID in config.tools.mcp_servers or (config.authz.outlook_mcp_url or "").strip() ) if configured: try: auth_status = await _call_outlook_mcp_tool( config, "auth_status", {}, scopes=["list_tools", "tool:auth_status"], ) connected = bool(auth_status.get("authenticated")) except Exception as exc: # noqa: BLE001 error = str(exc) return { "configured": configured, "connected": connected, "provider": "ews" if configured else None, "storage_mode": "authz", "saved": saved if configured else None, "auth_status": auth_status, "mcp_registered": mcp_registered, "mcp_server_id": OUTLOOK_SERVER_ID, "defaults": outlook_defaults(), "meta": meta, "error": error, } workspace = config.workspace_path paths = _get_paths(workspace) configured = paths.config_file.exists() meta = _load_meta(workspace) saved: dict[str, Any] | None = None connected = False auth_status: dict[str, Any] | None = None error: str | None = None if configured: try: input_data = _saved_connection_input(workspace) provider, _normalized, _mods = _build_provider(input_data) auth_status = await provider.auth_status() saved = _sanitize_connection(input_data) if auth_status.get("authenticated"): await provider.list_mail_folders(top=1) connected = True except Exception as exc: # noqa: BLE001 error = str(exc) return { "configured": configured, "connected": connected, "provider": "ews" if configured else None, "storage_mode": "workspace", "saved": saved, "auth_status": auth_status, "mcp_registered": OUTLOOK_SERVER_ID in config.tools.mcp_servers, "mcp_server_id": OUTLOOK_SERVER_ID, "defaults": outlook_defaults(), "meta": meta, "error": error, } async def get_overview(config: Config) -> dict[str, Any]: if _use_authz_mode(config): saved = await _authz_client(config).get_outlook_settings(_require_backend_identity(config)) if not saved.get("configured"): raise OutlookIntegrationError("Outlook is not configured for this backend.") timezone_name = str(saved.get("default_timezone") or "Asia/Shanghai") now = datetime.now(ZoneInfo(timezone_name)) start_of_day = datetime.combine(now.date(), time.min, tzinfo=now.tzinfo) end_of_day = start_of_day + timedelta(days=1) warnings: list[str] = [] try: inbox = await _call_outlook_mcp_tool( config, "mail_list_messages", {"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, scopes=["list_tools", "tool:mail_list_messages"], ) except Exception as exc: # noqa: BLE001 inbox = {"value": []} warnings.append(f"inbox unavailable: {exc}") try: sent = await _call_outlook_mcp_tool( config, "mail_list_messages", {"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, scopes=["list_tools", "tool:mail_list_messages"], ) except Exception as exc: # noqa: BLE001 sent = {"value": []} warnings.append(f"sent items unavailable: {exc}") try: calendar = await _call_outlook_mcp_tool( config, "calendar_list_events", { "start_time": start_of_day.isoformat(), "end_time": end_of_day.isoformat(), "top": OUTLOOK_OVERVIEW_EVENT_LIMIT, "skip": 0, }, scopes=["list_tools", "tool:calendar_list_events"], ) except Exception as exc: # noqa: BLE001 calendar = {"value": []} warnings.append(f"calendar unavailable: {exc}") meta = _update_meta( config.workspace_path, last_overview_refresh_at=datetime.now().isoformat(), ) return { "mailbox": saved.get("email") or "", "timezone": timezone_name, "today": now.date().isoformat(), "connection": await outlook_status(config), "recentInbox": inbox.get("value", []), "recentSent": sent.get("value", []), "todayEvents": calendar.get("value", []), "warnings": warnings, "meta": meta, } input_data = _saved_connection_input(config.workspace_path) provider, normalized, _mods = _build_provider(input_data) now = datetime.now(ZoneInfo(normalized.default_timezone)) start_of_day = datetime.combine(now.date(), time.min, tzinfo=now.tzinfo) end_of_day = start_of_day + timedelta(days=1) warnings: list[str] = [] try: inbox = await provider.list_messages( folder="inbox", top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT, skip=0, ) except Exception as exc: # noqa: BLE001 inbox = {"value": []} warnings.append(f"inbox unavailable: {exc}") try: sent = await provider.list_messages( folder="sentitems", top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT, skip=0, ) except Exception as exc: # noqa: BLE001 sent = {"value": []} warnings.append(f"sent items unavailable: {exc}") try: calendar = await provider.list_events( start_time=start_of_day.isoformat(), end_time=end_of_day.isoformat(), top=OUTLOOK_OVERVIEW_EVENT_LIMIT, skip=0, ) except Exception as exc: # noqa: BLE001 calendar = {"value": []} warnings.append(f"calendar unavailable: {exc}") meta = _update_meta( config.workspace_path, last_overview_refresh_at=datetime.now().isoformat(), ) return { "mailbox": normalized.email, "timezone": normalized.default_timezone, "today": now.date().isoformat(), "connection": await outlook_status(config), "recentInbox": inbox.get("value", []), "recentSent": sent.get("value", []), "todayEvents": calendar.get("value", []), "warnings": warnings, "meta": meta, } async def get_message_detail( config: Config, message_id: str, *, changekey: str | None = None, ) -> dict[str, Any]: if _use_authz_mode(config): return await _call_outlook_mcp_tool( config, "mail_get_message", { "message_id": message_id, "changekey": changekey, }, scopes=["list_tools", "tool:mail_get_message"], ) input_data = _saved_connection_input(config.workspace_path) provider, _normalized, _mods = _build_provider(input_data) return await provider.get_message(message_id=message_id, changekey=changekey) async def list_messages( config: Config, *, folder: str, top: int, skip: int = 0, unread_only: bool = False, ) -> dict[str, Any]: safe_top, safe_skip = _normalize_page_args(top=top, skip=skip) if _use_authz_mode(config): payload = await _call_outlook_mcp_tool( config, "mail_list_messages", { "folder": folder, "top": safe_top, "skip": safe_skip, "unread_only": unread_only, }, scopes=["list_tools", "tool:mail_list_messages"], ) return { "folder": folder, "unread_only": unread_only, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), } input_data = _saved_connection_input(config.workspace_path) provider, _normalized, _mods = _build_provider(input_data) payload = await provider.list_messages( folder=folder, top=safe_top, skip=safe_skip, unread_only=unread_only, ) return { "folder": folder, "unread_only": unread_only, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), } async def list_events( config: Config, *, start_time: str, end_time: str, top: int, skip: int = 0, ) -> dict[str, Any]: safe_top, safe_skip = _normalize_page_args(top=top, skip=skip) if _use_authz_mode(config): payload = await _call_outlook_mcp_tool( config, "calendar_list_events", { "start_time": start_time, "end_time": end_time, "top": safe_top, "skip": safe_skip, }, scopes=["list_tools", "tool:calendar_list_events"], ) return { "start_time": start_time, "end_time": end_time, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), } input_data = _saved_connection_input(config.workspace_path) provider, _normalized, _mods = _build_provider(input_data) payload = await provider.list_events( start_time=start_time, end_time=end_time, top=safe_top, skip=safe_skip, ) return { "start_time": start_time, "end_time": end_time, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), } def is_outlook_mcp_registered(config: Config) -> bool: return OUTLOOK_SERVER_ID in config.tools.mcp_servers def log_outlook_debug(message: str, **fields: Any) -> None: logger.bind(**fields).info(message)