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:
2026-03-16 17:01:58 +08:00
parent 04501fea22
commit b3767dd4ab
20 changed files with 1671 additions and 83 deletions

View File

@ -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

View File

@ -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

View File

@ -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}"

View File

@ -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 ? (

View File

@ -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

View File

@ -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;