Files
beaver_project/app-instance/backend/nanobot/web/outlook.py
steven_li b3767dd4ab feat(outlook): 添加 Outlook MCP 集成支持并优化分页功能
- 新增 NANO_OUTLOOK_MCP_URL 和 NANO_OUTLOOK_MCP_SERVER_ID 环境变量配置
- 实现 Outlook 邮件和日历的分页查询功能,添加安全参数验证
- 为 app-instance 创建脚本添加 Outlook MCP 服务器 ID 参数
- 更新前端 Outlook 页面实现邮件列表和日历事件的分页浏览
- 添加 Git 忽略文件配置和 Docker 挂载路径修复

BREAKING CHANGE: Outlook 集成现在需要配置 MCP URL 和服务器 ID 环境变量
2026-03-16 17:01:58 +08:00

965 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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