Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WpywMail</title>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<script>
|
||||
// 主题记忆(默认深色):尽早设置,避免首屏闪白
|
||||
try {
|
||||
const saved = localStorage.getItem('wpywmail-theme');
|
||||
const dark = saved ? saved === 'dark' : true;
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3481
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "wpywmail-ui",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "WpywMail 客户端界面(shadcn/ui + Tailwind + Vite)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b --noCheck && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.548.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#0a0a0a"/>
|
||||
<rect x="6" y="9" width="20" height="14" rx="3" fill="none" stroke="#fafafa" stroke-width="2"/>
|
||||
<path d="M7 11.5l9 6.5 9-6.5" fill="none" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
+477
@@ -0,0 +1,477 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Menu, Moon, RefreshCw, Search, Sun, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ComposeDialog, type ComposeMode } from '@/components/mail/ComposeDialog';
|
||||
import { LoginScreen } from '@/components/mail/LoginScreen';
|
||||
import { MailDisplay } from '@/components/mail/MailDisplay';
|
||||
import { MailList } from '@/components/mail/MailList';
|
||||
import { MailSidebar, folderLabel } from '@/components/mail/MailSidebar';
|
||||
import { api, type AccountProfile, type Folder, type MailDetail, type MailSummary, type StateResponse } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Compose = { open: boolean; mode: ComposeMode; source: MailDetail | null };
|
||||
|
||||
export default function App() {
|
||||
const [booted, setBooted] = useState(false);
|
||||
const [state, setState] = useState<StateResponse | null>(null);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [folder, setFolder] = useState('INBOX');
|
||||
const [messages, setMessages] = useState<MailSummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [detail, setDetail] = useState<MailDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [compose, setCompose] = useState<Compose>({ open: false, mode: 'new', source: null });
|
||||
const [toast, setToast] = useState<{ text: string; error?: boolean } | null>(null);
|
||||
const [dark, setDark] = useState(() => document.documentElement.classList.contains('dark'));
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [profile, setProfile] = useState<AccountProfile | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const toastTimer = useRef<number | null>(null);
|
||||
|
||||
const notify = useCallback((text: string, error = false) => {
|
||||
setToast({ text, error });
|
||||
if (toastTimer.current) window.clearTimeout(toastTimer.current);
|
||||
toastTimer.current = window.setTimeout(() => setToast(null), error ? 6000 : 2600);
|
||||
}, []);
|
||||
|
||||
/* ── 主题 ─────────────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
try {
|
||||
localStorage.setItem('wpywmail-theme', dark ? 'dark' : 'light');
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}, [dark]);
|
||||
|
||||
/* ── 启动 ─────────────────────────────────────────────── */
|
||||
const loadFolders = useCallback(async (refresh = true) => {
|
||||
try {
|
||||
const { folders: next } = await api.folders(refresh);
|
||||
setFolders(next);
|
||||
return next;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 文件夹计数刷新走防抖:每刷一次要在同一条 IMAP 连接上串行跑 6 个 STATUS,
|
||||
// 点一封邮件就立刻刷会打断阅读节奏。合并到 700ms 后一次性刷新。
|
||||
const foldersTimer = useRef<number | null>(null);
|
||||
const scheduleFoldersRefresh = useCallback(() => {
|
||||
if (foldersTimer.current) window.clearTimeout(foldersTimer.current);
|
||||
foldersTimer.current = window.setTimeout(() => {
|
||||
foldersTimer.current = null;
|
||||
scheduleFoldersRefresh();
|
||||
}, 700);
|
||||
}, [loadFolders]);
|
||||
|
||||
const loadMessages = useCallback(
|
||||
async (targetFolder: string, q = query) => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
const data = await api.messages(targetFolder, { limit: 100, q: q || undefined });
|
||||
setMessages(data.messages);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setMessages([]);
|
||||
setTotal(0);
|
||||
notify(`读取列表失败:${err instanceof Error ? err.message : String(err)}`, true);
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
},
|
||||
[notify, query],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const s = await api.state();
|
||||
setState(s);
|
||||
if (s.connected && s.folders) {
|
||||
setFolders(s.folders);
|
||||
const inbox = s.folders.find((f) => f.name.toUpperCase() === 'INBOX')?.name ?? s.folders[0]?.name ?? 'INBOX';
|
||||
setFolder(inbox);
|
||||
await loadMessages(inbox, '');
|
||||
// 账号设置(显示名/会话/审计)走服务器的账号接口;拿不到就静默跳过,不影响收发信
|
||||
void api.account
|
||||
.overview()
|
||||
.then((r) => setProfile(r.profile))
|
||||
.catch(() => setProfile(null));
|
||||
}
|
||||
} catch (err) {
|
||||
notify(`初始化失败:${err instanceof Error ? err.message : String(err)}`, true);
|
||||
} finally {
|
||||
setBooted(true);
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/* ── 文件夹轮询(只刷计数,不打断阅读) ─────────────────── */
|
||||
useEffect(() => {
|
||||
if (!state?.connected) return;
|
||||
const id = window.setInterval(() => {
|
||||
if (!document.hidden) void loadFolders(true);
|
||||
}, 30000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [state?.connected, loadFolders]);
|
||||
|
||||
/* ── 打开邮件 ─────────────────────────────────────────── */
|
||||
// 正文缓存 + 相邻预取:J/K 逐封翻的时候不该每次都等一个网络往返。
|
||||
const cacheRef = useRef(new Map<number, MailDetail>());
|
||||
const prefetchingRef = useRef(new Set<number>());
|
||||
|
||||
const prefetch = useCallback(
|
||||
(uid: number, targetFolder: string) => {
|
||||
if (cacheRef.current.has(uid) || prefetchingRef.current.has(uid)) return;
|
||||
prefetchingRef.current.add(uid);
|
||||
api
|
||||
.message(uid, targetFolder)
|
||||
.then((mail) => cacheRef.current.set(uid, mail))
|
||||
.catch(() => {})
|
||||
.finally(() => prefetchingRef.current.delete(uid));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const openMail = useCallback(
|
||||
async (uid: number) => {
|
||||
setSelected(uid);
|
||||
const cached = cacheRef.current.get(uid);
|
||||
if (cached) {
|
||||
// 命中缓存:立刻显示,后台再刷新一次(已读状态等可能变了)
|
||||
setDetail(cached);
|
||||
setDetailLoading(false);
|
||||
} else {
|
||||
setDetailLoading(true);
|
||||
}
|
||||
try {
|
||||
const mail = await api.message(uid, folder);
|
||||
cacheRef.current.set(uid, mail);
|
||||
setDetail(mail);
|
||||
if (!mail.seen) {
|
||||
setMessages((prev) => prev.map((m) => (m.uid === uid ? { ...m, seen: true } : m)));
|
||||
void api.setFlags(uid, folder, { seen: true }).then(() => scheduleFoldersRefresh()).catch(() => {});
|
||||
}
|
||||
// 预取上下相邻两封,翻页时直接命中缓存
|
||||
const idx = messages.findIndex((m) => m.uid === uid);
|
||||
if (idx >= 0) {
|
||||
if (messages[idx + 1]) prefetch(messages[idx + 1].uid, folder);
|
||||
if (messages[idx - 1]) prefetch(messages[idx - 1].uid, folder);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cached) {
|
||||
setDetail(null);
|
||||
notify(`打开失败:${err instanceof Error ? err.message : String(err)}`, true);
|
||||
}
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[folder, loadFolders, messages, notify, prefetch],
|
||||
);
|
||||
|
||||
const selectFolder = useCallback(
|
||||
async (name: string) => {
|
||||
setFolder(name);
|
||||
setQuery('');
|
||||
setSearchInput('');
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
setSidebarOpen(false);
|
||||
await loadMessages(name, '');
|
||||
},
|
||||
[loadMessages],
|
||||
);
|
||||
|
||||
/* ── 动作 ─────────────────────────────────────────────── */
|
||||
const afterMutate = useCallback(
|
||||
async (uid: number, message: string) => {
|
||||
setMessages((prev) => prev.filter((m) => m.uid !== uid));
|
||||
if (selected === uid) {
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
}
|
||||
notify(message);
|
||||
scheduleFoldersRefresh();
|
||||
},
|
||||
[notify, scheduleFoldersRefresh, selected],
|
||||
);
|
||||
|
||||
const toggleFlag = useCallback(async () => {
|
||||
if (selected == null) return;
|
||||
const current = messages.find((m) => m.uid === selected)?.flagged ?? detail?.flagged ?? false;
|
||||
try {
|
||||
await api.setFlags(selected, folder, { flagged: !current });
|
||||
setMessages((prev) => prev.map((m) => (m.uid === selected ? { ...m, flagged: !current } : m)));
|
||||
setDetail((d) => (d && d.uid === selected ? { ...d, flagged: !current } : d));
|
||||
notify(!current ? '已加旗标' : '已取消旗标');
|
||||
} catch (err) {
|
||||
notify(`操作失败:${err instanceof Error ? err.message : String(err)}`, true);
|
||||
}
|
||||
}, [detail?.flagged, folder, messages, notify, selected]);
|
||||
|
||||
const toggleSeen = useCallback(async () => {
|
||||
if (selected == null) return;
|
||||
const current = messages.find((m) => m.uid === selected)?.seen ?? detail?.seen ?? true;
|
||||
try {
|
||||
await api.setFlags(selected, folder, { seen: !current });
|
||||
setMessages((prev) => prev.map((m) => (m.uid === selected ? { ...m, seen: !current } : m)));
|
||||
setDetail((d) => (d && d.uid === selected ? { ...d, seen: !current } : d));
|
||||
scheduleFoldersRefresh();
|
||||
notify(!current ? '已标为已读' : '已标为未读');
|
||||
} catch (err) {
|
||||
notify(`操作失败:${err instanceof Error ? err.message : String(err)}`, true);
|
||||
}
|
||||
}, [detail?.seen, folder, loadFolders, messages, notify, selected]);
|
||||
|
||||
const archive = useCallback(async () => {
|
||||
if (selected == null) return;
|
||||
try {
|
||||
await api.move(selected, folder, 'Archive');
|
||||
await afterMutate(selected, '已归档');
|
||||
} catch (err) {
|
||||
notify(`归档失败:${err instanceof Error ? err.message : String(err)}`, true);
|
||||
}
|
||||
}, [afterMutate, folder, notify, selected]);
|
||||
|
||||
const remove = useCallback(async () => {
|
||||
if (selected == null) return;
|
||||
try {
|
||||
const out = await api.remove(selected, folder);
|
||||
await afterMutate(selected, out.moved ? `已移入 ${folderLabel(out.folder || 'Trash')}` : '已删除');
|
||||
} catch (err) {
|
||||
notify(`删除失败:${err instanceof Error ? err.message : String(err)}`, true);
|
||||
}
|
||||
}, [afterMutate, folder, notify, selected]);
|
||||
|
||||
const openCompose = useCallback((mode: ComposeMode, source: MailDetail | null = null) => {
|
||||
setCompose({ open: true, mode, source });
|
||||
}, []);
|
||||
|
||||
const quickReply = useCallback(
|
||||
async (body: string) => {
|
||||
if (!detail) return;
|
||||
const target = detail.replyTo?.[0]?.address ?? detail.from?.[0]?.address ?? '';
|
||||
try {
|
||||
const out = await api.send({
|
||||
to: target,
|
||||
subject: /^re:/i.test(detail.subject) ? detail.subject : `Re: ${detail.subject}`,
|
||||
text: body,
|
||||
inReplyTo: detail.messageId ?? undefined,
|
||||
references: detail.references ?? detail.messageId ?? undefined,
|
||||
});
|
||||
notify(`已回复 ${out.recipients.join('、')}`);
|
||||
scheduleFoldersRefresh();
|
||||
} catch (err) {
|
||||
notify(`发送失败:${err instanceof Error ? err.message : String(err)}`, true);
|
||||
}
|
||||
},
|
||||
[detail, notify, scheduleFoldersRefresh],
|
||||
);
|
||||
|
||||
/* ── 键盘 ─────────────────────────────────────────────── */
|
||||
const move = useCallback(
|
||||
(delta: number) => {
|
||||
if (messages.length === 0) return;
|
||||
const idx = messages.findIndex((m) => m.uid === selected);
|
||||
const next = Math.min(Math.max((idx < 0 ? (delta > 0 ? -1 : messages.length) : idx) + delta, 0), messages.length - 1);
|
||||
const uid = messages[next].uid;
|
||||
void openMail(uid);
|
||||
document.querySelector(`[data-uid="${uid}"]`)?.scrollIntoView({ block: 'nearest' });
|
||||
},
|
||||
[messages, openMail, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const typing = !!target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName);
|
||||
if (compose.open) return;
|
||||
if (e.key === 'Escape') {
|
||||
if (typing) target?.blur();
|
||||
else setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
if (typing) return;
|
||||
if (e.key === 'j' || e.key === 'ArrowDown') return move(1);
|
||||
if (e.key === 'k' || e.key === 'ArrowUp') return move(-1);
|
||||
if (e.key === 'Enter' && selected != null) return void openMail(selected);
|
||||
if (e.key === 'c') return openCompose('new');
|
||||
if (e.key === '/') {
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (!detail) return;
|
||||
if (e.key === 'r') return openCompose('reply', detail);
|
||||
if (e.key === 'f') return openCompose('forward', detail);
|
||||
if (e.key === 's') return void toggleFlag();
|
||||
if (e.key === 'u') return void toggleSeen();
|
||||
if (e.key === 'e') return void archive();
|
||||
if (e.key === '#') return void remove();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [archive, compose.open, detail, move, openCompose, openMail, remove, selected, toggleFlag, toggleSeen]);
|
||||
|
||||
const account = useMemo(
|
||||
() => ({
|
||||
user: state?.account.user ?? '',
|
||||
displayName: state?.account.displayName || state?.account.user || '',
|
||||
host: state?.account.host ?? '',
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
/* ── 渲染 ─────────────────────────────────────────────── */
|
||||
if (!booted) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">正在连接邮件服务器…</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state?.connected && !state?.account.hasPassword) {
|
||||
return (
|
||||
<LoginScreen
|
||||
initial={{ host: state?.account.host ?? 'mail.example.com', user: state?.account.user ?? '', displayName: '' }}
|
||||
error={state?.error}
|
||||
onSuccess={async () => {
|
||||
const s = await api.state();
|
||||
setState(s);
|
||||
if (s.folders) {
|
||||
setFolders(s.folders);
|
||||
const inbox = s.folders.find((f) => f.name.toUpperCase() === 'INBOX')?.name ?? 'INBOX';
|
||||
setFolder(inbox);
|
||||
await loadMessages(inbox, '');
|
||||
}
|
||||
notify('已连接');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background text-foreground flex h-full overflow-hidden">
|
||||
{/* 侧栏(窄屏收进抽屉) */}
|
||||
<MailSidebar
|
||||
folders={folders}
|
||||
current={folder}
|
||||
account={account}
|
||||
profile={profile}
|
||||
onSelect={(n) => void selectFolder(n)}
|
||||
onCompose={() => openCompose('new')}
|
||||
onDisplayName={(name) => setState((s) => (s ? { ...s, account: { ...s.account, displayName: name } } : s))}
|
||||
className={cn('w-64 shrink-0', 'max-lg:absolute max-lg:z-30 max-lg:h-full', !sidebarOpen && 'max-lg:hidden')}
|
||||
/>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="flex min-h-0 w-[380px] shrink-0 flex-col max-lg:w-[340px] max-md:w-full">
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3">
|
||||
<Button variant="ghost" size="icon" className="lg:hidden" onClick={() => setSidebarOpen((v) => !v)}>
|
||||
<Menu className="size-4" />
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setQuery(searchInput.trim());
|
||||
void loadMessages(folder, searchInput.trim());
|
||||
}
|
||||
}}
|
||||
placeholder="搜索主题 / 发件人(回车)"
|
||||
className="h-9 pl-8"
|
||||
/>
|
||||
{(query || searchInput) && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2"
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
setQuery('');
|
||||
void loadMessages(folder, '');
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="刷新"
|
||||
onClick={() => {
|
||||
void loadMessages(folder, query);
|
||||
void loadFolders(true);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" title="切换主题" onClick={() => setDark((v) => !v)}>
|
||||
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<MailList
|
||||
messages={messages}
|
||||
total={total}
|
||||
selectedUid={selected}
|
||||
loading={listLoading}
|
||||
query={query}
|
||||
folderLabel={folderLabel(folder)}
|
||||
onSelect={(uid) => void openMail(uid)}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 阅读区 */}
|
||||
<MailDisplay
|
||||
mail={detail}
|
||||
loading={detailLoading}
|
||||
onArchive={() => void archive()}
|
||||
onDelete={() => void remove()}
|
||||
onToggleFlag={() => void toggleFlag()}
|
||||
onToggleSeen={() => void toggleSeen()}
|
||||
onReply={() => openCompose('reply', detail)}
|
||||
onForward={() => openCompose('forward', detail)}
|
||||
onQuickReply={quickReply}
|
||||
/>
|
||||
|
||||
<ComposeDialog
|
||||
open={compose.open}
|
||||
mode={compose.mode}
|
||||
source={compose.source}
|
||||
selfAddress={account.user}
|
||||
onClose={() => setCompose((c) => ({ ...c, open: false }))}
|
||||
onSent={(info) => {
|
||||
notify(`已发送给 ${info.recipients.join('、')}`);
|
||||
scheduleFoldersRefresh();
|
||||
if (folder.toUpperCase() === 'SENT') void loadMessages(folder, query);
|
||||
}}
|
||||
onError={(m) => notify(m, true)}
|
||||
/>
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 slide-in-from-bottom-2 fixed bottom-6 left-1/2 z-50 max-w-[min(560px,90vw)] -translate-x-1/2 rounded-lg border px-4 py-3 text-sm shadow-lg',
|
||||
toast.error && 'border-destructive/40',
|
||||
)}
|
||||
>
|
||||
{toast.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Settings2, ShieldCheck, LogOut, Save, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { api, type AccountProfile, type AccountSession, type AuditEvent } from '@/lib/api';
|
||||
|
||||
const REASON_LABEL: Record<string, string> = {
|
||||
register: '注册',
|
||||
'register-verify': '注册验证通过',
|
||||
'login-ok': '登录成功',
|
||||
'login-failed': '登录失败',
|
||||
'login-locked': '登录被锁定拦截',
|
||||
'reset-request': '申请重置密码',
|
||||
'reset-ok': '密码已重置',
|
||||
'password-changed': '修改密码',
|
||||
'profile-updated': '修改资料',
|
||||
'session-revoked': '退出其他设备',
|
||||
'code-sent': '重发验证码',
|
||||
'code-failed': '验证码输错',
|
||||
'account-purged': '账号被删除',
|
||||
};
|
||||
|
||||
/**
|
||||
* 账号设置:显示名、会话(在哪登录过 / 退出其他设备)、改密码、最近的安全记录。
|
||||
*
|
||||
* 这些走服务器的「账号接口」(客户端后端按需用 IMAP 凭据换一个 token),
|
||||
* 所以服务器没开这个入口时这里会明确报错,但**不影响正常的收发信**。
|
||||
*/
|
||||
export function AccountDialog({ account, onDisplayName }: { account: AccountProfile | null; onDisplayName?: (name: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [profile, setProfile] = useState<AccountProfile | null>(null);
|
||||
const [sessions, setSessions] = useState<AccountSession[]>([]);
|
||||
const [audit, setAudit] = useState<AuditEvent[]>([]);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.account.overview();
|
||||
setProfile(r.profile);
|
||||
setSessions(r.sessions || []);
|
||||
setAudit(r.audit || []);
|
||||
setDisplayName((r.profile && r.profile.displayName) || '');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load();
|
||||
}, [open]);
|
||||
|
||||
async function saveName() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const r = await api.account.updateProfile(displayName);
|
||||
setProfile(r.user);
|
||||
onDisplayName?.(r.user.displayName);
|
||||
setNotice('显示名已更新。');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const r = await api.account.changePassword({ currentPassword, password: newPassword });
|
||||
setNotice(`密码已修改,其他设备被退出(${r.revokedSessions} 个会话)。`);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeOthers() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const r = await api.account.revokeSessions({});
|
||||
setNotice(`已退出其他设备(${r.revoked} 个会话)。`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
id="account-settings"
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs transition-colors"
|
||||
title="账号设置"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
账号设置
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>账号设置</DialogTitle>
|
||||
<DialogDescription>
|
||||
{profile?.email || account?.email || '当前账号'} · 登录 {sessions.length} 个设备
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<div className="border-destructive/40 bg-destructive/5 text-destructive rounded-md border px-3 py-2 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="border-primary/30 bg-primary/5 rounded-md border px-3 py-2 text-sm">{notice}</div>}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">显示名</h3>
|
||||
<div className="flex gap-2">
|
||||
<Input id="account-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="发件时显示的名字" />
|
||||
<Button type="button" onClick={saveName} disabled={busy} className="shrink-0">
|
||||
<Save className="size-4" />
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="inline-flex items-center gap-1.5 text-sm font-medium">
|
||||
<ShieldCheck className="size-4" />
|
||||
登录设备
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void load()} disabled={busy || loading}>
|
||||
<RefreshCw className={loading ? 'size-3.5 animate-spin' : 'size-3.5'} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={revokeOthers} disabled={busy || sessions.length < 2}>
|
||||
<LogOut className="size-3.5" />
|
||||
退出其他设备
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2" id="session-list">
|
||||
{sessions.length === 0 && <li className="text-muted-foreground text-sm">{loading ? '读取中…' : '暂无会话记录'}</li>}
|
||||
{sessions.map((s) => (
|
||||
<li key={s.token} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-xs">{s.tokenPrefix}…</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
创建 {fmt(s.createdAt)} · 到期 {fmt(s.expiresAt)}
|
||||
</div>
|
||||
</div>
|
||||
{s.current && <span className="bg-primary/10 text-primary shrink-0 rounded-full px-2 py-0.5 text-xs">当前</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">修改密码</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Input
|
||||
id="account-current-pass"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="当前密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<Input
|
||||
id="account-new-pass"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="新密码(至少 12 位)"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={changePassword}
|
||||
disabled={busy || !currentPassword || newPassword.length < 12}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
修改密码
|
||||
</Button>
|
||||
<p className="text-muted-foreground text-xs">改完会自动更新本机保存的密码,并把其他设备踢下线。</p>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">最近的安全记录</h3>
|
||||
<ul className="space-y-1 text-xs" id="audit-list">
|
||||
{audit.length === 0 && <li className="text-muted-foreground">{loading ? '读取中…' : '暂无记录'}</li>}
|
||||
{audit.slice(0, 12).map((e, i) => (
|
||||
<li key={`${e.at}-${i}`} className="flex items-center justify-between gap-3">
|
||||
<span className={e.success ? '' : 'text-destructive'}>
|
||||
{REASON_LABEL[e.reason] || e.reason}
|
||||
{e.detail ? ` · ${e.detail}` : ''}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 font-mono">{fmt(e.at)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Paperclip, Send, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { api, type MailDetail } from '@/lib/api';
|
||||
import { formatSize } from '@/lib/utils';
|
||||
|
||||
export type ComposeMode = 'new' | 'reply' | 'forward';
|
||||
|
||||
type Pending = { filename: string; contentType: string; base64: string; size: number };
|
||||
|
||||
async function fileToBase64(file: File): Promise<string> {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
let binary = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < buf.length; i += chunk) {
|
||||
binary += String.fromCharCode(...buf.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function ComposeDialog({
|
||||
open,
|
||||
mode,
|
||||
source,
|
||||
selfAddress,
|
||||
onClose,
|
||||
onSent,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: ComposeMode;
|
||||
source: MailDetail | null;
|
||||
selfAddress: string;
|
||||
onClose: () => void;
|
||||
onSent: (info: { recipients: string[]; bytes: number }) => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [to, setTo] = useState('');
|
||||
const [cc, setCc] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [text, setText] = useState('');
|
||||
const [pending, setPending] = useState<Pending[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState('');
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 打开时按模式预填(回复/转发)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStatus('');
|
||||
setPending([]);
|
||||
if (mode === 'reply' && source) {
|
||||
const src = source.replyTo?.[0] ?? source.from?.[0];
|
||||
setTo(src?.address ?? '');
|
||||
setSubject(/^re:/i.test(source.subject) ? source.subject : `Re: ${source.subject}`);
|
||||
setText(
|
||||
`\n\n——— 原邮件 ———\n发件人:${source.from.map((a) => a.name || a.address).join('、')}\n时间:${source.date ?? ''}\n\n${source.text}`,
|
||||
);
|
||||
} else if (mode === 'forward' && source) {
|
||||
setTo('');
|
||||
setSubject(/^fwd:/i.test(source.subject) ? source.subject : `Fwd: ${source.subject}`);
|
||||
setText(
|
||||
`\n\n——— 转发的邮件 ———\n发件人:${source.from.map((a) => a.name || a.address).join('、')}\n收件人:${source.to
|
||||
.map((a) => a.name || a.address)
|
||||
.join('、')}\n时间:${source.date ?? ''}\n主题:${source.subject}\n\n${source.text}`,
|
||||
);
|
||||
} else {
|
||||
setTo('');
|
||||
setCc('');
|
||||
setSubject('');
|
||||
setText('');
|
||||
}
|
||||
}, [open, mode, source]);
|
||||
|
||||
async function addFiles(files: FileList | null) {
|
||||
if (!files?.length) return;
|
||||
const next: Pending[] = [];
|
||||
for (const f of Array.from(files)) {
|
||||
next.push({
|
||||
filename: f.name,
|
||||
contentType: f.type || 'application/octet-stream',
|
||||
base64: await fileToBase64(f),
|
||||
size: f.size,
|
||||
});
|
||||
}
|
||||
setPending((prev) => [...prev, ...next]);
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!to.trim()) {
|
||||
onError('请填写收件人');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setStatus('发送中…');
|
||||
try {
|
||||
const out = await api.send({
|
||||
to,
|
||||
cc,
|
||||
subject,
|
||||
text,
|
||||
inReplyTo: mode === 'reply' ? source?.messageId ?? undefined : undefined,
|
||||
references: mode === 'reply' ? source?.references ?? source?.messageId ?? undefined : undefined,
|
||||
attachments: pending.map(({ filename, contentType, base64 }) => ({ filename, contentType, base64 })),
|
||||
});
|
||||
onSent({ recipients: out.recipients, bytes: out.bytes });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setStatus('');
|
||||
onError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function draft() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.saveDraft({ to, subject, text });
|
||||
setStatus('已存入草稿');
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const title = mode === 'reply' ? '回复' : mode === 'forward' ? '转发' : '新邮件';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-2xl" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-[64px_1fr] items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">收件人</span>
|
||||
<Input value={to} onChange={(e) => setTo(e.target.value)} placeholder="[email protected]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[64px_1fr] items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">抄送</span>
|
||||
<Input value={cc} onChange={(e) => setCc(e.target.value)} placeholder="可选,多个用逗号分隔" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[64px_1fr] items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">主题</span>
|
||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="写点什么…(Ctrl+Enter 发送)"
|
||||
className="min-h-[220px] resize-y"
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{pending.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pending.map((p, i) => (
|
||||
<span key={`${p.filename}-${i}`} className="bg-muted flex items-center gap-2 rounded-md px-2 py-1 text-xs">
|
||||
<Paperclip className="size-3.5" />
|
||||
<span className="max-w-[200px] truncate">{p.filename}</span>
|
||||
<span className="text-muted-foreground">{formatSize(p.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setPending((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => void addFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()} disabled={busy}>
|
||||
<Paperclip className="size-4" />
|
||||
添加附件
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => void draft()} disabled={busy}>
|
||||
存草稿
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">{status}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground hidden text-xs sm:inline">发件人 {selfAddress}</span>
|
||||
<Button size="sm" onClick={() => void send()} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Mail, UserPlus, KeyRound, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { api, type AccountPolicy } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* 登录屏:本地没有可用凭据、或连接失败时出现。
|
||||
*
|
||||
* 三种模式共用一个卡片:
|
||||
* 登录 —— 走 IMAP(本机服务保存凭据)
|
||||
* 注册 —— 走服务器的「账号接口」公网入口(/api/account/*),支持邀请码与邮箱验证码
|
||||
* 找回 —— 同上,验证码邮件会投进你自己的信箱
|
||||
*
|
||||
* 注册/找回需要的是服务器 v2.2.0 的账号接口;拿不到策略时这两个入口会给出明确提示,
|
||||
* 不影响正常登录(登录只依赖 IMAP)。
|
||||
*/
|
||||
export function LoginScreen({
|
||||
initial,
|
||||
error,
|
||||
onSuccess,
|
||||
}: {
|
||||
initial?: { host: string; user: string; displayName: string };
|
||||
error?: string | null;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<'login' | 'register' | 'forgot'>('login');
|
||||
const [host, setHost] = useState(initial?.host || 'mail.example.com');
|
||||
const [user, setUser] = useState(initial?.user || '');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState(initial?.displayName || '');
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(error ?? null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [policy, setPolicy] = useState<AccountPolicy | null>(null);
|
||||
const [policyError, setPolicyError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'login' || policy || policyError) return;
|
||||
api.account
|
||||
.policy()
|
||||
.then((r) => setPolicy(r.policy))
|
||||
.catch((err) => setPolicyError(err instanceof Error ? err.message : String(err)));
|
||||
}, [mode, policy, policyError]);
|
||||
|
||||
const allowed = policy
|
||||
? Array.isArray(policy.allowedDomains)
|
||||
? policy.allowedDomains.join(' / ')
|
||||
: String(policy.allowedDomains || '')
|
||||
: '';
|
||||
|
||||
async function submitLogin(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.login({ host, user, password, displayName, save: true });
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRegister(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const r = await api.account.register({ email: user, password, displayName, inviteCode });
|
||||
if (r.verificationRequired) {
|
||||
setCodeSent(true);
|
||||
setNotice(`验证码已发到 ${r.email}(${r.expiresInMinutes} 分钟内有效)。请填入下方验证码完成注册。`);
|
||||
} else {
|
||||
// 本机托管的邮箱免邮箱验证:注册即开通,直接连上去
|
||||
setNotice('账号已创建,正在登录…');
|
||||
await api.login({ host, user, password, displayName, save: true });
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVerify(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.account.verify({ email: user, code });
|
||||
await api.login({ host, user, password, displayName, save: true });
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForgot(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const r = await api.account.forgot(user);
|
||||
setCodeSent(true);
|
||||
setNotice(`如果这个邮箱在本服务器上,重置验证码已经发出(${r.expiresInMinutes} 分钟内有效)。`);
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReset(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.account.reset({ email: user, code, password });
|
||||
setNotice('密码已重置,正在登录…');
|
||||
await api.login({ host, user, password, displayName, save: true });
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.account.resend({ email: user, purpose: mode === 'forgot' ? 'reset' : 'register' });
|
||||
setNotice('验证码已重新发送。');
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const title = mode === 'login' ? '登录你的邮箱' : mode === 'register' ? '注册新邮箱' : '找回密码';
|
||||
const Icon = mode === 'login' ? Mail : mode === 'register' ? UserPlus : KeyRound;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center bg-muted/30 p-6">
|
||||
<div className="bg-card w-full max-w-md rounded-xl border p-8 shadow-sm">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="bg-primary text-primary-foreground flex size-10 items-center justify-center rounded-lg">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold">WpywMail</h1>
|
||||
<p className="text-muted-foreground truncate text-sm">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode !== 'login' && (
|
||||
<div className="bg-muted mb-5 grid grid-cols-2 gap-1 rounded-lg p-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md py-1.5 transition ${mode === 'register' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||
onClick={() => {
|
||||
setMode('register');
|
||||
setCodeSent(false);
|
||||
setMessage(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
>
|
||||
注册新账号
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md py-1.5 transition ${mode === 'forgot' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||
onClick={() => {
|
||||
setMode('forgot');
|
||||
setCodeSent(false);
|
||||
setMessage(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
>
|
||||
忘记密码
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode !== 'login' && policy && (
|
||||
<div className="text-muted-foreground bg-muted/50 mb-5 space-y-1 rounded-md px-3 py-2 text-xs">
|
||||
<div>
|
||||
{policy.registration === 'closed'
|
||||
? '本服务器已关闭自助注册,请联系管理员开设账号。'
|
||||
: policy.inviteRequired
|
||||
? '本服务器需要邀请码才能注册。'
|
||||
: '本服务器开放自助注册。'}
|
||||
{allowed && ` 允许的邮箱域名:${allowed}。`}
|
||||
</div>
|
||||
<div>密码至少 {policy.minPasswordLength} 位。</div>
|
||||
{policy.verificationNote && <div>{policy.verificationNote}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode !== 'login' && policyError && (
|
||||
<div className="border-destructive/40 bg-destructive/5 text-destructive mb-5 rounded-md border px-3 py-2 text-xs">
|
||||
取不到服务器的注册策略:{policyError}
|
||||
<br />
|
||||
注册与找回密码需要服务器的「账号接口」(默认 https://mail.example.com:9443)。仍可直接用下方「登录」。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div className="border-primary/30 bg-primary/5 mb-5 rounded-md border px-3 py-2 text-sm">{notice}</div>
|
||||
)}
|
||||
|
||||
{mode === 'login' && (
|
||||
<form className="space-y-4" onSubmit={submitLogin}>
|
||||
<Field label="邮箱账号" id="login-user">
|
||||
<Input
|
||||
id="login-user"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="密码" id="login-pass">
|
||||
<Input
|
||||
id="login-pass"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="服务器" id="login-host">
|
||||
<Input id="login-host" value={host} onChange={(e) => setHost(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="显示名" id="login-name">
|
||||
<Input
|
||||
id="login-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="可选"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{message && <ErrorBox text={message} />}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
type="button"
|
||||
id="to-register"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline"
|
||||
onClick={() => {
|
||||
setMode('register');
|
||||
setMessage(null);
|
||||
}}
|
||||
>
|
||||
注册新账号 / 忘记密码
|
||||
</button>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{busy ? '连接中…' : '连接'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === 'register' && !codeSent && (
|
||||
<form className="space-y-4" onSubmit={submitRegister}>
|
||||
<Field label="要注册的邮箱" id="reg-user">
|
||||
<Input
|
||||
id="reg-user"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
placeholder={allowed ? `you@${String(allowed).split(' / ')[0].replace('@', '')}` : '[email protected]'}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label={`密码(至少 ${policy?.minPasswordLength ?? 12} 位)`} id="reg-pass">
|
||||
<Input
|
||||
id="reg-pass"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="显示名" id="reg-name">
|
||||
<Input id="reg-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="可选" />
|
||||
</Field>
|
||||
<Field label={policy?.inviteRequired ? '邀请码(必填)' : '邀请码'} id="reg-invite">
|
||||
<Input
|
||||
id="reg-invite"
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value)}
|
||||
placeholder={policy?.inviteRequired ? '向管理员索取' : '可选'}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="IMAP 服务器" id="reg-host">
|
||||
<Input id="reg-host" value={host} onChange={(e) => setHost(e.target.value)} required />
|
||||
</Field>
|
||||
{message && <ErrorBox text={message} />}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<BackLink onClick={() => setMode('login')} />
|
||||
<Button type="submit" id="reg-submit" disabled={busy || policy?.registration === 'closed'}>
|
||||
{busy ? '提交中…' : '注册'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === 'register' && codeSent && (
|
||||
<form className="space-y-4" onSubmit={submitVerify}>
|
||||
<Field label="邮箱验证码" id="reg-code">
|
||||
<Input
|
||||
id="reg-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="6 位数字"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
{message && <ErrorBox text={message} />}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline"
|
||||
onClick={resend}
|
||||
disabled={busy}
|
||||
>
|
||||
重新发送验证码
|
||||
</button>
|
||||
<Button type="submit" id="reg-verify" disabled={busy}>
|
||||
{busy ? '验证中…' : '完成注册'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === 'forgot' && !codeSent && (
|
||||
<form className="space-y-4" onSubmit={submitForgot}>
|
||||
<Field label="你的邮箱" id="forgot-user">
|
||||
<Input
|
||||
id="forgot-user"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
{message && <ErrorBox text={message} />}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<BackLink onClick={() => setMode('login')} />
|
||||
<Button type="submit" id="forgot-submit" disabled={busy}>
|
||||
{busy ? '发送中…' : '发送重置码'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === 'forgot' && codeSent && (
|
||||
<form className="space-y-4" onSubmit={submitReset}>
|
||||
<Field label="邮件里的 6 位验证码" id="reset-code">
|
||||
<Input
|
||||
id="reset-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="6 位数字"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label={`新密码(至少 ${policy?.minPasswordLength ?? 12} 位)`} id="reset-pass">
|
||||
<Input
|
||||
id="reset-pass"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
{message && <ErrorBox text={message} />}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline"
|
||||
onClick={resend}
|
||||
disabled={busy}
|
||||
>
|
||||
重新发送
|
||||
</button>
|
||||
<Button type="submit" id="reset-submit" disabled={busy}>
|
||||
{busy ? '重置中…' : '重置并登录'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground mt-6 flex items-center justify-between text-xs">
|
||||
<span>IMAP 993 · SMTP 587</span>
|
||||
{mode === 'login' && <span>账号接口仅用于注册与安全设置</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBox({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="border-destructive/40 bg-destructive/5 text-destructive rounded-md border px-3 py-2 text-sm">{text}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-4 hover:underline"
|
||||
onClick={onClick}
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
返回登录
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Archive,
|
||||
CornerUpLeft,
|
||||
CornerUpRight,
|
||||
Download,
|
||||
Flag,
|
||||
Mail as MailIcon,
|
||||
MailOpen,
|
||||
Reply,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { api, type MailDetail } from '@/lib/api';
|
||||
import { avatarHue, cn, formatFullDate, formatSize, initials } from '@/lib/utils';
|
||||
|
||||
function HtmlBody({
|
||||
doc,
|
||||
blocked,
|
||||
sanitized,
|
||||
showImages,
|
||||
onShowImages,
|
||||
}: {
|
||||
doc: string | null;
|
||||
blocked: string[];
|
||||
sanitized: string[];
|
||||
showImages: boolean;
|
||||
onShowImages: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[320px] flex-col gap-2">
|
||||
{(blocked.length > 0 || sanitized.length > 0) && (
|
||||
<div className="bg-muted/60 text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md px-3 py-2 text-xs">
|
||||
{blocked.length > 0 && (
|
||||
<span>
|
||||
已拦截 {blocked.length} 张远程图片(发件人可借此知道你在什么时候打开了邮件)
|
||||
</span>
|
||||
)}
|
||||
{blocked.length > 0 && !showImages && (
|
||||
<button
|
||||
type="button"
|
||||
id="show-images"
|
||||
onClick={onShowImages}
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
仍要显示
|
||||
</button>
|
||||
)}
|
||||
{sanitized.length > 0 && <span>已清理:{sanitized.join('、')}</span>}
|
||||
</div>
|
||||
)}
|
||||
{doc ? (
|
||||
// ⚠ 安全边界:sandbox 里**不给 allow-scripts**,邮件里的脚本一律执行不了。
|
||||
// 服务端已经净化过一遍,这里是第二层;allow-popups 只为让正文里的链接能点开。
|
||||
<iframe
|
||||
id="html-body"
|
||||
title="邮件正文"
|
||||
sandbox="allow-popups"
|
||||
srcDoc={doc}
|
||||
className="bg-background h-[60vh] min-h-[280px] w-full rounded-md border"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex min-h-[200px] items-center justify-center text-sm">正在渲染正文…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolButton({
|
||||
label,
|
||||
shortcut,
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={onClick} className={className} aria-label={label}>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{label}
|
||||
{shortcut ? ` · ${shortcut}` : ''}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailDisplay({
|
||||
mail,
|
||||
loading,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onToggleFlag,
|
||||
onToggleSeen,
|
||||
onReply,
|
||||
onForward,
|
||||
onQuickReply,
|
||||
}: {
|
||||
mail: MailDetail | null;
|
||||
loading: boolean;
|
||||
onArchive: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleFlag: () => void;
|
||||
onToggleSeen: () => void;
|
||||
onReply: () => void;
|
||||
onForward: () => void;
|
||||
onQuickReply: (text: string) => Promise<void>;
|
||||
}) {
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
// 富文本 / 纯文本:默认有 HTML 就渲染 HTML(跟主流邮件客户端一致),可一键切回纯文本
|
||||
const [view, setView] = useState<'html' | 'text'>('html');
|
||||
const [showImages, setShowImages] = useState(false);
|
||||
const [doc, setDoc] = useState<string | null>(null);
|
||||
const dark = document.documentElement.classList.contains('dark');
|
||||
|
||||
// 切邮件时把「显示图片」收回去(换一封就重新拦,别把上一封的许可带过去)
|
||||
useEffect(() => {
|
||||
setShowImages(false);
|
||||
setView('html');
|
||||
}, [mail?.uid, mail?.folder]);
|
||||
|
||||
// HTML 视图按需取「净化 + 包好」的文档;打开图片 / 切主题时重新取一次
|
||||
useEffect(() => {
|
||||
if (view !== 'html' || !mail?.html) {
|
||||
setDoc(null);
|
||||
return;
|
||||
}
|
||||
if (doc && !showImages) { /* 已有文档且没要求放行图片,直接用 */ }
|
||||
let alive = true;
|
||||
api
|
||||
.message(mail.uid, mail.folder, { images: showImages, theme: dark ? 'dark' : 'light' })
|
||||
.then((d) => {
|
||||
if (alive) setDoc(d.htmlDocument ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setDoc(null);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view, showImages, dark, mail?.uid, mail?.folder, mail?.html]);
|
||||
|
||||
// 只有「当前什么都没显示」时才铺满加载态;已有内容时保留旧内容(避免点一下就整块跳动)
|
||||
if (loading && !mail) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">正在打开…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mail) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<div className="text-muted-foreground flex max-w-sm flex-col items-center gap-2 text-center">
|
||||
<MailIcon className="size-8 opacity-40" />
|
||||
<div className="text-sm">未选择邮件</div>
|
||||
<div className="text-xs">从左侧列表选一封,或用 J / K 上下移动、Enter 打开</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const from = mail.from?.[0];
|
||||
const fromName = from?.name || from?.address || '(未知发件人)';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{/* 工具条 */}
|
||||
<div className="flex h-14 shrink-0 items-center gap-1 border-b px-3">
|
||||
<ToolButton label="归档" shortcut="E" onClick={onArchive}>
|
||||
<Archive className="size-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="移到垃圾邮件" onClick={onArchive}>
|
||||
<ShieldAlert className="size-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="删除" shortcut="#" onClick={onDelete} className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="size-4" />
|
||||
</ToolButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 !h-5" />
|
||||
|
||||
<ToolButton
|
||||
label={mail.flagged ? '取消旗标' : '加旗标'}
|
||||
shortcut="S"
|
||||
onClick={onToggleFlag}
|
||||
className={mail.flagged ? 'text-amber-500' : undefined}
|
||||
>
|
||||
<Flag className={cn('size-4', mail.flagged && 'fill-current')} />
|
||||
</ToolButton>
|
||||
<ToolButton label={mail.seen ? '标为未读' : '标为已读'} shortcut="U" onClick={onToggleSeen}>
|
||||
{mail.seen ? <MailIcon className="size-4" /> : <MailOpen className="size-4" />}
|
||||
</ToolButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 !h-5" />
|
||||
|
||||
<ToolButton label="回复" shortcut="R" onClick={onReply}>
|
||||
<Reply className="size-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="转发" shortcut="F" onClick={onForward}>
|
||||
<CornerUpRight className="size-4" />
|
||||
</ToolButton>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 pr-1">
|
||||
{mail.html && (
|
||||
<div className="bg-muted flex items-center gap-1 rounded-md p-0.5 text-xs" id="body-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
id="view-html"
|
||||
onClick={() => setView('html')}
|
||||
className={cn('rounded px-2 py-0.5 transition-colors', view === 'html' ? 'bg-background shadow-sm' : 'text-muted-foreground')}
|
||||
>
|
||||
富文本
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="view-text"
|
||||
onClick={() => setView('text')}
|
||||
className={cn('rounded px-2 py-0.5 transition-colors', view === 'text' ? 'bg-background shadow-sm' : 'text-muted-foreground')}
|
||||
>
|
||||
纯文本
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted-foreground hidden text-xs sm:inline">{formatSize(mail.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scroll-pane scrollbar-thin flex-1">
|
||||
<div className="mx-auto w-full max-w-3xl p-6">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{mail.subject || '(无主题)'}</h1>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{mail.flagged && <Badge variant="secondary">已加旗标</Badge>}
|
||||
{!mail.seen && <Badge>未读</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-start gap-3">
|
||||
<Avatar className="size-9">
|
||||
<AvatarFallback
|
||||
style={{ backgroundColor: avatarHue(from?.address || fromName), color: 'white' }}
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
{initials(from?.name || '', from?.address || fromName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="truncate text-sm font-medium">{fromName}</span>
|
||||
{from?.name && from?.address && (
|
||||
<span className="text-muted-foreground truncate text-xs"><{from.address}></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
收件人:{mail.to.map((a) => a.name || a.address).join('、') || '—'}
|
||||
{mail.cc.length > 0 && ` · 抄送:${mail.cc.map((a) => a.name || a.address).join('、')}`}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{formatFullDate(mail.date)}</span>
|
||||
</div>
|
||||
|
||||
{mail.attachments.length > 0 && (
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{mail.attachments.map((a) => (
|
||||
<a
|
||||
key={a.index}
|
||||
href={api.attachmentUrl(mail.uid, a.index, mail.folder)}
|
||||
className="bg-card hover:bg-accent flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors"
|
||||
>
|
||||
<Download className="text-muted-foreground size-4" />
|
||||
<span className="max-w-[220px] truncate">{a.filename}</span>
|
||||
<span className="text-muted-foreground text-xs">{formatSize(a.size)}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'html' && mail.html ? (
|
||||
<HtmlBody
|
||||
doc={doc}
|
||||
blocked={mail.blockedImages ?? []}
|
||||
sanitized={mail.sanitized ?? []}
|
||||
showImages={showImages}
|
||||
onShowImages={() => setShowImages(true)}
|
||||
/>
|
||||
) : (
|
||||
<article className="mail-body text-sm leading-7">{mail.text || '(这封邮件没有可显示的纯文本正文)'}</article>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快速回复 */}
|
||||
<div className="shrink-0 border-t p-4">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2">
|
||||
<Textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder={`回复 ${fromName}…(Ctrl+Enter 发送)`}
|
||||
className="min-h-[72px] resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && replyText.trim()) {
|
||||
e.preventDefault();
|
||||
void (async () => {
|
||||
setSending(true);
|
||||
try {
|
||||
await onQuickReply(replyText);
|
||||
setReplyText('');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={onReply} className="text-muted-foreground">
|
||||
<CornerUpLeft className="size-4" />
|
||||
完整回复
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={sending || !replyText.trim()}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
setSending(true);
|
||||
try {
|
||||
await onQuickReply(replyText);
|
||||
setReplyText('');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
})()
|
||||
}
|
||||
>
|
||||
{sending ? '发送中…' : '发送回复'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { memo } from 'react';
|
||||
import { Flag } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn, avatarHue, formatDate, formatSize, initials } from '@/lib/utils';
|
||||
import type { MailSummary } from '@/lib/api';
|
||||
|
||||
function senderName(m: MailSummary) {
|
||||
const a = m.from?.[0];
|
||||
if (!a) return '(无发件人)';
|
||||
return a.name || a.address.split('@')[0] || a.address;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行邮件。用 memo 包住:列表 100 行时,选中一封只让变化的那两行重渲染,
|
||||
* 而不是整列表重画 —— 这是「顺滑」的关键之一。
|
||||
*/
|
||||
const Row = memo(function Row({
|
||||
m,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
m: MailSummary;
|
||||
active: boolean;
|
||||
onSelect: (uid: number) => void;
|
||||
}) {
|
||||
const name = senderName(m);
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-uid={m.uid}
|
||||
onClick={() => onSelect(m.uid)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 px-4 py-3 text-left transition-colors duration-100',
|
||||
active ? 'bg-accent' : 'hover:bg-accent/50',
|
||||
)}
|
||||
>
|
||||
<Avatar className="mt-0.5 size-8">
|
||||
<AvatarFallback
|
||||
style={{ backgroundColor: avatarHue(m.from?.[0]?.address || name), color: 'white' }}
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
{initials(m.from?.[0]?.name || '', m.from?.[0]?.address || name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{!m.seen && <span className="bg-primary size-2 shrink-0 rounded-full" aria-label="未读" />}
|
||||
<span className={cn('truncate text-sm', !m.seen && 'font-semibold')}>{name}</span>
|
||||
<span className="text-muted-foreground ml-auto shrink-0 text-xs">
|
||||
{formatDate(m.date || m.internalDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn('truncate text-sm', !m.seen ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{m.subject || '(无主题)'}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">{formatSize(m.size)}</span>
|
||||
{m.flagged && <Flag className="size-3.5 text-amber-500" />}
|
||||
{m.answered && (
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
|
||||
已回复
|
||||
</Badge>
|
||||
)}
|
||||
{m.draft && (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
草稿
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
export function MailList({
|
||||
messages,
|
||||
total,
|
||||
selectedUid,
|
||||
loading,
|
||||
query,
|
||||
folderLabel,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
messages: MailSummary[];
|
||||
total: number;
|
||||
selectedUid: number | null;
|
||||
loading: boolean;
|
||||
query: string;
|
||||
folderLabel: string;
|
||||
onSelect: (uid: number) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-col border-r', className)}>
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-sm font-semibold">{query ? `搜索:${query}` : folderLabel}</h2>
|
||||
<span className="text-muted-foreground text-xs">{total} 封</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
用原生 overflow-y:auto,而不是 Radix ScrollArea:
|
||||
ScrollArea 必须有显式高度才滚得动,塞进 flex 列里极容易退化成「内容把容器撑开、
|
||||
整页跟着一起滚」。原生滚动在桌面端也更顺(惯性、滚轮、触控板都走系统实现)。
|
||||
*/}
|
||||
<div className="scroll-pane scrollbar-thin flex-1">
|
||||
{loading ? (
|
||||
<div className="space-y-3 p-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<Skeleton className="size-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-muted-foreground flex min-h-[240px] flex-col items-center justify-center gap-1 p-8 text-center text-sm">
|
||||
<div className="text-base">{query ? '没有匹配的邮件' : '这个文件夹是空的'}</div>
|
||||
<div className="text-xs">{query ? '试试别的关键词,或清空搜索' : '新邮件到达时会出现在这里'}</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{messages.map((m) => (
|
||||
<Row key={m.uid} m={m} active={m.uid === selectedUid} onSelect={onSelect} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Archive,
|
||||
FileText,
|
||||
Inbox,
|
||||
Send,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AccountProfile, Folder } from '@/lib/api';
|
||||
import { AccountDialog } from './AccountDialog';
|
||||
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
INBOX: Inbox,
|
||||
DRAFTS: FileText,
|
||||
SENT: Send,
|
||||
ARCHIVE: Archive,
|
||||
JUNK: ShieldAlert,
|
||||
TRASH: Trash2,
|
||||
};
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
INBOX: '收件箱',
|
||||
DRAFTS: '草稿',
|
||||
SENT: '已发送',
|
||||
ARCHIVE: '归档',
|
||||
JUNK: '垃圾邮件',
|
||||
TRASH: '废纸篓',
|
||||
};
|
||||
|
||||
export function folderLabel(name: string) {
|
||||
return LABELS[name.toUpperCase()] || name;
|
||||
}
|
||||
|
||||
export function MailSidebar({
|
||||
folders,
|
||||
current,
|
||||
account,
|
||||
profile,
|
||||
onSelect,
|
||||
onCompose,
|
||||
onDisplayName,
|
||||
className,
|
||||
}: {
|
||||
folders: Folder[];
|
||||
current: string;
|
||||
account: { user: string; displayName: string; host: string };
|
||||
profile?: AccountProfile | null;
|
||||
onSelect: (name: string) => void;
|
||||
onCompose: () => void;
|
||||
onDisplayName?: (name: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const totalUnread = folders.reduce((n, f) => n + (f.unseen || 0), 0);
|
||||
|
||||
return (
|
||||
<aside className={cn('bg-sidebar text-sidebar-foreground flex min-h-0 flex-col gap-2 border-r', className)}>
|
||||
{/* 账号与服务器 */}
|
||||
<div className="flex flex-col gap-1 border-b p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{account.displayName || account.user}</div>
|
||||
<div className="text-muted-foreground truncate text-xs">{account.user}</div>
|
||||
</div>
|
||||
{totalUnread > 0 && (
|
||||
<span className="bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
{totalUnread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs">{account.host}</div>
|
||||
</div>
|
||||
|
||||
{/* 撰写出入口 */}
|
||||
<div className="p-4 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCompose}
|
||||
className="hover:bg-sidebar-accent hover:text-sidebar-accent-foreground w-full rounded-md border border-dashed px-3 py-2 text-left text-sm transition-colors"
|
||||
>
|
||||
撰写新邮件
|
||||
<span className="text-muted-foreground ml-2 text-xs">C</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 文件夹 */}
|
||||
<nav className="scrollbar-thin flex-1 overflow-y-auto px-2 pb-4">
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs font-medium">文件夹</div>
|
||||
{folders.map((f) => {
|
||||
const key = f.name.toUpperCase();
|
||||
const Icon = ICONS[key] ?? Inbox;
|
||||
const active = f.name === current;
|
||||
return (
|
||||
<button
|
||||
key={f.name}
|
||||
type="button"
|
||||
onClick={() => onSelect(f.name)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
|
||||
active
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
|
||||
: 'hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('size-4 shrink-0', active ? 'opacity-100' : 'opacity-70')} />
|
||||
<span className="flex-1 truncate text-left">{folderLabel(f.name)}</span>
|
||||
{f.unseen > 0 ? (
|
||||
<span className="text-xs font-medium">{f.unseen}</span>
|
||||
) : f.messages > 0 ? (
|
||||
<span className="text-muted-foreground text-xs">{f.messages}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="text-muted-foreground flex items-center justify-between gap-2 border-t px-4 py-3 text-xs">
|
||||
<span className="truncate">快捷键:J/K 切换 · Enter 打开 · R 回复 · S 旗标 · E 归档 · # 删除</span>
|
||||
<AccountDialog account={profile} onDisplayName={onDisplayName} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image data-slot="avatar-image" className={cn('aspect-square size-full', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn('bg-muted flex size-full items-center justify-center rounded-full text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-white',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span';
|
||||
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
function DialogTrigger(props: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
function DialogPortal(props: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">关闭</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="dialog-header" className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return <DialogPrimitive.Title data-slot="dialog-title" className={cn('text-lg leading-none font-semibold', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function DropdownMenu(props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
function DropdownMenuTrigger(props: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { inset?: boolean; variant?: 'default' | 'destructive' }) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
function DropdownMenuRadioGroup(props: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function DropdownMenuSub(props: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn('relative', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="skeleton" className={cn('bg-accent animate-pulse rounded-md', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function TooltipProvider({ delayDuration = 200, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
|
||||
}
|
||||
|
||||
function Tooltip(props: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger(props: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 本地邮件服务的 API 客户端。
|
||||
* 后端是 E:\deepseek\WpywMailClient\server\index.js(IMAP/SMTP/MIME 全在那边,已通过 4 套验收)。
|
||||
* 这里只做类型化包装,不含任何邮件协议逻辑。
|
||||
*/
|
||||
|
||||
export type Address = { name: string; address: string };
|
||||
|
||||
export type Folder = {
|
||||
name: string;
|
||||
flags: string[];
|
||||
messages: number;
|
||||
unseen: number;
|
||||
recent: number;
|
||||
};
|
||||
|
||||
export type MailSummary = {
|
||||
uid: number;
|
||||
subject: string;
|
||||
from: Address[];
|
||||
to: Address[];
|
||||
date: string | null;
|
||||
internalDate: string | null;
|
||||
size: number;
|
||||
seen: boolean;
|
||||
flagged: boolean;
|
||||
answered: boolean;
|
||||
draft: boolean;
|
||||
flags: string[];
|
||||
};
|
||||
|
||||
export type Attachment = {
|
||||
index: number;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
inline: boolean;
|
||||
contentId: string | null;
|
||||
};
|
||||
|
||||
export type MailDetail = {
|
||||
uid: number;
|
||||
folder: string;
|
||||
subject: string;
|
||||
from: Address[];
|
||||
to: Address[];
|
||||
cc: Address[];
|
||||
replyTo: Address[];
|
||||
date: string | null;
|
||||
messageId: string | null;
|
||||
inReplyTo: string | null;
|
||||
references: string | null;
|
||||
text: string;
|
||||
html: string | null;
|
||||
/** 服务端净化后并包成完整文档的 HTML(用于 iframe srcDoc)。 */
|
||||
htmlDocument?: string | null;
|
||||
/** 被拦下的远程图片地址(默认拦截,防止一打开就把你的信息告诉发件人)。 */
|
||||
blockedImages?: string[];
|
||||
/** 净化时清掉的东西(script/iframe/事件处理器/远程图片…)。 */
|
||||
sanitized?: string[];
|
||||
size: number;
|
||||
seen: boolean;
|
||||
flagged: boolean;
|
||||
flags: string[];
|
||||
attachments: Attachment[];
|
||||
};
|
||||
|
||||
export type AccountInfo = {
|
||||
host: string;
|
||||
imapPort: number;
|
||||
smtpPort: number;
|
||||
user: string;
|
||||
displayName: string;
|
||||
domain: string;
|
||||
hasPassword: boolean;
|
||||
accountFile: string | null;
|
||||
};
|
||||
|
||||
export type StateResponse = {
|
||||
account: AccountInfo;
|
||||
connected: boolean;
|
||||
folders: Folder[] | null;
|
||||
error: string | null;
|
||||
capabilities: string[];
|
||||
serverTime: string;
|
||||
};
|
||||
|
||||
export type SendPayload = {
|
||||
to: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
attachments?: { filename: string; contentType: string; base64: string }[];
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, init);
|
||||
const text = await res.text();
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = { error: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = (data as { error?: string } | null)?.error || `HTTP ${res.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
const json = (body: unknown): RequestInit => ({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const api = {
|
||||
state: () => request<StateResponse>('/api/state'),
|
||||
|
||||
login: (body: { host?: string; user: string; password: string; displayName?: string; save?: boolean }) =>
|
||||
request<{ ok: boolean; folders: Folder[] }>('/api/login', json(body)),
|
||||
|
||||
logout: () => request<{ ok: boolean }>('/api/logout', { method: 'POST' }),
|
||||
|
||||
folders: (refresh = false) => request<{ folders: Folder[] }>(`/api/folders${refresh ? '?refresh=1' : ''}`),
|
||||
|
||||
messages: (folder: string, opts: { limit?: number; offset?: number; q?: string } = {}) => {
|
||||
const p = new URLSearchParams({ folder, limit: String(opts.limit ?? 60) });
|
||||
if (opts.offset) p.set('offset', String(opts.offset));
|
||||
if (opts.q) p.set('q', opts.q);
|
||||
return request<{ folder: string; total: number; offset: number; limit: number; messages: MailSummary[] }>(
|
||||
`/api/messages?${p.toString()}`,
|
||||
);
|
||||
},
|
||||
|
||||
message: (uid: number, folder: string, opts: { images?: boolean; theme?: 'dark' | 'light' } = {}) => {
|
||||
const p = new URLSearchParams({ folder });
|
||||
if (opts.images) p.set('images', '1');
|
||||
if (opts.theme) p.set('theme', opts.theme);
|
||||
return request<MailDetail>(`/api/messages/${uid}?${p.toString()}`);
|
||||
},
|
||||
|
||||
attachmentUrl: (uid: number, index: number, folder: string) =>
|
||||
`/api/messages/${uid}/attachments/${index}?folder=${encodeURIComponent(folder)}`,
|
||||
|
||||
setFlags: (uid: number, folder: string, flags: { seen?: boolean; flagged?: boolean; answered?: boolean }) =>
|
||||
request<{ ok: boolean }>('/api/flags', json({ uid, folder, ...flags })),
|
||||
|
||||
remove: (uid: number, folder: string, permanent = false) =>
|
||||
request<{ ok: boolean; moved: boolean; folder: string | null }>('/api/delete', json({ uid, folder, permanent })),
|
||||
|
||||
move: (uid: number, folder: string, target: string) =>
|
||||
request<{ ok: boolean }>('/api/move', json({ uid, folder, target })),
|
||||
|
||||
send: (payload: SendPayload) =>
|
||||
request<{ ok: boolean; bytes: number; recipients: string[] }>('/api/send', json(payload)),
|
||||
|
||||
saveDraft: (payload: { to?: string; subject?: string; text?: string }) =>
|
||||
request<{ ok: boolean }>('/api/drafts', json(payload)),
|
||||
|
||||
/**
|
||||
* 账号体系(对接服务器 v2.2.0)。走的是服务器上只放账号类接口的公网 HTTPS 入口,
|
||||
* 和读信发信的 IMAP/SMTP 是两条独立通道 —— 那个入口看不到任何邮件数据。
|
||||
*/
|
||||
account: {
|
||||
policy: () => request<{ policy: AccountPolicy; apiBase: string }>('/api/account/policy'),
|
||||
|
||||
register: (body: { email: string; password: string; displayName?: string; inviteCode?: string }) =>
|
||||
request<RegisterResult>('/api/account/register', json(body)),
|
||||
|
||||
verify: (body: { email: string; code: string }) =>
|
||||
request<{ ok: boolean; session: { token: string } | null }>('/api/account/verify', json(body)),
|
||||
|
||||
resend: (body: { email: string; purpose?: 'register' | 'reset' }) =>
|
||||
request<{ ok: boolean }>('/api/account/resend', json(body)),
|
||||
|
||||
forgot: (email: string) =>
|
||||
request<{ ok: boolean; expiresInMinutes: number }>('/api/account/forgot', json({ email })),
|
||||
|
||||
reset: (body: { email: string; code: string; password: string }) =>
|
||||
request<{ ok: boolean }>('/api/account/reset', json(body)),
|
||||
|
||||
overview: () =>
|
||||
request<{ profile: AccountProfile | null; sessions: AccountSession[]; audit: AuditEvent[] }>('/api/account/overview'),
|
||||
|
||||
updateProfile: (displayName: string) =>
|
||||
request<{ ok: boolean; user: AccountProfile }>('/api/account/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName }),
|
||||
}),
|
||||
|
||||
changePassword: (body: { currentPassword: string; password: string }) =>
|
||||
request<{ ok: boolean; revokedSessions: number }>('/api/account/password', json(body)),
|
||||
|
||||
revokeSessions: (body: { all?: boolean; token?: string }) =>
|
||||
request<{ ok: boolean; revoked: number; selfRevoked: boolean }>('/api/account/sessions/revoke', json(body)),
|
||||
},
|
||||
};
|
||||
|
||||
export type AccountPolicy = {
|
||||
registration: 'open' | 'invite' | 'closed';
|
||||
inviteRequired: boolean;
|
||||
requireEmailVerification: boolean;
|
||||
minPasswordLength: number;
|
||||
allowedDomains: string[] | string;
|
||||
codeMinutes: number;
|
||||
maxLoginFailures: number;
|
||||
lockoutMinutes: number;
|
||||
selfHostedDomain: string;
|
||||
verificationNote: string;
|
||||
};
|
||||
|
||||
export type RegisterResult = {
|
||||
ok: boolean;
|
||||
verificationRequired: boolean;
|
||||
email: string;
|
||||
expiresInMinutes: number | null;
|
||||
session: { token: string; user: AccountProfile } | null;
|
||||
};
|
||||
|
||||
export type AccountProfile = {
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
lastLoginAt: string | null;
|
||||
domain: string;
|
||||
};
|
||||
|
||||
export type AccountSession = {
|
||||
tokenPrefix: string;
|
||||
token: string;
|
||||
current: boolean;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type AuditEvent = {
|
||||
at: string;
|
||||
email: string;
|
||||
ip: string;
|
||||
reason: string;
|
||||
success: boolean;
|
||||
detail: string;
|
||||
userAgent: string;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** 相对时间:今天显示时分,今年显示月日,更早显示年月日 */
|
||||
export function formatDate(raw?: string | null): string {
|
||||
if (!raw) return '';
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return String(raw).slice(0, 16);
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
if (d.toDateString() === now.toDateString()) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
export function formatFullDate(raw?: string | null): string {
|
||||
if (!raw) return '';
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return String(raw);
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** 取显示名首字,用于头像占位 */
|
||||
export function initials(name: string, address: string): string {
|
||||
const source = (name || address || '?').trim();
|
||||
const first = source.replace(/["'<>]/g, '').trim()[0];
|
||||
return (first || '?').toUpperCase();
|
||||
}
|
||||
|
||||
/** 由地址生成稳定的头像底色(色相散开,饱和度/亮度固定,避免花哨) */
|
||||
export function avatarHue(seed: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) % 360;
|
||||
return `hsl(${h} 45% 42%)`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,175 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
/* shadcn/ui 默认(new-york / neutral)主题 token,原样实例化。
|
||||
light 与 dark 两套都保留;默认深色(这个项目的主人偏好深色),可切换并记忆。 */
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 16%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 12%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
/* 桌面客户端不该出现页面级滚动条:滚动只发生在三栏内部 */
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Microsoft YaHei",
|
||||
"PingFang SC", "Noto Sans SC", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* 邮件正文里的链接与换行 */
|
||||
.mail-body {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mail-body a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条(桌面客户端观感)+ 三栏内部的滚动容器 */
|
||||
@layer utilities {
|
||||
.scroll-pane {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* 关键:flex 子项默认 min-height:auto 会按内容撑高,导致「列表没滚、整页滚」 */
|
||||
min-height: 0;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(in oklab, var(--foreground) 18%, transparent);
|
||||
border-radius: 9999px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(in oklab, var(--foreground) 30%, transparent);
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/components/mail/accountdialog.tsx","./src/components/mail/composedialog.tsx","./src/components/mail/loginscreen.tsx","./src/components/mail/maildisplay.tsx","./src/components/mail/maillist.tsx","./src/components/mail/mailsidebar.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api.ts","./src/lib/utils.ts","./vite.config.ts"],"checkPending":true,"version":"5.9.3"}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'node:path';
|
||||
|
||||
// 构建产物交给本地 Node 服务托管(server/index.js 会优先发 ui/dist),
|
||||
// 所以 base 用相对路径,避免路径耦合。
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1200,
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
// 开发时把 API 代理到本地邮件服务,便于热更新调试
|
||||
proxy: { '/api': 'http://127.0.0.1:8788' },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user