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 环境变量
This commit is contained in:
@ -21,7 +21,10 @@ 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")
|
||||
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):
|
||||
@ -125,6 +128,38 @@ def _default_outlook_permissions() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
@ -708,7 +743,7 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
inbox = await _call_outlook_mcp_tool(
|
||||
config,
|
||||
"mail_list_messages",
|
||||
{"folder": "inbox", "top": 8},
|
||||
{"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||
scopes=["list_tools", "tool:mail_list_messages"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@ -718,7 +753,7 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
sent = await _call_outlook_mcp_tool(
|
||||
config,
|
||||
"mail_list_messages",
|
||||
{"folder": "sentitems", "top": 8},
|
||||
{"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||
scopes=["list_tools", "tool:mail_list_messages"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@ -731,7 +766,8 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
{
|
||||
"start_time": start_of_day.isoformat(),
|
||||
"end_time": end_of_day.isoformat(),
|
||||
"top": 20,
|
||||
"top": OUTLOOK_OVERVIEW_EVENT_LIMIT,
|
||||
"skip": 0,
|
||||
},
|
||||
scopes=["list_tools", "tool:calendar_list_events"],
|
||||
)
|
||||
@ -764,13 +800,21 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
warnings: list[str] = []
|
||||
|
||||
try:
|
||||
inbox = await provider.list_messages(folder="inbox", top=8)
|
||||
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=8)
|
||||
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}")
|
||||
@ -779,7 +823,8 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
calendar = await provider.list_events(
|
||||
start_time=start_of_day.isoformat(),
|
||||
end_time=end_of_day.isoformat(),
|
||||
top=20,
|
||||
top=OUTLOOK_OVERVIEW_EVENT_LIMIT,
|
||||
skip=0,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
calendar = {"value": []}
|
||||
@ -825,6 +870,92 @@ async def get_message_detail(
|
||||
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
|
||||
|
||||
|
||||
@ -2312,6 +2312,11 @@ def _register_routes(app: FastAPI) -> None:
|
||||
server_id = req.id.strip()
|
||||
if not server_id:
|
||||
raise HTTPException(status_code=400, detail="Server id is required")
|
||||
auth_mode = (req.auth_mode or "none").strip().lower() or "none"
|
||||
auth_audience = (req.auth_audience or "").strip()
|
||||
auth_scopes = [str(item).strip() for item in list(req.auth_scopes or []) if str(item).strip()]
|
||||
if auth_mode == "oauth_backend_token" and not auth_audience:
|
||||
auth_audience = f"mcp:{server_id}"
|
||||
|
||||
config.tools.mcp_servers[server_id] = MCPServerConfig(
|
||||
command=req.command,
|
||||
@ -2319,9 +2324,9 @@ def _register_routes(app: FastAPI) -> None:
|
||||
env=req.env,
|
||||
url=req.url,
|
||||
headers=req.headers,
|
||||
auth_mode=req.auth_mode,
|
||||
auth_audience=req.auth_audience,
|
||||
auth_scopes=req.auth_scopes,
|
||||
auth_mode=auth_mode,
|
||||
auth_audience=auth_audience,
|
||||
auth_scopes=auth_scopes,
|
||||
tool_timeout=req.tool_timeout,
|
||||
sensitive=req.sensitive,
|
||||
)
|
||||
@ -2460,6 +2465,56 @@ def _register_routes(app: FastAPI) -> None:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/integrations/outlook/messages")
|
||||
async def get_outlook_messages(
|
||||
folder: str = "inbox",
|
||||
top: int = 20,
|
||||
skip: int = 0,
|
||||
unread_only: bool = False,
|
||||
):
|
||||
from nanobot.web.outlook import OutlookIntegrationError, list_messages
|
||||
|
||||
config: Config = app.state.config
|
||||
if not folder.strip():
|
||||
raise HTTPException(status_code=400, detail="folder is required")
|
||||
try:
|
||||
return await list_messages(
|
||||
config,
|
||||
folder=folder.strip(),
|
||||
top=top,
|
||||
skip=skip,
|
||||
unread_only=unread_only,
|
||||
)
|
||||
except OutlookIntegrationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/integrations/outlook/events")
|
||||
async def get_outlook_events(
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
top: int = 20,
|
||||
skip: int = 0,
|
||||
):
|
||||
from nanobot.web.outlook import OutlookIntegrationError, list_events
|
||||
|
||||
config: Config = app.state.config
|
||||
if not start_time.strip() or not end_time.strip():
|
||||
raise HTTPException(status_code=400, detail="start_time and end_time are required")
|
||||
try:
|
||||
return await list_events(
|
||||
config,
|
||||
start_time=start_time.strip(),
|
||||
end_time=end_time.strip(),
|
||||
top=top,
|
||||
skip=skip,
|
||||
)
|
||||
except OutlookIntegrationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/integrations/outlook/message-detail")
|
||||
async def get_outlook_message_detail(message_id: str, changekey: str | None = None):
|
||||
from nanobot.web.outlook import OutlookIntegrationError, get_message_detail
|
||||
|
||||
@ -16,6 +16,7 @@ HOST_PORT=""
|
||||
PUBLIC_URL=""
|
||||
AUTHZ_BASE_URL=""
|
||||
AUTHZ_OUTLOOK_MCP_URL=""
|
||||
OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}"
|
||||
BACKEND_ID=""
|
||||
CLIENT_ID=""
|
||||
CLIENT_SECRET=""
|
||||
@ -60,6 +61,8 @@ Optional:
|
||||
--authz-base-url <url> AuthZ service base URL.
|
||||
--authz-outlook-mcp-url <url>
|
||||
Managed Outlook MCP URL for AuthZ mode.
|
||||
--outlook-mcp-server-id <id>
|
||||
Default Outlook MCP server id. Default: outlook_mcp
|
||||
--backend-id <id> Pre-assigned backend id.
|
||||
--client-id <id> Pre-assigned AuthZ client id.
|
||||
--client-secret <secret> Pre-assigned AuthZ client secret.
|
||||
@ -133,6 +136,7 @@ render_config_json() {
|
||||
API_BASE="$API_BASE" \
|
||||
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
||||
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
|
||||
OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \
|
||||
BACKEND_ID="$BACKEND_ID" \
|
||||
CLIENT_ID="$CLIENT_ID" \
|
||||
CLIENT_SECRET="$CLIENT_SECRET" \
|
||||
@ -145,12 +149,48 @@ from pathlib import Path
|
||||
|
||||
target = Path(os.environ["TARGET_PATH"])
|
||||
provider = os.environ["PROVIDER"]
|
||||
outlook_mcp_url = os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip()
|
||||
outlook_server_id = os.environ["OUTLOOK_MCP_SERVER_ID"].strip() or "outlook_mcp"
|
||||
|
||||
provider_cfg = {"apiKey": os.environ["API_KEY"]}
|
||||
api_base = os.environ["API_BASE"].strip()
|
||||
if api_base:
|
||||
provider_cfg["apiBase"] = api_base
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
default_mcp_servers = {}
|
||||
if outlook_mcp_url:
|
||||
default_mcp_servers[outlook_server_id] = {
|
||||
"command": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"url": outlook_mcp_url,
|
||||
"headers": {},
|
||||
"authMode": "oauth_backend_token",
|
||||
"authAudience": f"mcp:{outlook_server_id}",
|
||||
"authScopes": ["list_tools", *[f"tool:{name}" for name in outlook_tool_names]],
|
||||
"toolTimeout": 60,
|
||||
"sensitive": True,
|
||||
}
|
||||
|
||||
data = {
|
||||
"agents": {
|
||||
"defaults": {
|
||||
@ -163,12 +203,13 @@ data = {
|
||||
},
|
||||
"tools": {
|
||||
"restrictToWorkspace": True,
|
||||
"mcpServers": default_mcp_servers,
|
||||
},
|
||||
"authz": {
|
||||
"enabled": bool(os.environ["AUTHZ_BASE_URL"].strip()),
|
||||
"baseUrl": os.environ["AUTHZ_BASE_URL"].strip() or "http://127.0.0.1:19090",
|
||||
"requestTimeoutSeconds": 10,
|
||||
"outlookMcpUrl": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(),
|
||||
"outlookMcpUrl": outlook_mcp_url,
|
||||
},
|
||||
"backend_identity": {
|
||||
"backendId": os.environ["BACKEND_ID"].strip(),
|
||||
@ -281,6 +322,10 @@ while [[ $# -gt 0 ]]; do
|
||||
AUTHZ_OUTLOOK_MCP_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--outlook-mcp-server-id)
|
||||
OUTLOOK_MCP_SERVER_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--backend-id)
|
||||
BACKEND_ID="${2:-}"
|
||||
shift 2
|
||||
@ -448,6 +493,7 @@ RUN_ARGS=(
|
||||
-e "APP_PUBLIC_PORT=8080"
|
||||
-e "APP_FRONTEND_PORT=3000"
|
||||
-e "APP_BACKEND_PORT=18080"
|
||||
-e "NANOBOT_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
||||
--label "nano.instance.id=${INSTANCE_ID}"
|
||||
--label "nano.instance.slug=${INSTANCE_SLUG}"
|
||||
--label "nano.instance.public_url=${PUBLIC_URL}"
|
||||
|
||||
@ -19,7 +19,9 @@ import {
|
||||
import {
|
||||
connectOutlook,
|
||||
disconnectOutlook,
|
||||
getOutlookEvents,
|
||||
getOutlookMessageDetail,
|
||||
getOutlookMessages,
|
||||
getOutlookOverview,
|
||||
getOutlookStatus,
|
||||
testOutlookConnection,
|
||||
@ -27,9 +29,12 @@ import {
|
||||
import type {
|
||||
OutlookConnectionPayload,
|
||||
OutlookConnectionTestResult,
|
||||
OutlookEventListResponse,
|
||||
OutlookEventSummary,
|
||||
OutlookMessageDetail,
|
||||
OutlookMessageListResponse,
|
||||
OutlookMessageSummary,
|
||||
OutlookPageInfo,
|
||||
OutlookStatus,
|
||||
} from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -52,6 +57,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
type OutlookFormState = OutlookConnectionPayload;
|
||||
type OutlookView = 'inbox' | 'sent' | 'calendar' | 'settings';
|
||||
type OutlookMailboxView = 'inbox' | 'sent';
|
||||
|
||||
const MAILBOX_PAGE_SIZE = 20;
|
||||
const CALENDAR_PAGE_SIZE = 100;
|
||||
|
||||
const EMPTY_FORM: OutlookFormState = {
|
||||
email: '',
|
||||
@ -124,6 +133,18 @@ function formatTime(value?: string | null): string {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function buildCalendarRange(anchorKey: string): { startTime: string; endTime: string } {
|
||||
const anchor = new Date(`${anchorKey}T00:00:00`);
|
||||
const start = Number.isNaN(anchor.getTime()) ? new Date() : anchor;
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 7);
|
||||
return {
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mailboxLabel(
|
||||
mailbox?:
|
||||
| {
|
||||
@ -333,6 +354,15 @@ export default function OutlookPage() {
|
||||
const [messageLoading, setMessageLoading] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<OutlookEventSummary | null>(null);
|
||||
const [activeView, setActiveView] = useState<OutlookView>('settings');
|
||||
const [inboxPage, setInboxPage] = useState<OutlookMessageListResponse | null>(null);
|
||||
const [sentPage, setSentPage] = useState<OutlookMessageListResponse | null>(null);
|
||||
const [calendarPage, setCalendarPage] = useState<OutlookEventListResponse | null>(null);
|
||||
const [calendarAnchorKey, setCalendarAnchorKey] = useState<string>(toLocalDateKey(new Date()));
|
||||
const [mailboxLoading, setMailboxLoading] = useState<Record<OutlookMailboxView, boolean>>({
|
||||
inbox: false,
|
||||
sent: false,
|
||||
});
|
||||
const [calendarLoading, setCalendarLoading] = useState(false);
|
||||
|
||||
const applyStatus = useCallback((nextStatus: OutlookStatus, forceFormSync = false) => {
|
||||
setStatus(nextStatus);
|
||||
@ -358,6 +388,44 @@ export default function OutlookPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMailboxPage = useCallback(async (view: OutlookMailboxView, skip = 0) => {
|
||||
setMailboxLoading((current) => ({ ...current, [view]: true }));
|
||||
try {
|
||||
const nextPage = await getOutlookMessages(view === 'inbox' ? 'inbox' : 'sentitems', {
|
||||
top: MAILBOX_PAGE_SIZE,
|
||||
skip,
|
||||
});
|
||||
if (view === 'inbox') {
|
||||
setInboxPage(nextPage);
|
||||
} else {
|
||||
setSentPage(nextPage);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || `加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`);
|
||||
} finally {
|
||||
setMailboxLoading((current) => ({ ...current, [view]: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCalendarPage = useCallback(async (anchorKey: string) => {
|
||||
setCalendarLoading(true);
|
||||
try {
|
||||
const range = buildCalendarRange(anchorKey);
|
||||
const nextPage = await getOutlookEvents({
|
||||
...range,
|
||||
top: CALENDAR_PAGE_SIZE,
|
||||
skip: 0,
|
||||
});
|
||||
setCalendarPage(nextPage);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载日程失败');
|
||||
} finally {
|
||||
setCalendarLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadStatus = useCallback(async (
|
||||
background = false,
|
||||
options?: {
|
||||
@ -465,14 +533,14 @@ export default function OutlookPage() {
|
||||
label: '收件箱',
|
||||
hint: '最近接收邮件',
|
||||
icon: Inbox,
|
||||
count: overviewPending ? null : inboxCount,
|
||||
count: null,
|
||||
},
|
||||
{
|
||||
id: 'sent' as const,
|
||||
label: '发件箱',
|
||||
hint: '最近发送记录',
|
||||
icon: Send,
|
||||
count: overviewPending ? null : sentCount,
|
||||
count: null,
|
||||
},
|
||||
{
|
||||
id: 'calendar' as const,
|
||||
@ -497,6 +565,33 @@ export default function OutlookPage() {
|
||||
}
|
||||
}, [activeView, availableViews]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConfigured) {
|
||||
return;
|
||||
}
|
||||
if (activeView === 'inbox' && !inboxPage && !mailboxLoading.inbox) {
|
||||
void loadMailboxPage('inbox', 0);
|
||||
}
|
||||
if (activeView === 'sent' && !sentPage && !mailboxLoading.sent) {
|
||||
void loadMailboxPage('sent', 0);
|
||||
}
|
||||
if (activeView === 'calendar' && !calendarPage && !calendarLoading) {
|
||||
void loadCalendarPage(calendarAnchorKey);
|
||||
}
|
||||
}, [
|
||||
activeView,
|
||||
calendarAnchorKey,
|
||||
calendarLoading,
|
||||
calendarPage,
|
||||
inboxPage,
|
||||
isConfigured,
|
||||
loadCalendarPage,
|
||||
loadMailboxPage,
|
||||
mailboxLoading.inbox,
|
||||
mailboxLoading.sent,
|
||||
sentPage,
|
||||
]);
|
||||
|
||||
const updateField = <K extends keyof OutlookFormState>(key: K, value: OutlookFormState[K]) => {
|
||||
setFormDirty(true);
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
@ -524,6 +619,10 @@ export default function OutlookPage() {
|
||||
setForm((current) => ({ ...current, password: '' }));
|
||||
setFormDirty(false);
|
||||
setTestResult(null);
|
||||
setInboxPage(null);
|
||||
setSentPage(null);
|
||||
setCalendarPage(null);
|
||||
setCalendarAnchorKey(toLocalDateKey(new Date()));
|
||||
await loadStatus(true, { forceFormSync: true });
|
||||
setActiveView('inbox');
|
||||
} catch (err: any) {
|
||||
@ -542,6 +641,10 @@ export default function OutlookPage() {
|
||||
setTestResult(null);
|
||||
setSelectedMessageRef(null);
|
||||
setSelectedEvent(null);
|
||||
setInboxPage(null);
|
||||
setSentPage(null);
|
||||
setCalendarPage(null);
|
||||
setCalendarAnchorKey(toLocalDateKey(new Date()));
|
||||
setActiveView('settings');
|
||||
setFormDirty(false);
|
||||
await loadStatus(true, { forceFormSync: true });
|
||||
@ -554,6 +657,13 @@ export default function OutlookPage() {
|
||||
|
||||
const refreshOverview = async () => {
|
||||
await loadStatus(true, { preserveOverview: true });
|
||||
if (activeView === 'inbox') {
|
||||
await loadMailboxPage('inbox', inboxPage?.page.skip ?? 0);
|
||||
} else if (activeView === 'sent') {
|
||||
await loadMailboxPage('sent', sentPage?.page.skip ?? 0);
|
||||
} else if (activeView === 'calendar') {
|
||||
await loadCalendarPage(calendarAnchorKey);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -601,7 +711,7 @@ export default function OutlookPage() {
|
||||
<TopStat label="日程" value={String(eventCount)} loading={overviewPending} />
|
||||
</>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={() => void loadStatus(true)}>
|
||||
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
@ -669,10 +779,20 @@ export default function OutlookPage() {
|
||||
<MessageCard
|
||||
title="收件箱"
|
||||
icon={<MailOpen className="h-4 w-4" />}
|
||||
items={overview?.recentInbox || []}
|
||||
loading={overviewPending}
|
||||
items={inboxPage?.value || []}
|
||||
page={inboxPage?.page || null}
|
||||
loading={mailboxLoading.inbox || (activeView === 'inbox' && !inboxPage)}
|
||||
emptyLabel="还没有读取到收件箱邮件"
|
||||
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
|
||||
onRefresh={() => void loadMailboxPage('inbox', inboxPage?.page.skip ?? 0)}
|
||||
refreshing={mailboxLoading.inbox}
|
||||
onPreviousPage={() => void loadMailboxPage('inbox', Math.max(0, (inboxPage?.page.skip ?? 0) - MAILBOX_PAGE_SIZE))}
|
||||
onNextPage={() => {
|
||||
const nextSkip = inboxPage?.page.next_skip;
|
||||
if (typeof nextSkip === 'number') {
|
||||
void loadMailboxPage('inbox', nextSkip);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@ -680,21 +800,48 @@ export default function OutlookPage() {
|
||||
<MessageCard
|
||||
title="发件箱"
|
||||
icon={<Send className="h-4 w-4" />}
|
||||
items={overview?.recentSent || []}
|
||||
loading={overviewPending}
|
||||
items={sentPage?.value || []}
|
||||
page={sentPage?.page || null}
|
||||
loading={mailboxLoading.sent || (activeView === 'sent' && !sentPage)}
|
||||
emptyLabel="还没有读取到已发送邮件"
|
||||
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
|
||||
onRefresh={() => void loadMailboxPage('sent', sentPage?.page.skip ?? 0)}
|
||||
refreshing={mailboxLoading.sent}
|
||||
onPreviousPage={() => void loadMailboxPage('sent', Math.max(0, (sentPage?.page.skip ?? 0) - MAILBOX_PAGE_SIZE))}
|
||||
onNextPage={() => {
|
||||
const nextSkip = sentPage?.page.next_skip;
|
||||
if (typeof nextSkip === 'number') {
|
||||
void loadMailboxPage('sent', nextSkip);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="calendar" className="mt-0">
|
||||
<EventCard
|
||||
items={overview?.todayEvents || []}
|
||||
startDate={overview?.today}
|
||||
loading={overviewPending}
|
||||
items={calendarPage?.value || []}
|
||||
startDate={calendarAnchorKey}
|
||||
loading={calendarLoading || (activeView === 'calendar' && !calendarPage)}
|
||||
onOpen={(item) => setSelectedEvent(item)}
|
||||
onRefresh={refreshOverview}
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => void loadCalendarPage(calendarAnchorKey)}
|
||||
refreshing={calendarLoading}
|
||||
onPreviousWeek={() => {
|
||||
const next = new Date(`${calendarAnchorKey}T00:00:00`);
|
||||
next.setDate(next.getDate() - 7);
|
||||
setCalendarAnchorKey(toLocalDateKey(next));
|
||||
setCalendarPage(null);
|
||||
}}
|
||||
onNextWeek={() => {
|
||||
const next = new Date(`${calendarAnchorKey}T00:00:00`);
|
||||
next.setDate(next.getDate() + 7);
|
||||
setCalendarAnchorKey(toLocalDateKey(next));
|
||||
setCalendarPage(null);
|
||||
}}
|
||||
onCurrentWeek={() => {
|
||||
const nextKey = toLocalDateKey(new Date());
|
||||
setCalendarAnchorKey(nextKey);
|
||||
setCalendarPage(null);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@ -1037,27 +1184,29 @@ function MessageCard({
|
||||
title,
|
||||
icon,
|
||||
items,
|
||||
page,
|
||||
loading = false,
|
||||
emptyLabel,
|
||||
onOpen,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
items: OutlookMessageSummary[];
|
||||
page: OutlookPageInfo | null;
|
||||
loading?: boolean;
|
||||
emptyLabel: string;
|
||||
onOpen: (item: OutlookMessageSummary) => void;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
onPreviousPage: () => void;
|
||||
onNextPage: () => void;
|
||||
}) {
|
||||
const pageSize = 8;
|
||||
const [page, setPage] = useState(1);
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
||||
const visibleItems = items.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, totalPages]);
|
||||
const currentPage = page ? Math.floor(page.skip / Math.max(page.top, 1)) + 1 : 1;
|
||||
const pageLabel = page ? `第 ${currentPage} 页 · 本页 ${page.returned} 封` : '正在读取邮件…';
|
||||
|
||||
return (
|
||||
<Card className="rounded-[28px] shadow-sm">
|
||||
@ -1067,26 +1216,24 @@ function MessageCard({
|
||||
{icon}
|
||||
{title}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{loading ? '正在读取邮件…' : `共 ${items.length} 封`}</p>
|
||||
<p className="text-sm text-muted-foreground">{loading ? '正在读取邮件…' : pageLabel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onNextPage}
|
||||
disabled={!page || !page.has_more || refreshing}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
{!loading && totalPages > 1 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page === 1}>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{loading ? (
|
||||
@ -1106,7 +1253,7 @@ function MessageCard({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visibleItems.map((item) => (
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id || `${item.subject}-${item.receivedDateTime}`}
|
||||
type="button"
|
||||
@ -1144,6 +1291,9 @@ function EventCard({
|
||||
onOpen,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
onPreviousWeek,
|
||||
onNextWeek,
|
||||
onCurrentWeek,
|
||||
}: {
|
||||
items: OutlookEventSummary[];
|
||||
startDate?: string | null;
|
||||
@ -1151,6 +1301,9 @@ function EventCard({
|
||||
onOpen: (item: OutlookEventSummary) => void;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
onPreviousWeek: () => void;
|
||||
onNextWeek: () => void;
|
||||
onCurrentWeek: () => void;
|
||||
}) {
|
||||
const initialAnchor = startDate ? new Date(startDate) : new Date();
|
||||
const anchor = Number.isNaN(initialAnchor.getTime()) ? new Date() : initialAnchor;
|
||||
@ -1186,9 +1339,20 @@ function EventCard({
|
||||
{formatDayLabel(weekDays[0])} - {formatDayLabel(weekDays[weekDays.length - 1])}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onPreviousWeek} disabled={refreshing}>
|
||||
上一周
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}>
|
||||
本周
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}>
|
||||
下一周
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{loading ? (
|
||||
|
||||
@ -21,7 +21,9 @@ import type {
|
||||
OutlookConnectionPayload,
|
||||
OutlookConnectionTestResult,
|
||||
OutlookConnectResult,
|
||||
OutlookEventListResponse,
|
||||
OutlookMessageDetail,
|
||||
OutlookMessageListResponse,
|
||||
OutlookOverview,
|
||||
OutlookStatus,
|
||||
UiAgentDescriptor,
|
||||
@ -838,6 +840,44 @@ export async function getOutlookOverview(): Promise<OutlookOverview> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOutlookMessages(
|
||||
folder: string,
|
||||
options?: {
|
||||
top?: number;
|
||||
skip?: number;
|
||||
unreadOnly?: boolean;
|
||||
}
|
||||
): Promise<OutlookMessageListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
folder,
|
||||
top: String(options?.top ?? 20),
|
||||
skip: String(options?.skip ?? 0),
|
||||
});
|
||||
if (options?.unreadOnly) {
|
||||
params.set('unread_only', 'true');
|
||||
}
|
||||
return fetchJSON(`/api/integrations/outlook/messages?${params.toString()}`, {
|
||||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOutlookEvents(options: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
top?: number;
|
||||
skip?: number;
|
||||
}): Promise<OutlookEventListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
start_time: options.startTime,
|
||||
end_time: options.endTime,
|
||||
top: String(options.top ?? 20),
|
||||
skip: String(options.skip ?? 0),
|
||||
});
|
||||
return fetchJSON(`/api/integrations/outlook/events?${params.toString()}`, {
|
||||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOutlookMessageDetail(
|
||||
messageId: string,
|
||||
changekey?: string | null
|
||||
|
||||
@ -331,6 +331,14 @@ export interface OutlookEventSummary {
|
||||
organizer?: OutlookMailboxAddress | null;
|
||||
}
|
||||
|
||||
export interface OutlookPageInfo {
|
||||
top: number;
|
||||
skip: number;
|
||||
returned: number;
|
||||
has_more: boolean;
|
||||
next_skip?: number | null;
|
||||
}
|
||||
|
||||
export interface OutlookDefaultsFields {
|
||||
domain: string;
|
||||
service_endpoint: string;
|
||||
@ -445,6 +453,20 @@ export interface OutlookOverview {
|
||||
meta: OutlookMeta;
|
||||
}
|
||||
|
||||
export interface OutlookMessageListResponse {
|
||||
folder: string;
|
||||
unread_only: boolean;
|
||||
value: OutlookMessageSummary[];
|
||||
page: OutlookPageInfo;
|
||||
}
|
||||
|
||||
export interface OutlookEventListResponse {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
value: OutlookEventSummary[];
|
||||
page: OutlookPageInfo;
|
||||
}
|
||||
|
||||
export interface ProcessRun {
|
||||
run_id: string;
|
||||
parent_run_id?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user