- 新增 NANO_OUTLOOK_MCP_URL 和 NANO_OUTLOOK_MCP_SERVER_ID 环境变量配置 - 实现 Outlook 邮件和日历的分页查询功能,添加安全参数验证 - 为 app-instance 创建脚本添加 Outlook MCP 服务器 ID 参数 - 更新前端 Outlook 页面实现邮件列表和日历事件的分页浏览 - 添加 Git 忽略文件配置和 Docker 挂载路径修复 BREAKING CHANGE: Outlook 集成现在需要配置 MCP URL 和服务器 ID 环境变量
965 lines
32 KiB
Python
965 lines
32 KiB
Python
"""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)
|