第一次提交
This commit is contained in:
833
app-instance/backend/nanobot/web/outlook.py
Normal file
833
app-instance/backend/nanobot/web/outlook.py
Normal file
@ -0,0 +1,833 @@
|
||||
"""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")
|
||||
|
||||
|
||||
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": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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": 8},
|
||||
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": 8},
|
||||
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": 20,
|
||||
},
|
||||
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=8)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
inbox = {"value": []}
|
||||
warnings.append(f"inbox unavailable: {exc}")
|
||||
|
||||
try:
|
||||
sent = await provider.list_messages(folder="sentitems", top=8)
|
||||
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=20,
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user