"""Workspace-scoped Outlook helpers for the web UI.""" from __future__ import annotations import asyncio import json import os import shlex from contextlib import AsyncExitStack from dataclasses import asdict, dataclass from datetime import datetime, time, timedelta from pathlib import Path from typing import Any from zoneinfo import ZoneInfo import httpx from beaver.foundation.config import BeaverConfig from beaver.integrations.authz import AuthzClient OUTLOOK_SERVER_ID = os.getenv("BEAVER_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: domain: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_DOMAIN", "") service_endpoint: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_EWS_URL", "") server: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_EWS_SERVER", "") default_timezone: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_TIMEZONE", "Asia/Shanghai") autodiscover: bool = os.getenv("BEAVER_OUTLOOK_DEFAULT_AUTODISCOVER", "0") == "1" @dataclass(frozen=True) class OutlookConnectionInput: 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" 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 _call_timeout_seconds() -> float: raw = os.getenv("BEAVER_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip() try: return max(1.0, float(raw)) if raw else 180.0 except ValueError: return 180.0 def _use_authz_mode(config: BeaverConfig) -> bool: return bool(config.authz.enabled and config.authz.base_url.strip()) def _authz_client(config: BeaverConfig) -> 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: BeaverConfig) -> str: backend_id = config.backend_identity.backend_id.strip() client_id = config.backend_identity.client_id.strip() client_secret = config.backend_identity.client_secret.strip() if not (backend_id and client_id and client_secret): raise OutlookIntegrationError("Backend is not registered with AuthZ yet.") return backend_id def _outlook_mcp_url(config: BeaverConfig) -> str: url = config.authz.outlook_mcp_url.strip() if not url: raise OutlookIntegrationError("AuthZ mode requires authz.outlook_mcp_url to be configured.") return url def outlook_defaults() -> dict[str, Any]: return { "provider": "ews", "server_id": OUTLOOK_SERVER_ID, "mcp_command": os.getenv("BEAVER_OUTLOOK_MCP_COMMAND", "bw-outlook-mcp"), "mcp_extra_args": shlex.split(os.getenv("BEAVER_OUTLOOK_MCP_EXTRA_ARGS", "").strip()), "fields": asdict(OutlookDefaults()), } def outlook_mcp_config_payload(config: BeaverConfig) -> dict[str, Any]: url = _outlook_mcp_url(config) return { "url": url, "authMode": "oauth_backend_token", "authAudience": f"mcp:{OUTLOOK_SERVER_ID}", "authScopes": ["list_tools", *[f"tool:{name}" for name in OUTLOOK_TOOL_NAMES]], "sensitive": True, "toolTimeout": 60, "kind": "online", "category": "outlook", "managed": True, "displayName": "Outlook MCP", "source": "beaver-managed", } def _meta_file(workspace: Path) -> Path: return workspace.expanduser().resolve() / "state" / "bw_outlook_mcp" / "ui_meta.json" def _load_meta(workspace: Path) -> dict[str, Any]: path = _meta_file(workspace) if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError, ValueError): return {} return data if isinstance(data, dict) else {} def _update_meta(workspace: Path, **fields: Any) -> dict[str, Any]: payload = _load_meta(workspace) payload.update(fields) payload["updated_at"] = datetime.now().isoformat() path = _meta_file(workspace) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") return payload 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 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 _default_outlook_permissions() -> dict[str, Any]: return { "mcp": { OUTLOOK_SERVER_ID: { "enabled": True, "tools": list(OUTLOOK_TOOL_NAMES), } }, "a2a": {"enabled": False, "agents": []}, } async def ensure_outlook_authz_permissions(config: BeaverConfig) -> 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()) async def _call_outlook_mcp_tool( config: BeaverConfig, tool_name: str, arguments: dict[str, Any], *, scopes: list[str] | None = None, timeout_seconds: float | None = None, ) -> dict[str, Any]: from mcp import ClientSession, types from mcp.client.streamable_http import streamable_http_client url = _outlook_mcp_url(config) client = _authz_client(config) try: 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}"], ) except httpx.TimeoutException as exc: raise OutlookIntegrationError("AuthZ token 请求超时。") from exc except httpx.HTTPError as exc: detail = str(exc).strip() or exc.__class__.__name__ raise OutlookIntegrationError(f"AuthZ token 获取失败:{detail}") from exc 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 def _invoke() -> dict[str, Any]: 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, timeout=timeout_seconds or _call_timeout_seconds(), ) ) read, write, _ = await stack.enter_async_context(streamable_http_client(url, 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: parts.append(block.text if isinstance(block, types.TextContent) else str(block)) output = "\n".join(parts).strip() if not output: return {} try: parsed = json.loads(output) except json.JSONDecodeError: return {"text": output} return parsed if isinstance(parsed, dict) else {"value": parsed} timeout_value = timeout_seconds or _call_timeout_seconds() try: return await asyncio.wait_for(_invoke(), timeout=timeout_value) except TimeoutError as exc: raise OutlookIntegrationError(f"Outlook MCP 请求超时:{tool_name} 超过 {int(timeout_value)}s") from exc except OutlookIntegrationError: raise except Exception as exc: detail = str(exc).strip() or exc.__class__.__name__ raise OutlookIntegrationError(f"Outlook MCP 调用失败:{detail}") from exc async def test_connection(data: OutlookConnectionInput, config: BeaverConfig) -> dict[str, Any]: if not _use_authz_mode(config): raise OutlookIntegrationError("Outlook setup requires AuthZ mode in this Beaver instance.") 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." ], } async def connect_workspace(config: BeaverConfig, workspace: Path, data: OutlookConnectionInput) -> dict[str, Any]: probe = await test_connection(data, 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) meta = _update_meta( workspace, provider="ews", mailbox=normalized.email, last_verified_at=datetime.now().isoformat(), last_connected_at=datetime.now().isoformat(), ) return { "ok": True, "probe": probe["sample"], "saved": {"backend_id": backend_id, "configured": True}, "mcp": {"id": OUTLOOK_SERVER_ID, **outlook_mcp_config_payload(config)}, "meta": meta, } async def disconnect_workspace(config: BeaverConfig) -> dict[str, Any]: backend_id = _require_backend_identity(config) removed = False try: result = await _authz_client(config).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} async def outlook_status(config: BeaverConfig, workspace: Path, *, verify: bool = False) -> dict[str, Any]: meta = _load_meta(workspace) if not _use_authz_mode(config): return { "configured": False, "connected": False, "provider": None, "storage_mode": "workspace", "saved": None, "auth_status": None, "mcp_registered": OUTLOOK_SERVER_ID in config.tools.mcp_servers, "mcp_server_id": OUTLOOK_SERVER_ID, "defaults": outlook_defaults(), "meta": meta, "error": "Outlook setup requires AuthZ mode in this Beaver instance.", } client = _authz_client(config) backend_id = _require_backend_identity(config) 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 if configured and verify: 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: 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": bool(OUTLOOK_SERVER_ID in config.tools.mcp_servers or config.authz.outlook_mcp_url.strip()), "mcp_server_id": OUTLOOK_SERVER_ID, "defaults": outlook_defaults(), "meta": meta, "error": error, } async def get_overview(config: BeaverConfig, workspace: Path) -> dict[str, Any]: 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] = [] async def _load_section(label: str, coro: Any) -> dict[str, Any]: try: payload = await coro return payload if isinstance(payload, dict) else {"value": []} except Exception as exc: warnings.append(f"{label} unavailable: {exc}") return {"value": []} inbox = await _load_section( "inbox", _call_outlook_mcp_tool( config, "mail_list_messages", {"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, scopes=["list_tools", "tool:mail_list_messages"], ), ) sent = await _load_section( "sent items", _call_outlook_mcp_tool( config, "mail_list_messages", {"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, scopes=["list_tools", "tool:mail_list_messages"], ), ) calendar = await _load_section( "calendar", _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"], ), ) meta = _update_meta(workspace, 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, workspace), "recentInbox": inbox.get("value", []), "recentSent": sent.get("value", []), "todayEvents": calendar.get("value", []), "warnings": warnings, "meta": meta, } def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]: return max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE)), max(0, int(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): return { **payload, "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 { **payload, "page": { "top": top, "skip": skip, "returned": returned, "has_more": returned >= top, "next_skip": skip + returned if returned >= top else None, }, } async def list_messages( config: BeaverConfig, *, 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) 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)} async def list_events( config: BeaverConfig, *, 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) 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)} async def get_message_detail(config: BeaverConfig, message_id: str, *, changekey: str | None = None) -> dict[str, Any]: return await _call_outlook_mcp_tool( config, "mail_get_message", {"message_id": message_id, "changekey": changekey}, scopes=["list_tools", "tool:mail_get_message"], )