Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理

This commit is contained in:
WpyQwq
2026-09-19 11:20:43 +08:00
commit c7fb8f8f68
47 changed files with 11573 additions and 0 deletions
+22
View File
@@ -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>
+3481
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -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"
}
}
+5
View File
@@ -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
View File
@@ -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>
);
}
+261
View File
@@ -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())}`;
}
+227
View File
@@ -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>
);
}
+453
View File
@@ -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>
);
}
+360
View File
@@ -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">&lt;{from.address}&gt;</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>
);
}
+142
View File
@@ -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>
);
}
+124
View File
@@ -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>
);
}
+31
View File
@@ -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 };
+31
View File
@@ -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 };
+45
View File
@@ -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 };
+103
View File
@@ -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,
};
+187
View File
@@ -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,
};
+20
View File
@@ -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 };
+45
View File
@@ -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 };
+25
View File
@@ -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 };
+7
View File
@@ -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 };
+17
View File
@@ -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 };
+41
View File
@@ -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 };
+253
View File
@@ -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;
};
+48
View File
@@ -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%)`;
}
+13
View File
@@ -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>,
);
+175
View File
@@ -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;
}
}
+20
View File
@@ -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"]
}
+1
View File
@@ -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"}
+25
View File
@@ -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' },
},
});