第一次提交

This commit is contained in:
2026-03-13 16:40:08 +08:00
commit 0a49bcfb2d
277 changed files with 61890 additions and 0 deletions

View File

@ -0,0 +1 @@
"""Web interface for nanobot."""

View File

@ -0,0 +1,259 @@
"""File storage helpers for the web API."""
from __future__ import annotations
import json
import shutil
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import quote
def content_disposition(disposition: str, filename: str) -> str:
"""Build Content-Disposition header value, RFC 5987 encoding for non-ASCII."""
try:
filename.encode("ascii")
return f'{disposition}; filename="{filename}"'
except UnicodeEncodeError:
utf8_quoted = quote(filename)
return f"{disposition}; filename*=UTF-8''{utf8_quoted}"
from loguru import logger
def _is_safe_filename(filename: str) -> bool:
"""Check if filename is safe (no path separators or dot-prefixed)."""
return bool(filename) and "/" not in filename and "\\" not in filename and not filename.startswith(".")
def _is_safe_file_id(file_id: str) -> bool:
"""Ensure file_id contains only hex characters."""
return bool(file_id) and all(c in '0123456789abcdef' for c in file_id)
def _files_dir(workspace: Path) -> Path:
"""Return the files storage directory, creating it if needed."""
d = workspace / "files"
d.mkdir(parents=True, exist_ok=True)
return d
def generate_file_id() -> str:
"""Generate a short unique file ID (12 hex chars)."""
return uuid.uuid4().hex[:12]
def save_file(
workspace: Path,
file_id: str,
filename: str,
content: bytes,
content_type: str,
session_id: str = "web:default",
) -> dict[str, Any]:
"""Save a file to workspace/files/<file_id>/ and write metadata.json."""
if not _is_safe_filename(filename):
raise ValueError(f"Invalid filename: {filename}")
file_dir = _files_dir(workspace) / file_id
file_dir.mkdir(parents=True, exist_ok=True)
file_path = file_dir / filename
file_path.write_bytes(content)
metadata = {
"file_id": file_id,
"name": filename,
"content_type": content_type,
"size": len(content),
"created_at": datetime.now(timezone.utc).isoformat(),
"session_id": session_id,
}
(file_dir / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8")
return metadata
def get_file_metadata(workspace: Path, file_id: str) -> dict[str, Any] | None:
"""Load metadata for a file. Returns None if not found or invalid."""
if not _is_safe_file_id(file_id):
return None
meta_path = _files_dir(workspace) / file_id / "metadata.json"
if not meta_path.exists():
return None
try:
return json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError):
logger.warning(f"Corrupted metadata file: {meta_path}")
return None
def get_file_path(workspace: Path, file_id: str) -> Path | None:
"""Get the actual file path for a file_id. Returns None if not found."""
meta = get_file_metadata(workspace, file_id)
if meta is None:
return None
file_path = _files_dir(workspace) / file_id / meta["name"]
# Ensure resolved path is within files directory
try:
file_path.resolve().relative_to(_files_dir(workspace).resolve())
except ValueError:
return None
return file_path if file_path.exists() else None
def list_files(workspace: Path, session_id: str | None = None) -> list[dict[str, Any]]:
"""List all file metadata, optionally filtered by session_id."""
files_dir = _files_dir(workspace)
result = []
for entry in sorted(files_dir.iterdir()):
if not entry.is_dir():
continue
meta_path = entry / "metadata.json"
if not meta_path.exists():
continue
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError):
continue
if session_id and meta.get("session_id") != session_id:
continue
result.append(meta)
return result
def delete_file(workspace: Path, file_id: str) -> bool:
"""Delete a file and its metadata. Returns True if deleted."""
if not _is_safe_file_id(file_id):
return False
file_dir = _files_dir(workspace) / file_id
if not file_dir.exists():
return False
shutil.rmtree(file_dir)
return True
# ---------------------------------------------------------------------------
# Workspace browser helpers (browse the entire workspace directory)
# ---------------------------------------------------------------------------
import mimetypes
def _resolve_workspace_path(workspace: Path, rel_path: str) -> Path | None:
"""Resolve a relative path within workspace, rejecting traversal."""
workspace = workspace.resolve()
target = (workspace / rel_path).resolve()
try:
target.relative_to(workspace)
except ValueError:
return None
return target
def browse_workspace(workspace: Path, rel_path: str = "") -> dict[str, Any]:
"""List contents of a directory within the workspace."""
workspace = workspace.resolve()
target = _resolve_workspace_path(workspace, rel_path)
if target is None or not target.is_dir():
raise ValueError("Invalid directory path")
items: list[dict[str, Any]] = []
try:
entries = sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
except PermissionError:
raise ValueError("Permission denied")
for entry in entries:
# Skip hidden files/dirs
if entry.name.startswith("."):
continue
rel = str(entry.relative_to(workspace))
if entry.is_dir():
items.append({
"name": entry.name,
"path": rel,
"type": "directory",
"size": None,
"modified": datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc).isoformat(),
})
elif entry.is_file():
stat = entry.stat()
ct, _ = mimetypes.guess_type(entry.name)
items.append({
"name": entry.name,
"path": rel,
"type": "file",
"size": stat.st_size,
"content_type": ct or "application/octet-stream",
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
})
return {
"path": str(target.relative_to(workspace)) if target != workspace else "",
"items": items,
}
def workspace_file_path(workspace: Path, rel_path: str) -> Path | None:
"""Resolve a file path within workspace for download."""
target = _resolve_workspace_path(workspace, rel_path)
if target is None or not target.is_file():
return None
return target
def save_to_workspace(workspace: Path, rel_dir: str, filename: str, content: bytes) -> dict[str, Any]:
"""Save uploaded file to a specific directory in the workspace."""
workspace = workspace.resolve()
target_dir = _resolve_workspace_path(workspace, rel_dir)
if target_dir is None:
raise ValueError("Invalid directory path")
target_dir.mkdir(parents=True, exist_ok=True)
file_path = (target_dir / filename).resolve()
try:
file_path.relative_to(workspace)
except ValueError:
raise ValueError("Invalid filename")
file_path.write_bytes(content)
stat = file_path.stat()
ct, _ = mimetypes.guess_type(filename)
return {
"name": filename,
"path": str(file_path.relative_to(workspace)),
"type": "file",
"size": stat.st_size,
"content_type": ct or "application/octet-stream",
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
}
def delete_workspace_path(workspace: Path, rel_path: str) -> bool:
"""Delete a file or directory from the workspace."""
target = _resolve_workspace_path(workspace, rel_path)
if target is None or not target.exists():
return False
# Don't allow deleting the workspace root
if target == workspace.resolve():
return False
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
return True
def create_workspace_dir(workspace: Path, rel_path: str) -> dict[str, Any]:
"""Create a directory in the workspace."""
workspace = workspace.resolve()
target = _resolve_workspace_path(workspace, rel_path)
if target is None:
raise ValueError("Invalid directory path")
target.mkdir(parents=True, exist_ok=True)
return {
"name": target.name,
"path": str(target.relative_to(workspace)),
"type": "directory",
}

View 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)

File diff suppressed because it is too large Load Diff