'use client'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { AlertCircle, CalendarDays, CheckCircle2, Inbox, Loader2, Mail, MailOpen, RefreshCw, Save, Send, Settings2, Unplug, } from 'lucide-react'; import { connectOutlook, disconnectOutlook, getOutlookEvents, getOutlookMessageDetail, getOutlookMessages, getOutlookOverview, getOutlookStatus, testOutlookConnection, } from '@/lib/api'; import type { OutlookConnectionPayload, OutlookConnectionTestResult, OutlookEventListResponse, OutlookEventSummary, OutlookMessageDetail, OutlookMessageListResponse, OutlookMessageSummary, OutlookPageInfo, OutlookStatus, } from '@/types'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import type { AppLocale } from '@/lib/i18n/core'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; 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: '', password: '', username: '', domain: '', service_endpoint: '', server: '', autodiscover: false, default_timezone: 'Asia/Shanghai', }; function toFormState(status: OutlookStatus | null): OutlookFormState { const defaults = status?.defaults.fields; const saved = status?.saved; return { email: saved?.email || '', password: '', username: saved?.username || '', domain: saved?.domain || defaults?.domain || '', service_endpoint: saved?.service_endpoint || defaults?.service_endpoint || '', server: saved?.server || defaults?.server || '', autodiscover: saved?.autodiscover ?? defaults?.autodiscover ?? false, default_timezone: saved?.default_timezone || defaults?.default_timezone || 'Asia/Shanghai', }; } function formatDateTime(value?: string | null, locale: AppLocale = 'zh-CN'): string { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).format(date); } function toLocalDateKey(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } function formatDateKey(value?: string | null): string | null { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; return toLocalDateKey(date); } function formatDayLabel(value: Date, locale: AppLocale = 'zh-CN'): string { return new Intl.DateTimeFormat(locale, { month: '2-digit', day: '2-digit', weekday: 'short', }).format(value); } function formatTime(value?: string | null, locale: AppLocale = 'zh-CN'): string { if (!value) return '-'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', }).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?: | { emailAddress?: { name?: string | null; address?: string | null; } | null; } | null ): string { const name = mailbox?.emailAddress?.name?.trim(); const address = mailbox?.emailAddress?.address?.trim(); if (name && address) return `${name} <${address}>`; return address || name || '-'; } function statusVariant(connected: boolean): 'default' | 'secondary' { return connected ? 'default' : 'secondary'; } function sanitizeEmailHtml(html: string): string { if (typeof window === 'undefined') return html; const parser = new DOMParser(); const documentRef = parser.parseFromString(html, 'text/html'); const blockedSelectors = [ 'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select', 'meta', 'link', 'base', 'img', ]; const removableFormattingAttrs = new Set([ 'style', 'class', 'id', 'bgcolor', 'color', 'face', 'width', 'height', 'cellpadding', 'cellspacing', 'align', ]); for (const selector of blockedSelectors) { documentRef.querySelectorAll(selector).forEach((node) => node.remove()); } documentRef.querySelectorAll('*').forEach((element) => { for (const attr of Array.from(element.attributes)) { const name = attr.name.toLowerCase(); const value = attr.value.trim(); if (removableFormattingAttrs.has(name)) { element.removeAttribute(attr.name); continue; } if (name.startsWith('on')) { element.removeAttribute(attr.name); continue; } if (name === 'srcdoc') { element.removeAttribute(attr.name); continue; } if ((name === 'href' || name === 'src') && value) { const normalized = value.toLowerCase(); if ( normalized.startsWith('javascript:') || normalized.startsWith('data:') || normalized.startsWith('file:') ) { element.removeAttribute(attr.name); } } } if (element.tagName.toLowerCase() === 'a') { element.setAttribute('target', '_blank'); element.setAttribute('rel', 'noreferrer noopener'); } }); return documentRef.body.innerHTML; } function buildEmailPreviewDocument(html: string, locale: AppLocale = 'zh-CN'): string { return ` ${sanitizeEmailHtml(html)} `; } function renderPlainText(content: string): React.ReactNode[] { const urlPattern = /(https?:\/\/[^\s<]+|mailto:[^\s<]+)/gi; const lines = content.split(/\r?\n/); const nodes: React.ReactNode[] = []; lines.forEach((line, lineIndex) => { const parts = line.split(urlPattern); parts.forEach((part, partIndex) => { if (!part) return; if (urlPattern.test(part)) { nodes.push( {part} ); } else { nodes.push({part}); } urlPattern.lastIndex = 0; }); if (lineIndex < lines.length - 1) { nodes.push(
); } }); return nodes; } export default function OutlookPage() { const { locale } = useAppI18n(); const t = (zh: string, en: string) => pickAppText(locale, zh, en); const [status, setStatus] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [formDirty, setFormDirty] = useState(false); const [testResult, setTestResult] = useState(null); const [overview, setOverview] = useState> | null>(null); const [statusLoading, setStatusLoading] = useState(true); const [overviewLoading, setOverviewLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [testing, setTesting] = useState(false); const [saving, setSaving] = useState(false); const [disconnecting, setDisconnecting] = useState(false); const [error, setError] = useState(null); const [selectedMessageRef, setSelectedMessageRef] = useState<{ id: string; changekey?: string | null; } | null>(null); const [selectedMessage, setSelectedMessage] = useState(null); const [messageLoading, setMessageLoading] = useState(false); const [selectedEvent, setSelectedEvent] = useState(null); const [activeView, setActiveView] = useState('settings'); const [inboxPage, setInboxPage] = useState(null); const [sentPage, setSentPage] = useState(null); const [calendarPage, setCalendarPage] = useState(null); const [calendarAnchorKey, setCalendarAnchorKey] = useState(toLocalDateKey(new Date())); const [mailboxLoading, setMailboxLoading] = useState>({ inbox: false, sent: false, }); const [calendarLoading, setCalendarLoading] = useState(false); const applyStatus = useCallback((nextStatus: OutlookStatus, forceFormSync = false) => { setStatus(nextStatus); if (forceFormSync || !formDirty) { setForm(toFormState(nextStatus)); setFormDirty(false); } }, [formDirty]); const loadOverview = useCallback(async (preserveExisting = false) => { setOverviewLoading(true); try { const nextOverview = await getOutlookOverview(); setOverview(nextOverview); setError(null); } catch (err: any) { if (!preserveExisting) { setOverview(null); } setError(err.message || t('加载 Outlook 概览失败', 'Failed to load the Outlook overview')); } finally { setOverviewLoading(false); } }, [t]); 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 || t( `加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`, `Failed to load the ${view === 'inbox' ? 'inbox' : 'sent mailbox'}` ) ); } finally { setMailboxLoading((current) => ({ ...current, [view]: false })); } }, [t]); 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 || t('加载日程失败', 'Failed to load calendar events')); } finally { setCalendarLoading(false); } }, [t]); const loadStatus = useCallback(async ( background = false, options?: { forceFormSync?: boolean; preserveOverview?: boolean; } ) => { if (background) { setRefreshing(true); } else { setStatusLoading(true); } setError(null); try { const nextStatus = await getOutlookStatus(); applyStatus(nextStatus, options?.forceFormSync); if (!background) { setStatusLoading(false); } if (nextStatus.configured) { await loadOverview(options?.preserveOverview ?? background); } else { setOverview(null); setOverviewLoading(false); } } catch (err: any) { setError(err.message || t('加载 Outlook 集成状态失败', 'Failed to load Outlook integration status')); setOverviewLoading(false); } finally { if (background) { setRefreshing(false); } else { setStatusLoading(false); } } }, [applyStatus, loadOverview, t]); useEffect(() => { void loadStatus(); }, [loadStatus]); useEffect(() => { if (!selectedMessageRef) { setSelectedMessage(null); return; } let cancelled = false; setMessageLoading(true); getOutlookMessageDetail(selectedMessageRef.id, selectedMessageRef.changekey) .then((message) => { if (!cancelled) { setSelectedMessage(message); } }) .catch((err: any) => { if (!cancelled) { setError(err.message || t('加载邮件详情失败', 'Failed to load message details')); } }) .finally(() => { if (!cancelled) { setMessageLoading(false); } }); return () => { cancelled = true; }; }, [selectedMessageRef, t]); const canTest = useMemo( () => Boolean( form.email.trim() && form.password && (form.autodiscover || (form.service_endpoint || '').trim() || (form.server || '').trim()) ), [form.autodiscover, form.email, form.password, form.server, form.service_endpoint] ); const isConfigured = Boolean(status?.configured); const isConnected = Boolean(status?.connected); const inboxCount = overview?.recentInbox.length ?? 0; const sentCount = overview?.recentSent.length ?? 0; const eventCount = overview?.todayEvents.length ?? 0; const overviewWarnings = overview?.warnings || []; const testWarnings = testResult?.warnings || []; const statusPending = statusLoading && !status; const overviewPending = overviewLoading && !overview; const availableViews = useMemo(() => { if (!isConfigured) { return [ { id: 'settings' as const, label: t('设置', 'Settings'), hint: t('配置 Outlook 连接', 'Configure the Outlook connection'), icon: Settings2, count: null, }, ]; } return [ { id: 'inbox' as const, label: t('收件箱', 'Inbox'), hint: t('最近接收邮件', 'Recently received mail'), icon: Inbox, count: null, }, { id: 'sent' as const, label: t('发件箱', 'Sent'), hint: t('最近发送记录', 'Recently sent messages'), icon: Send, count: null, }, { id: 'calendar' as const, label: t('日程', 'Calendar'), hint: t('未来 7 天', 'Next 7 days'), icon: CalendarDays, count: overviewPending ? null : eventCount, }, { id: 'settings' as const, label: t('设置', 'Settings'), hint: t('连接与状态', 'Connection and status'), icon: Settings2, count: null, }, ]; }, [eventCount, inboxCount, isConfigured, overviewPending, sentCount, t]); useEffect(() => { if (!availableViews.some((view) => view.id === activeView)) { setActiveView(availableViews[0].id); } }, [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 = (key: K, value: OutlookFormState[K]) => { setFormDirty(true); setForm((current) => ({ ...current, [key]: value })); }; const handleTest = async () => { setTesting(true); setError(null); try { const result = await testOutlookConnection(form); setTestResult(result); } catch (err: any) { setError(err.message || t('测试连接失败', 'Failed to test the connection')); setTestResult(null); } finally { setTesting(false); } }; const handleConnect = async () => { setSaving(true); setError(null); try { await connectOutlook(form); 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) { setError(err.message || t('保存 Outlook 配置失败', 'Failed to save Outlook settings')); } finally { setSaving(false); } }; const handleDisconnect = async () => { setDisconnecting(true); setError(null); try { await disconnectOutlook(); setOverview(null); 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 }); } catch (err: any) { setError(err.message || t('断开 Outlook 连接失败', 'Failed to disconnect Outlook')); } finally { setDisconnecting(false); } }; 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 (
Outlook
{statusPending ? ( <> ) : ( <> {isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')} {status?.mcp_registered ? t('MCP 已注册', 'MCP registered') : t('MCP 未注册', 'MCP not registered')} {status?.provider || 'ews'} {t('邮箱', 'Mailbox')} {overview?.mailbox || status?.saved?.email || '-'} {t('时区', 'Timezone')} {status?.saved?.default_timezone || overview?.timezone || form.default_timezone} {t('最近刷新', 'Last refreshed')} {formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)} )}
{isConfigured ? ( <> ) : null}
{error && (
{error}
)} {!error && overviewWarnings.length > 0 && ( {overviewWarnings.map((warning, index) => (
{warning}
))}
)} setActiveView(value as OutlookView)} className="space-y-6"> {availableViews.map((view) => { const Icon = view.icon; return (

{view.label}

{typeof view.count === 'number' ? (

{t(`${view.count} 条`, `${view.count} items`)}

) : null}
); })}
} items={inboxPage?.value || []} page={inboxPage?.page || null} loading={mailboxLoading.inbox || (activeView === 'inbox' && !inboxPage)} emptyLabel={t('还没有读取到收件箱邮件', 'No inbox messages have been loaded yet')} locale={locale} 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); } }} /> } items={sentPage?.value || []} page={sentPage?.page || null} loading={mailboxLoading.sent || (activeView === 'sent' && !sentPage)} emptyLabel={t('还没有读取到已发送邮件', 'No sent messages have been loaded yet')} locale={locale} 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); } }} /> setSelectedEvent(item)} 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); }} />
{t('连接设置', 'Connection settings')}
updateField('email', event.target.value)} placeholder="you@boardware.com" /> updateField('username', event.target.value)} placeholder={t('留空时默认取邮箱前缀', 'Leave blank to default to the email prefix')} /> updateField('password', event.target.value)} placeholder={t('请输入邮箱密码', 'Enter the mailbox password')} /> updateField('domain', event.target.value)} placeholder="boardware.com.mo" /> updateField('service_endpoint', event.target.value)} placeholder="https://mail.boardware.com.mo/EWS/Exchange.asmx" disabled={form.autodiscover} /> updateField('server', event.target.value)} placeholder="mail.boardware.com.mo" disabled={form.autodiscover} /> updateField('default_timezone', event.target.value)} placeholder="Asia/Shanghai" />

{t('开启后优先使用 Exchange 自动发现,不再强依赖手填 EWS URL。', 'When enabled, Exchange autodiscover is preferred so the EWS URL does not need to be entered manually.')}

updateField('autodiscover', checked)} />
{testResult && (
{t('测试成功', 'Test succeeded')} {testResult.mailbox} {t('用户名', 'Username')}: {testResult.resolved_username}
{testWarnings.length > 0 && (
{testWarnings.map((warning, index) => (
{warning}
))}
)}
)}
{t('连接状态', 'Connection status')}
{statusPending ? ( <> ) : ( <> {isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')} {status?.mcp_registered ? t('MCP 已注册', 'MCP registered') : t('MCP 未注册', 'MCP not registered')} {status?.provider || 'ews'} )}
{status?.error && (
{status.error}
)}

{t('当前存储位置', 'Current storage mode')}

{status?.storage_mode === 'authz' ? t( '当前为 AuthZ 模式。Outlook 凭据保存在 AuthZ Service,由外置 Outlook MCP 按 backend 身份读取。', 'AuthZ mode is active. Outlook credentials are stored in the AuthZ service and read by the external Outlook MCP using the backend identity.' ) : <>{t('当前为 workspace 模式。Outlook 状态文件会写入当前 workspace 的', 'Workspace mode is active. Outlook state files are written to')} state/bw_outlook_mcp.}

!open && setSelectedMessageRef(null)}> {selectedMessage?.subject || t('邮件详情', 'Message details')} {selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime, locale) : t('正在加载', 'Loading')} {messageLoading ? (
) : selectedMessage ? (
{selectedMessage.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
{t('正文', 'Body')}
{selectedMessage.body?.contentType?.toLowerCase() === 'html' ? (