Compare commits

...

2 Commits

Author SHA1 Message Date
zxyszx 131421a0f1 release: prepare v1.2.21
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-10 02:55:49 +08:00
云逸 2a26a3b127 [codex] 合并账号级与邮箱单独转发 (#1)
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
* fix: merge account and mailbox forwarding

* docs: prepare v1.2.20 release

---------

Co-authored-by: zxyszx <299979470+zxyszx@users.noreply.github.com>
2026-08-09 11:33:41 +08:00
28 changed files with 536 additions and 205 deletions
+3
View File
@@ -0,0 +1,3 @@
- 调整邮件转发规则:账号级转发固定作用于所有邮箱,并与单个邮箱追加的转发目标同时生效。
- 优化单个邮箱转发设置:账号级目标默认勾选、置顶并锁定,邮箱及转发地址按数字和字母排序,目标较多时可查看完整列表。
- 优化 Telegram 版本频道通知排版:使用精简标题、分层说明和完整更新链接,提升手机端阅读体验。
+8
View File
@@ -0,0 +1,8 @@
- 优化登录与会话流程:登录后完整返回原页面及查询条件,区分未登录、超时、取消和网络故障,并为服务不可用状态提供明确的重新连接入口。
- 完善后台与邮箱错误处理:管理员设置、邮件列表、邮件详情、发送队列、个人资料、签名、DNS 检查及注册流程均增加真实失败提示和重试反馈,避免静默失败或错误成功提示。
- 优化邮箱与个人设置:恢复显示名称编辑,新增可持久化的标准/紧凑邮件布局,移除无效时区和模拟资料,修正发信成功后草稿删除失败的提示逻辑。
- 保持并强化转发规则:账号级转发目标在所有邮箱中默认勾选、置顶且不可取消,单邮箱可继续追加独立目标,现有单邮箱配置不受影响,目标按数字和字母排序。
- 统一界面视觉与交互:采用更清晰的中性色、文字层级、焦点状态和紧凑圆角,补充图标按钮名称、工具提示及全屏页面主区域,改善桌面与移动端可访问性。
- 优化前端加载与开发体验:拆分邮件编辑器相关代码包,控制单个构建文件体积,并支持通过 `VITE_API_TARGET` 指定本地后端代理地址。
- 加固邮件 HTML 安全:升级 DOMPurify 至已修复版本,依赖审计无已知漏洞;同步清理过时演示数据、无效状态和旧页面逻辑。
- 完成全量质量复核:前后端构建、Go 全量及竞态测试、SMTP、外部 IMAP、OAuth、Telegram、Webhook、转发、队列、权限隔离、安装、备份、回滚和 DKIM 同步测试均已通过。
+7 -5
View File
@@ -289,18 +289,20 @@ jobs:
sections = []
for index, entry in enumerate(entries, 1):
parts = re.split(r"[,;。]", entry, maxsplit=1)
parts = re.split(r"[:]", entry, maxsplit=1)
if len(parts) == 1:
parts = re.split(r"[,;。]", entry, maxsplit=1)
title = parts[0].strip()
description = parts[1].strip() if len(parts) > 1 else ""
section = f"<b>{index:02d} {html.escape(title)}</b>"
section = f"<b>{index:02d} · {html.escape(title)}</b>"
if description:
section += "\n" + html.escape(description.rstrip("。") + "。")
section += "\n<blockquote>" + html.escape(description.rstrip("。") + "。") + "</blockquote>"
sections.append(section)
tag = os.environ["RELEASE_TAG"]
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n新版本现已发布\n\n<b>本次更新</b>\n\n"
prefix = f"<b>NewSzxcn Email {html.escape(tag)}</b>\n<i>新版本现已发布</i>\n\n<b>更新内容 · {len(sections)} 项</b>\n\n"
release_url = html.escape(os.environ["RELEASE_URL"], quote=True)
footer = f'\n\n🔗 <a href="{release_url}">查看本次更新</a>'
footer = f'\n\n<a href="{release_url}">查看完整更新说明 ↗</a>'
available = max(0, 3600 - len(prefix) - len(footer))
visible_sections = []
used = 0
+2
View File
@@ -231,6 +231,8 @@ pnpm install
pnpm run dev
```
后端不在默认的 `http://localhost:8080` 时,可通过 `VITE_API_TARGET=http://localhost:18080 pnpm run dev` 指定本地代理目标。
提交前建议运行:
```bash
+1 -1
View File
@@ -1 +1 @@
1.2.15
1.2.21
+16 -4
View File
@@ -2756,8 +2756,20 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, secondID).Scan(&recipientsJSON); err != nil {
t.Fatal(err)
}
if !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") || strings.Contains(recipientsJSON, "account-forward@example.test") || strings.Contains(recipientsJSON, "account-forward-two@example.test") {
t.Fatalf("mailbox forwarding should override account target, recipients=%s", recipientsJSON)
if !strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
t.Fatalf("mailbox forwarding should include account and mailbox targets, recipients=%s", recipientsJSON)
}
if code := admin.do("POST", "/api/me/forwarding/account", map[string]any{"targetEmails": []string{"account-forward-two@example.test"}}, &settings); code != http.StatusOK {
t.Fatalf("update account forwarding after mailbox forwarding code=%d settings=%+v", code, settings)
}
raw = []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: account changed\r\nMessage-ID: <account-changed@example.test>\r\n\r\nbody")
thirdID := insertInbound("<account-changed@example.test>", "account changed", raw)
if err := a.db.QueryRow(`SELECT recipients_json FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceForwarding, thirdID).Scan(&recipientsJSON); err != nil {
t.Fatal(err)
}
if strings.Contains(recipientsJSON, "account-forward@example.test") || !strings.Contains(recipientsJSON, "account-forward-two@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward@example.test") || !strings.Contains(recipientsJSON, "mailbox-forward-two@example.test") {
t.Fatalf("changing account forwarding should preserve mailbox targets, recipients=%s", recipientsJSON)
}
loopRaw := []byte("From: sender@example.test\r\nTo: admin@lanqin.local\r\nSubject: loop\r\n" + forwardingHeaderName + ": mail.example.test\r\nMessage-ID: <forward-loop@example.test>\r\n\r\nbody")
@@ -2766,8 +2778,8 @@ func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
if err := a.db.QueryRow(`SELECT COUNT(1) FROM send_queue WHERE source=?`, sendSourceForwarding).Scan(&queueCount); err != nil {
t.Fatal(err)
}
if queueCount != 2 {
t.Fatalf("forwarding queue count=%d, want 2", queueCount)
if queueCount != 3 {
t.Fatalf("forwarding queue count=%d, want 3", queueCount)
}
}
+1 -4
View File
@@ -138,10 +138,7 @@ func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (t
if err != nil {
return nil, "", "", err
}
targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)
if len(targets) == 0 {
targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
}
targets := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
if len(targets) == 0 {
return nil, userID, mailboxAddress, nil
}
+1 -1
View File
@@ -36,7 +36,7 @@
"@tiptap/starter-kit": "^3.27.0",
"class-variance-authority": "^0.7.0",
"clsx": "2.1.1",
"dompurify": "3.4.12",
"dompurify": "3.4.13",
"lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0",
"react": "18.3.1",
+7 -3
View File
@@ -1,15 +1,19 @@
import React from "react"
import { Navigate, useLocation } from "react-router-dom"
import { useMe, isTimeoutError } from "@/hooks/use-me"
import { useMe } from "@/hooks/use-me"
import { AuthLoading, AuthError } from "@/components/auth-states"
import { isUnauthorizedError } from "@/lib/api"
export function AuthGuard({ children }: { children: React.ReactNode }) {
const me = useMe()
const location = useLocation()
if (me.isLoading) return <AuthLoading />
if (me.isError && isTimeoutError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
if (me.isError && !isUnauthorizedError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
if (me.isError || !me.data?.user) {
const from = `${location.pathname}${location.search}${location.hash}`
return <Navigate to="/login" replace state={{ from }} />
}
return <>{children}</>
}
+5 -5
View File
@@ -1,17 +1,17 @@
import { Button } from "@/components/ui/button"
export function AuthLoading() {
return <div className="grid min-h-screen place-items-center text-muted-foreground">...</div>
return <main className="grid min-h-screen place-items-center text-muted-foreground">...</main>
}
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<main className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-sm space-y-4 text-center">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium"></div>
<div className="text-sm text-muted-foreground">{message}</div>
<Button type="button" variant="outline" onClick={onRetry}></Button>
<Button type="button" variant="outline" onClick={onRetry}></Button>
</div>
</div>
</main>
)
}
+5 -5
View File
@@ -5,18 +5,18 @@ 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-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
+1 -1
View File
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+1 -1
View File
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+2 -6
View File
@@ -1,5 +1,5 @@
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { api, isUnauthorizedError } from "@/lib/api"
import type { User } from "@/lib/api"
type MeResponse = { user: User }
@@ -10,11 +10,7 @@ export function useMe(
return useQuery({
queryKey: ["me"],
queryFn: api.me,
retry: 1,
retry: (failureCount, error) => !isUnauthorizedError(error) && failureCount < 1,
...options,
})
}
export function isTimeoutError(error: unknown): boolean {
return error instanceof Error && error.message.includes("请求超时")
}
+19 -19
View File
@@ -5,34 +5,34 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 0 0% 9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 224 44% 12%;
--popover-foreground: 0 0% 9%;
--primary: 0 0% 12%;
--primary-foreground: 0 0% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 84% 4.9%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 12%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 42%;
--accent: 0 0% 95%;
--accent-foreground: 0 0% 12%;
--destructive: 358 88% 61%;
--destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 216 22% 42%;
--border: 0 0% 89.8%;
--input: 0 0% 82%;
--ring: 0 0% 32%;
--radius: 0.5rem;
--app-sidebar-width: 18rem;
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 224 44% 12%;
--sidebar-foreground: 0 0% 16%;
--sidebar-primary: 0 0% 12%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 210 40% 96.1%;
--sidebar-accent-foreground: 222.2 84% 4.9%;
--sidebar-border: 214.3 31.8% 91.4%;
--sidebar-ring: 215.4 16.3% 46.9%;
--sidebar-accent: 0 0% 95%;
--sidebar-accent-foreground: 0 0% 12%;
--sidebar-border: 0 0% 89.8%;
--sidebar-ring: 0 0% 42%;
}
* { @apply border-border; }
+22 -4
View File
@@ -4,6 +4,17 @@ export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000
const MAIL_DELIVERY_TIMEOUT_MS = 60_000
export class ApiError extends Error {
constructor(message: string, readonly status: number) {
super(message)
this.name = "ApiError"
}
}
export function isUnauthorizedError(error: unknown): boolean {
return error instanceof ApiError && error.status === 401
}
export type MailSearchParams = {
q?: string
from?: string
@@ -45,27 +56,34 @@ function appendMailSearchParams(params: URLSearchParams, search: MailSearchParam
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
const { timeoutMs, ...requestInit } = init
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), timeoutMs || REQUEST_TIMEOUT_MS)
let timedOut = false
const timeout = window.setTimeout(() => {
timedOut = true
controller.abort()
}, timeoutMs || REQUEST_TIMEOUT_MS)
const externalSignal = requestInit.signal
const abortFromExternalSignal = () => controller.abort()
if (externalSignal) {
if (externalSignal.aborted) controller.abort()
else externalSignal.addEventListener("abort", () => controller.abort(), { once: true })
else externalSignal.addEventListener("abort", abortFromExternalSignal, { once: true })
}
try {
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(requestInit.headers || {}) }, ...requestInit, signal: controller.signal })
if (!res.ok) {
let message = `${res.status} ${res.statusText}`
try { const body = await res.json(); message = body.error || message } catch {}
throw new Error(message)
throw new ApiError(message, res.status)
}
return res.json() as Promise<T>
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("请求超时,请检查后端服务是否正常")
throw new Error(timedOut ? "请求超时,请检查后端服务是否正常" : "请求已取消")
}
if (error instanceof TypeError) throw new Error("无法连接后端服务,请检查服务状态")
throw error instanceof Error ? error : new Error("网络请求失败")
} finally {
window.clearTimeout(timeout)
externalSignal?.removeEventListener("abort", abortFromExternalSignal)
}
}
+4
View File
@@ -0,0 +1,4 @@
export function safeReturnPath(value: unknown, fallback = "/"): string {
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return fallback
return value
}
+65 -17
View File
@@ -101,6 +101,14 @@ export function AdminPage() {
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
const rawSection = params.get("section") as Section | null
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
const sectionQuery = section === "overview" ? overview
: section === "users" ? users
: section === "permissionGroups" ? permissionGroups
: section === "domains" ? domains
: section === "mailboxes" ? mailboxes
: section === "aliases" ? aliases
: section === "settings" ? settings
: null
async function refreshAdminPage() {
if (refreshing) return
@@ -124,6 +132,8 @@ export function AdminPage() {
<main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
{section === "overview" && canOverview && (
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
@@ -397,7 +407,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{group.description || "未填写说明"}</div>
</div>
{(canUpdate || canDelete) && <DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理权限配置 ${group.name}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}></DropdownMenuItem>
<DropdownMenuSeparator />
@@ -903,7 +913,8 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
</Table>
</div>
{messages.isLoading && <Empty text="加载中..." />}
{!messages.isLoading && items.length === 0 && <Empty text="暂无邮件" />}
{messages.isError && <QueryFailure error={messages.error} onRetry={() => { void messages.refetch() }} compact />}
{!messages.isLoading && !messages.isError && items.length === 0 && <Empty text="暂无邮件" />}
{!messages.isLoading && messages.hasNextPage && (
<div className="flex justify-center">
<Button variant="outline" size="sm" disabled={messages.isFetchingNextPage} onClick={() => messages.fetchNextPage()}>
@@ -1016,7 +1027,8 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
</Table>
</div>
{audit.isLoading && <Empty text="加载中..." />}
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送记录" />}
{audit.isError && <QueryFailure error={audit.error} onRetry={() => { void audit.refetch() }} compact />}
{!audit.isLoading && !audit.isError && items.length === 0 && <Empty text="暂无发送记录" />}
{!audit.isLoading && audit.hasNextPage && (
<div className="flex justify-center">
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
@@ -1330,7 +1342,7 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
<div className="space-y-3 border-l-2 border-primary/50 py-1 pl-3">
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 font-mono text-sm font-semibold">{telegramPairing.code}</code>
<Button type="button" variant="ghost" size="icon" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
<Button type="button" variant="ghost" size="icon" aria-label="复制绑定码" title="复制绑定码" onClick={() => navigator.clipboard.writeText(telegramPairing.code)}>
<Copy className="h-4 w-4" />
</Button>
</div>
@@ -1429,7 +1441,8 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
</CardContent>
</Card>}
{settingsTab === "templates" && canViewTemplates && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
{settingsTab === "templates" && canViewTemplates && templates.isError && <QueryFailure error={templates.error} onRetry={() => { void templates.refetch() }} />}
{settingsTab === "templates" && canViewTemplates && !templates.isError && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
{settingsTab === "security" && <Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
@@ -1808,6 +1821,18 @@ function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; va
}
function InfoBox({ label, value }: { label: string; value: React.ReactNode }) { return <div className="rounded-lg border p-4"><div className="text-xl font-semibold tracking-tight sm:text-2xl">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div> }
function Empty({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
function QueryFailure({ error, onRetry, compact = false }: { error: unknown; onRetry: () => void; compact?: boolean }) {
return (
<div className={cn("mb-4 flex flex-col gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 sm:flex-row sm:items-center sm:justify-between", compact && "mb-0")} role="alert">
<div className="min-w-0">
<div className="text-sm font-semibold text-destructive"></div>
<div className="mt-1 break-words text-sm text-muted-foreground">{queryErrorMessage(error)}</div>
</div>
<Button type="button" variant="outline" size="sm" className="shrink-0" onClick={onRetry}><RefreshCcw className="h-4 w-4" /></Button>
</div>
)
}
function DomainBadgeRow({ domain }: { domain: Domain }) { return <div className="flex items-center justify-between rounded-lg border p-3"><span className="font-medium">{domain.name}</span><Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "正常" : domain.dnsStatus}</Badge></div> }
function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) }
@@ -1968,7 +1993,7 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
})
}
if (!canUpdate && !canResetPassword && !onDelete) return null
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}></DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}></DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理账号 ${accountPrimaryEmail(user)}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}></DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}></DropdownMenuItem>}{!user.protected && user.role !== "admin" && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem></>}{!user.protected && user.role !== "admin" && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
}
function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
@@ -2012,12 +2037,12 @@ function CreateUserDialog(_props: { permissionGroups: PermissionGroup[] }) {
function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: MailboxType; users: AdminUser[]; canUpdate: boolean; onDelete?: () => void }) {
const [open, setOpen] = React.useState(false)
if (!canUpdate && !onDelete) return null
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}></DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理邮箱 ${mailbox.address}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setOpen(true)}></DropdownMenuItem>}{canUpdate && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditMailboxDialog mailbox={mailbox} users={users} open={open} onOpenChange={setOpen} />}</>
}
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
if (!onToggle && !onDelete) return null
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" aria-label={`管理转发 ${alias.source}`} title="更多操作"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}></DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
}
function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) {
@@ -2146,15 +2171,15 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
const user = me.data?.user
const canCheckDNS = hasPermission(user, "admin.dns.check")
const { toast } = useToast(); const qc = useQueryClient(); const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain })
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join("") }) } })
const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join("") }) }, onError: (error) => toast({ title: "DNS 检测失败", description: error.message }) })
if (!domain) return <Card><CardContent className="p-6 text-muted-foreground"></CardContent></Card>
const content = <>
<p className="mb-3 text-sm text-muted-foreground"> DNS </p>
<div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>
{records.isError ? <QueryFailure error={records.error} onRetry={() => { void records.refetch() }} compact /> : <div className="space-y-3">{records.data?.items.map((r) => <DNSRecordRow key={`${r.type}-${r.name}`} record={r} />)}</div>}
{check.data && <>
<Separator className="my-4" />
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"><CheckCircle2 className="h-4 w-4" /></div>
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <div key={k} className="flex items-center gap-2 text-sm"><CheckCircle2 className={`h-4 w-4 shrink-0 ${v.ok ? "text-green-600" : "text-destructive"}`} /><span className="font-medium">{k.toUpperCase()}:</span> {v.message}</div>)}</div>
<div className="mt-2 space-y-2">{Object.entries(check.data.checks).map(([k, v]) => <DNSCheckRow key={k} name={k} check={v} />)}</div>
</>}</>
const checkButton = canCheckDNS ? <Button variant="outline" size="sm" onClick={() => check.mutate()} disabled={check.isPending}><RefreshCcw className="h-4 w-4" /></Button> : null
const header = <div className="flex items-center justify-between"><CardTitle>DNS </CardTitle>{checkButton}</div>
@@ -2162,6 +2187,20 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo
return <Card><CardHeader>{header}</CardHeader><CardContent>{content}</CardContent></Card>
}
function DNSCheckRow({ name, check }: { name: string; check: { ok: boolean; message: string; found?: string[] } }) {
const visibleRecords = check.found?.filter(Boolean) ?? []
return <div className="space-y-1 text-sm">
<div className="flex items-start gap-2">
<CheckCircle2 className={`mt-0.5 h-4 w-4 shrink-0 ${check.ok ? "text-green-600" : "text-destructive"}`} />
<div className="min-w-0"><span className="font-medium">{name.toUpperCase()}:</span> {check.message}</div>
</div>
{!check.ok && visibleRecords.length > 0 && <div className="ml-6 rounded-md bg-muted/60 px-3 py-2 font-mono text-xs text-muted-foreground">
<div className="mb-1 font-sans text-foreground"></div>
<div className="space-y-1">{visibleRecords.map((record, index) => <div key={`${name}-${index}`} className="break-all">{record}</div>)}</div>
</div>}
</div>
}
function dnsDescription(record: DNSRecord): string {
if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。"
if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。"
@@ -2212,19 +2251,25 @@ function fieldNumber(form: FormData, name: string, fallback: number) {
return Number.isFinite(n) && n > 0 ? n : fallback
}
function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; className?: string }) {
const id = React.useId()
return (
<div className={`flex min-h-14 items-center justify-between gap-4 ${className}`}>
<Label className="text-base font-medium">{label}</Label>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
<Label htmlFor={id} className="text-base font-medium">{label}</Label>
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
</div>
)
}
function Field({ label, required = true, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) { return <div className="space-y-2"><Label>{label}</Label><Input required={required} {...props} /></div> }
function Field({ label, required = true, id: suppliedId, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) {
const generatedId = React.useId()
const id = suppliedId || generatedId
return <div className="space-y-2"><Label htmlFor={id}>{label}</Label><Input id={id} required={required} {...props} /></div>
}
function MailboxLimitField({ defaultValue }: { defaultValue: number }) {
const id = React.useId()
return (
<div className="space-y-2">
<Label></Label>
<Input name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
<Label htmlFor={id}></Label>
<Input id={id} name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
<div className="text-xs text-muted-foreground"> 9 0 </div>
</div>
)
@@ -2236,5 +2281,8 @@ function mailboxLimitFromForm(form: FormData, fallback = defaultMailboxLimitOver
function effectiveMailboxLimit(user: AdminUser) {
return user.mailboxLimitOverride ?? user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride
}
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) { return <div className="space-y-2"><Label>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></div> }
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) {
const id = React.useId()
return <div className="space-y-2"><Label htmlFor={id}>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger id={id}><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, itemLabel]) => <SelectItem key={value} value={value}>{itemLabel}</SelectItem>)}</SelectContent></Select></div>
}
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) { return <div className="space-y-2"><Label></Label><Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger><SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent></Select></div> }
+10 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react"
import { Link, Navigate } from "react-router-dom"
import { Link, Navigate, useLocation } from "react-router-dom"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { ArrowRight, KeyRound, LockKeyhole } from "lucide-react"
import { api } from "@/lib/api"
@@ -10,9 +10,12 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useToast } from "@/hooks/use-toast"
import { safeReturnPath } from "@/lib/navigation"
import { AuthError, AuthLoading } from "@/components/auth-states"
export function LoginPage() {
const me = useMe()
const location = useLocation()
const qc = useQueryClient()
const { toast } = useToast()
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
@@ -33,9 +36,12 @@ export function LoginPage() {
onError: (e) => toast({ title: "登录失败", description: e.message }),
})
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
if (me.data?.user) return <Navigate to="/" replace />
const returnPath = safeReturnPath((location.state as { from?: unknown } | null)?.from)
if (me.data?.user) return <Navigate to={returnPath} replace />
if (publicSettings.isLoading) return <AuthLoading />
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
return (
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<div className="w-full max-w-[420px]">
<div className="mb-7 text-center">
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
@@ -82,6 +88,6 @@ export function LoginPage() {
</div>
)}
</div>
</div>
</main>
)
}
+97 -18
View File
@@ -155,7 +155,6 @@ export function MailPage() {
const [autoRefreshing, setAutoRefreshing] = React.useState(false)
const [exportingMail, setExportingMail] = React.useState(false)
const [importingMail, setImportingMail] = React.useState(false)
const [, setLastAutoRefreshAt] = React.useState<Date | null>(null)
const [bulkPending, setBulkPending] = React.useState(false)
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
@@ -427,7 +426,7 @@ export function MailPage() {
onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) },
})
const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) } })
const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mailboxes"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) }, onError: (error) => toast({ title: "移动失败", description: error.message }) })
const cancelScheduledSend = useMutation({
mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id),
onMutate: (item) => setCancelingScheduledId(item.id),
@@ -646,7 +645,6 @@ export function MailPage() {
qc.invalidateQueries({ queryKey: ["send-queue"] }),
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
]).finally(() => {
setLastAutoRefreshAt(new Date())
window.setTimeout(() => setAutoRefreshing(false), 600)
})
}, mailRefreshInterval || 30000)
@@ -700,10 +698,12 @@ export function MailPage() {
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
const mailMessagesLoading = mailView === "external" ? externalMessages.isLoading : mailView === "unknown" ? unknownMessages.isLoading : messages.isLoading
const mailMessagesError = mailView === "external" ? externalMessages.error : mailView === "unknown" ? unknownMessages.error : messages.error
const mailMessagesLoadingMore = mailView === "external" ? externalMessages.isFetchingNextPage : mailView === "unknown" ? unknownMessages.isFetchingNextPage : messages.isFetchingNextPage
const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : mailView === "unknown" ? !!unknownMessages.hasNextPage : !!messages.hasNextPage
const canLoadMore = hasMoreMessages && !mailMessagesLoadingMore
const loadMoreMessages = () => mailView === "external" ? externalMessages.fetchNextPage() : mailView === "unknown" ? unknownMessages.fetchNextPage() : messages.fetchNextPage()
const refetchMessages = () => mailView === "external" ? externalMessages.refetch() : mailView === "unknown" ? unknownMessages.refetch() : messages.refetch()
function toggleCompactSelectAll(checked: boolean) {
setCompactSelectedIds(checked ? visibleMessageIds : [])
}
@@ -1103,7 +1103,6 @@ export function MailPage() {
try {
await refreshMailData()
const refreshedAt = new Date()
setLastAutoRefreshAt(refreshedAt)
toast({ title: "邮件已刷新", description: `更新于 ${refreshedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` })
} catch (error) {
toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" })
@@ -1263,6 +1262,7 @@ export function MailPage() {
)}
</SidebarHeader>
<SidebarContent className="px-1">
{!sidebarCollapsed && publicSettings.isError && <SidebarQueryFailure label="邮箱设置读取失败" onRetry={() => { void publicSettings.refetch() }} />}
<SidebarGroup>
{!sidebarCollapsed && (
<div className="flex items-center justify-between px-2 py-1">
@@ -1316,8 +1316,10 @@ export function MailPage() {
)}
</SidebarMenu>
{folders.isLoading && <FolderSkeleton />}
{!sidebarCollapsed && folders.isError && <SidebarQueryFailure label="文件夹读取失败" onRetry={() => { void folders.refetch() }} />}
</SidebarGroupContent>
</SidebarGroup>
{!sidebarCollapsed && externalImapEnabled && externalMailAccounts.isError && <SidebarQueryFailure label="外部邮箱读取失败" onRetry={() => { void externalMailAccounts.refetch() }} />}
{externalAccountItems.length > 0 && <SidebarGroup>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>}
<SidebarGroupContent>
@@ -1360,6 +1362,7 @@ export function MailPage() {
</SidebarMenuItem>
))}
{!sidebarCollapsed && expanded && externalFolders.isLoading && <FolderSkeleton />}
{!sidebarCollapsed && expanded && account.id === selectedExternalAccountId && externalFolders.isError && <SidebarQueryFailure label="远端文件夹读取失败" onRetry={() => { void externalFolders.refetch() }} />}
</React.Fragment>
)})}
</SidebarMenu>
@@ -1370,7 +1373,7 @@ export function MailPage() {
<div className="flex items-center justify-between px-2 py-1">
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" /></SidebarGroupLabel>
{canManageFolders && (
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)}>
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建文件夹" title="新建文件夹" onClick={() => setFolderDialogOpen(true)}>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
@@ -1430,11 +1433,11 @@ export function MailPage() {
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" /></SidebarGroupLabel>
{canManageCurrentMailboxLabels && (
<div className="flex items-center gap-0.5">
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => { setNewLabelEditing(true); setLabelEditMode(true) }}>
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="新建标签" title="新建标签" onClick={() => { setNewLabelEditing(true); setLabelEditMode(true) }}>
<Plus className="h-3.5 w-3.5" />
</Button>
{labelEditMode && (
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setLabelEditMode((v) => !v)}>
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" aria-label="完成标签编辑" title="完成编辑" onClick={() => setLabelEditMode((v) => !v)}>
<Check className="h-3 w-3" />
</Button>
)}
@@ -1479,7 +1482,7 @@ export function MailPage() {
</SidebarMenuItem>
)
})}
{canReadMail && !sidebarCollapsed && !labels.isLoading && labelItems.length === 0 && <div className="px-2 py-1 text-xs text-muted-foreground"></div>}
{canReadMail && !sidebarCollapsed && !labels.isLoading && !labels.isError && labelItems.length === 0 && <div className="px-2 py-1 text-xs text-muted-foreground"></div>}
{canManageCurrentMailboxLabels && labelEditMode && newLabelEditing && (
<SidebarMenuItem>
<NewLabelButton collapsed={sidebarCollapsed} pending={createLabel.isPending} onCreate={(name) => { createLabel.mutate(name); setNewLabelEditing(false) }} editing={newLabelEditing} onEditingChange={setNewLabelEditing} />
@@ -1487,6 +1490,7 @@ export function MailPage() {
)}
</SidebarMenu>
{labels.isLoading && <FolderSkeleton />}
{!sidebarCollapsed && labels.isError && <SidebarQueryFailure label="标签读取失败" onRetry={() => { void labels.refetch() }} />}
</SidebarGroupContent>
</SidebarGroup>}
</SidebarContent>
@@ -1513,8 +1517,12 @@ export function MailPage() {
<PermissionEmptyState title="无邮箱前台权限" description="当前账号未开启邮箱前台访问权限。" onOpenSettings={openSettings} />
) : !canReadMail ? (
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
) : mailboxList.isError ? (
<MailQueryFailure error={mailboxList.error} onRetry={() => { void mailboxList.refetch() }} />
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
<NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
) : mailView === "scheduled" && scheduledSends.isError ? (
<MailQueryFailure error={scheduledSends.error} onRetry={() => { void scheduledSends.refetch() }} />
) : mailView === "scheduled" && canScheduleMail ? (
<ScheduledSendView
compact={compactMailLayout}
@@ -1527,6 +1535,8 @@ export function MailPage() {
/>
) : mailView === "scheduled" ? (
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
) : mailView === "sendQueue" && sendQueue.isError ? (
<MailQueryFailure error={sendQueue.error} onRetry={() => { void sendQueue.refetch() }} />
) : mailView === "sendQueue" && canViewSendQueue ? (
<SendQueueView
compact={compactMailLayout}
@@ -1552,6 +1562,8 @@ export function MailPage() {
/>
) : mailView === "sendQueue" ? (
<PermissionEmptyState title="无发送队列权限" description="当前账号不能查看发送队列。" onOpenSettings={openSettings} />
) : mailMessagesError ? (
<MailQueryFailure error={mailMessagesError} onRetry={() => { void refetchMessages() }} />
) : compactMailLayout ? (
<CompactMailView
title={viewTitle}
@@ -1569,6 +1581,8 @@ export function MailPage() {
selectedId={selectedId}
selected={selected}
detailLoading={detail.isLoading}
detailError={detail.error}
onRetryDetail={() => { void detail.refetch() }}
labels={labelItems}
labelPending={addLabel.isPending || removeLabel.isPending}
onSelect={openMessage}
@@ -1690,7 +1704,8 @@ export function MailPage() {
</div>
)}
{detail.isLoading && <div className="space-y-4 p-6"><Skeleton className="h-8 w-2/3" /><Skeleton className="h-4 w-1/3" /><Separator /><Skeleton className="h-40 w-full" /></div>}
{selected && <div className="flex h-full min-h-0 flex-col">
{detail.isError && <MailDetailFailure error={detail.error} onRetry={() => { void detail.refetch() }} />}
{!detail.isError && selected && <div className="flex h-full min-h-0 flex-col">
<div className="border-b p-5">
{isTwoPaneMailViewport && (
<Button variant="ghost" size="sm" className="-ml-2 mb-3 h-8 px-2 text-[13px] font-normal" onClick={() => setSelectedId(null)}>
@@ -1773,7 +1788,9 @@ export function MailPage() {
<SendQueueAuditDialog
open={!!sendQueueAuditId}
loading={sendQueueAudit.isLoading}
error={sendQueueAudit.error}
events={sendQueueAudit.data?.items || []}
onRetry={() => { void sendQueueAudit.refetch() }}
onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }}
/>
<MessageContextMenu
@@ -2143,6 +2160,45 @@ function PermissionEmptyState({ title, description, onOpenSettings }: { title: s
)
}
function MailQueryFailure({ error, onRetry }: { error: Error; onRetry: () => void }) {
return (
<div className="grid min-h-0 flex-1 place-items-center p-6" role="alert">
<div className="w-full max-w-md rounded-lg border border-destructive/40 bg-destructive/5 p-8 text-center">
<div className="text-lg font-semibold text-destructive"></div>
<div className="mt-2 break-words text-sm text-muted-foreground">{error.message || "请检查网络连接后重试"}</div>
<Button type="button" variant="outline" className="mt-5" onClick={onRetry}>
<RefreshCcw className="h-4 w-4" />
</Button>
</div>
</div>
)
}
function MailDetailFailure({ error, onRetry }: { error: Error; onRetry: () => void }) {
return (
<div className="grid h-full min-h-[240px] place-items-center p-6" role="alert">
<div className="max-w-md text-center">
<div className="font-semibold text-destructive"></div>
<div className="mt-2 break-words text-sm text-muted-foreground">{error.message || "请检查网络连接后重试"}</div>
<Button type="button" variant="outline" size="sm" className="mt-4" onClick={onRetry}>
<RefreshCcw className="h-4 w-4" />
</Button>
</div>
</div>
)
}
function SidebarQueryFailure({ label, onRetry }: { label: string; onRetry: () => void }) {
return (
<div className="mx-2 my-1 flex min-h-8 items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-2 text-xs text-destructive" role="alert">
<span className="min-w-0 flex-1 truncate">{label}</span>
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-destructive hover:text-destructive" aria-label={`重试${label}`} title="重新读取" onClick={onRetry}>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
)
}
function ScheduledSendView({ compact, items, total, loading, query, cancelingId, onCancel }: { compact: boolean; items: ScheduledSend[]; total: number; loading: boolean; query: string; cancelingId: string; onCancel: (item: ScheduledSend) => void }) {
const empty = query.trim() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件"
return (
@@ -2378,7 +2434,7 @@ function SendQueueStatusBadge({ status }: { status: SendQueueStatus }) {
)
}
function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: boolean; loading: boolean; events: SendQueueAuditEvent[]; onOpenChange: (open: boolean) => void }) {
function SendQueueAuditDialog({ open, loading, error, events, onRetry, onOpenChange }: { open: boolean; loading: boolean; error: Error | null; events: SendQueueAuditEvent[]; onRetry: () => void; onOpenChange: (open: boolean) => void }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(92vw,42rem)] max-w-none">
@@ -2387,8 +2443,9 @@ function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: b
</DialogHeader>
<div className="max-h-[60vh] overflow-auto pr-1">
{loading && <div className="space-y-3">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-14 w-full" />)}</div>}
{!loading && events.length === 0 && <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground"></div>}
{!loading && events.length > 0 && (
{!loading && error && <div className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-center" role="alert"><div className="text-sm font-medium text-destructive"></div><div className="mt-1 break-words text-xs text-muted-foreground">{error.message}</div><Button type="button" variant="outline" size="sm" className="mt-4" onClick={onRetry}><RefreshCcw className="h-4 w-4" /></Button></div>}
{!loading && !error && events.length === 0 && <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground"></div>}
{!loading && !error && events.length > 0 && (
<div className="space-y-3">
{events.map((event) => (
<div key={event.id} className="rounded-lg border p-3">
@@ -2709,6 +2766,7 @@ function CompactMailView({
selectedId,
selected,
detailLoading,
detailError,
labels,
labelPending,
onSelect,
@@ -2716,6 +2774,7 @@ function CompactMailView({
onToggleSelected,
scheduledDraftIds,
onLoadMore,
onRetryDetail,
onCloseReader,
onStar,
onReply,
@@ -2750,6 +2809,7 @@ function CompactMailView({
selectedId: string | null
selected?: MailMessage
detailLoading: boolean
detailError: Error | null
labels: MailLabel[]
labelPending: boolean
onSelect: (id: string | null) => void
@@ -2757,6 +2817,7 @@ function CompactMailView({
onToggleSelected: (id: string, checked: boolean) => void
scheduledDraftIds: Set<string>
onLoadMore: () => void
onRetryDetail: () => void
onCloseReader: () => void
onStar: (message: MailMessage) => void
onReply: (message: MailMessage) => void
@@ -2786,11 +2847,13 @@ function CompactMailView({
<CompactMessageDetail
selected={selected}
loading={detailLoading}
error={detailError}
labels={labels}
labelPending={labelPending}
previousMessage={previousMessage}
nextMessage={nextMessage}
onBack={onCloseReader}
onRetry={onRetryDetail}
onSelect={onSelect}
onStar={onStar}
onReply={onReply}
@@ -2853,11 +2916,13 @@ function CompactMailView({
function CompactMessageDetail({
selected,
loading,
error,
labels,
labelPending,
previousMessage,
nextMessage,
onBack,
onRetry,
onSelect,
onStar,
onReply,
@@ -2876,11 +2941,13 @@ function CompactMessageDetail({
}: {
selected?: MailMessage
loading: boolean
error: Error | null
labels: MailLabel[]
labelPending: boolean
previousMessage?: MailMessage
nextMessage?: MailMessage
onBack: () => void
onRetry: () => void
onSelect: (id: string | null) => void
onStar: (message: MailMessage) => void
onReply: (message: MailMessage) => void
@@ -2960,8 +3027,9 @@ function CompactMessageDetail({
</div>
</div>
{loading && <div className="space-y-4 p-8"><Skeleton className="h-8 w-2/3" /><Skeleton className="h-4 w-1/3" /><Separator /><Skeleton className="h-64 w-full" /></div>}
{!loading && !selected && <div className="grid flex-1 place-items-center text-sm text-muted-foreground"></div>}
{selected && (
{!loading && error && <MailDetailFailure error={error} onRetry={onRetry} />}
{!loading && !error && !selected && <div className="grid flex-1 place-items-center text-sm text-muted-foreground"></div>}
{!error && selected && (
<ScrollArea className="min-h-0 flex-1">
<div className="w-full px-4 py-4 sm:px-8 sm:py-6">
<div className="space-y-5 border-b pb-5">
@@ -3682,12 +3750,15 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
return sent
},
onSuccess: async (_, payloads) => {
let draftDeleteFailed = false
if (draftId) {
try {
await api.deleteDraft(draftId)
} catch {}
} catch {
draftDeleteFailed = true
}
}
toast({ title: payloads.length > 1 ? `已分别发送 ${payloads.length} 封邮件` : "发送成功" })
toast({ title: payloads.length > 1 ? `已分别发送 ${payloads.length} 封邮件` : "发送成功", description: draftDeleteFailed ? "邮件已发送,但原草稿未能自动删除" : undefined })
setFiles([])
setDraftId("")
onSent()
@@ -3952,6 +4023,14 @@ function ComposeDialog({ mailboxes, mailbox, open, draft, limits, canSend, canMa
<ComposeField label="主 题">
<Input name="subject" placeholder="输入主题" value={subjectValue} onChange={(event) => setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" />
</ComposeField>
{defaultSignature.isError && (
<div className="flex items-center justify-between gap-3 border-b bg-destructive/5 px-4 py-2 text-xs text-destructive sm:px-6" role="alert">
<span></span>
<Button type="button" variant="ghost" size="sm" className="h-7 text-destructive hover:text-destructive" onClick={() => { void defaultSignature.refetch() }}>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
)}
<MailBodyComposer
defaultValue={composerText}
defaultHtml={draft?.html}
@@ -4383,7 +4462,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
<DropdownMenuContent align="start" className="w-64 p-2">
<div className="grid grid-cols-8 gap-1">
{composerEmojiOptions.map((emoji) => (
<Button key={emoji} type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-lg" onClick={() => insertEmoji(emoji)}>
<Button key={emoji} type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-lg" aria-label={`插入表情 ${emoji}`} title={`插入 ${emoji}`} onClick={() => insertEmoji(emoji)}>
{emoji}
</Button>
))}
@@ -4495,7 +4574,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, max
<Paperclip className="h-3.5 w-3.5" />
<span className="max-w-48 truncate">{file.name}</span>
<span className="text-muted-foreground">{formatBytes(file.size)}</span>
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 rounded-md" onClick={() => onRemoveFile(index)}>
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 rounded-md" aria-label={`移除附件 ${file.name}`} title="移除附件" onClick={() => onRemoveFile(index)}>
<X className="h-3.5 w-3.5" />
</Button>
</Badge>
+2 -2
View File
@@ -4,7 +4,7 @@ import { Home, MailQuestion } from "lucide-react"
export function NotFoundPage() {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<main className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-sm text-center">
<div className="mb-6 flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
@@ -24,6 +24,6 @@ export function NotFoundPage() {
</Button>
</div>
</div>
</div>
</main>
)
}
+202 -78
View File
@@ -32,6 +32,7 @@ import { useToast } from "@/hooks/use-toast"
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens"
type AccountSettingsTab = "account" | "mail" | "clients" | "security"
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
type RetryableQuery = { isError: boolean; error: Error | null; refetch: () => Promise<unknown> }
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
profile: { label: "账号设置", icon: <Settings className="h-4 w-4" /> },
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
@@ -50,6 +51,8 @@ const accountSettingTabs: { key: AccountSettingsTab; label: string }[] = [
{ key: "clients", label: "通知与客户端" },
{ key: "security", label: "安全" },
]
const forwardingTargetCollator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
export function ProfilePage() {
const me = useMe()
const qc = useQueryClient()
@@ -176,7 +179,7 @@ export function ProfilePage() {
onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) },
onError: (error) => toast({ title: "保存失败", description: error.message }),
})
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } })
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
const createSignature = useMutation({
mutationFn: (form: FormData) => api.createSignature({ mailboxId: String(form.get("mailboxId") || ""), name: String(form.get("name") || ""), content: String(form.get("content") || ""), isDefault: form.get("isDefault") === "on" }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已保存" }) },
@@ -192,7 +195,7 @@ export function ProfilePage() {
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "默认签名已更新" }) },
onError: (error) => toast({ title: "设置失败", description: error.message }),
})
const deleteSignature = useMutation({ mutationFn: api.deleteSignature, onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已删除" }) } })
const deleteSignature = useMutation({ mutationFn: api.deleteSignature, onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
const createRule = useMutation({
mutationFn: (payload: {
mailboxId: string
@@ -214,7 +217,7 @@ export function ProfilePage() {
},
onError: (error) => toast({ title: "保存失败", description: error.message }),
})
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) } })
const deleteRule = useMutation({ mutationFn: api.deleteRule, onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); toast({ title: "规则已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
const updateRule = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Partial<RuleCreatePayload> }) => api.updateRule(id, payload),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["rules"] }); setRuleDialogOpen(false); toast({ title: "收件规则已更新" }) },
@@ -241,7 +244,7 @@ export function ProfilePage() {
onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已保存" }) },
onError: (error) => toast({ title: "保存失败", description: error.message }),
})
const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) } })
const deleteBlocked = useMutation({ mutationFn: api.deleteBlockedSender, onSuccess: () => { qc.invalidateQueries({ queryKey: ["blocked-senders"] }); toast({ title: "拦截规则已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) })
const createLabel = useMutation({
mutationFn: (form: FormData) => api.createLabel({ mailboxId: activeMailboxId, name: String(form.get("name") || ""), color: String(form.get("color") || "") }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "标签已创建" }) },
@@ -431,6 +434,15 @@ export function ProfilePage() {
</div>
)
function renderTab() {
const failedQueries = visibleTabQueries().filter((query) => query.isError && query.error)
if (failedQueries.length > 0) {
return (
<ProfileQueryFailure
errors={failedQueries.map((query) => query.error!)}
onRetry={() => { void Promise.all(failedQueries.map((query) => query.refetch())) }}
/>
)
}
if (tab === "profile") return (
<AccountSettingsSection
activeTab={accountTab}
@@ -504,6 +516,22 @@ export function ProfilePage() {
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} />
return null
}
function visibleTabQueries(): RetryableQuery[] {
if (tab === "profile") {
if (accountTab === "security") return []
if (accountTab === "account") return [mailboxes, accountStats]
if (accountTab === "mail") return [mailboxes, labels, signatures]
return [mailboxes, publicSettings]
}
if (tab === "mailboxes") return [mailboxes, mailboxApplyOptions, publicSettings, externalImapAccounts, externalRunFolders, externalSyncRuns]
if (tab === "contacts") return [contacts]
if (tab === "cleanup" || tab === "cleanupQueue") return [mailboxes, mailboxStats]
if (tab === "rules") return [mailboxes, rules, ruleForwarding, labels]
if (tab === "blocked") return [mailboxes, blocked, blockedStats]
if (tab === "stats") return [mailboxes, dashboardStats]
if (tab === "apiTokens") return [apiTokens]
return []
}
}
function contentFrameClass(tab: Tab) {
@@ -675,7 +703,20 @@ function SettingsCard({ title, subtitle, action, children, className, contentCla
)
}
function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) {
function ProfileQueryFailure({ errors, onRetry }: { errors: Error[]; onRetry: () => void }) {
const messages = Array.from(new Set(errors.map((error) => error.message).filter(Boolean)))
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-8 text-center" role="alert">
<div className="text-base font-semibold text-destructive"></div>
<div className="mt-2 break-words text-sm text-muted-foreground">{messages.join("") || "请检查网络连接后重试"}</div>
<Button type="button" variant="outline" className="mt-5" onClick={onRetry}>
<RefreshCcw className="h-4 w-4" />
</Button>
</div>
)
}
function AccountTabSection({ user, profile, stats, showStats, displayMode, onDisplayModeChange, selectedMailbox, mailboxes, onOpenCleanup }: { user: AccountSettingsSectionProps["user"]; profile: AccountSettingsSectionProps["profile"]; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; selectedMailbox?: Mailbox; mailboxes: Mailbox[]; onOpenCleanup: () => void }) {
const accountName = user.email
const quotaBytes = stats?.quotaBytes || (selectedMailbox?.quotaMb ? selectedMailbox.quotaMb * 1024 * 1024 : 0)
const storageBytes = stats?.storageBytes || 0
@@ -683,26 +724,35 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
return (
<div className="space-y-6">
<SettingsCard title="账号信息">
<div className="space-y-5">
<form className="space-y-5" onSubmit={(event) => { event.preventDefault(); profile.mutate(new FormData(event.currentTarget)) }}>
<InfoLine label="主登录邮箱" value={accountName} />
<div className="grid gap-2 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
<Label className="text-base font-normal text-muted-foreground"></Label>
<Select defaultValue="Asia/Shanghai">
<SelectTrigger className="h-[29px] sm:ml-auto sm:w-[236px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Asia/Shanghai">Asia/Shanghai (UTC+8)</SelectItem>
<SelectItem value="Asia/Tokyo">Asia/Tokyo (UTC+9)</SelectItem>
<SelectItem value="Asia/Singapore">Asia/Singapore (UTC+8)</SelectItem>
<SelectItem value="UTC">UTC (UTC+0)</SelectItem>
</SelectContent>
</Select>
<Label htmlFor="profile-display-name" className="text-base font-normal text-muted-foreground"></Label>
<div className="flex flex-col gap-2 sm:ml-auto sm:w-[320px] sm:flex-row">
<Input id="profile-display-name" name="displayName" defaultValue={user.displayName} maxLength={80} required className="h-9" />
<Button type="submit" size="sm" disabled={profile.isPending}>{profile.isPending ? "保存中..." : "保存"}</Button>
</div>
</div>
</form>
</SettingsCard>
<SettingsCard title="邮件列表显示">
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1" role="group" aria-label="邮件列表显示模式">
{(["detailed", "compact"] as DisplayMode[]).map((mode) => (
<button
key={mode}
type="button"
className={cn("h-9 rounded-md px-3 text-sm font-medium transition-colors", displayMode === mode ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
aria-pressed={displayMode === mode}
onClick={() => onDisplayModeChange(mode)}
>
{mode === "detailed" ? "标准布局" : "紧凑布局"}
</button>
))}
</div>
</SettingsCard>
<SettingsCard title="存储容量" action={<Button type="button" variant="outline" size="sm" onClick={onOpenCleanup}></Button>}>
{showStats && <SettingsCard title="存储容量" action={<Button type="button" variant="outline" size="sm" onClick={onOpenCleanup}></Button>}>
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
<span>{quotaBytes > 0 ? `${formatBytes(storageBytes)} / ${formatBytes(quotaBytes)}` : formatBytes(storageBytes)}</span>
<span>{quotaBytes > 0 ? `${quotaPct}%` : "不限"}</span>
@@ -710,7 +760,7 @@ function AccountTabSection({ user, stats, selectedMailbox, mailboxes, onOpenClea
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${quotaBytes > 0 ? quotaPct : 12}%` }} />
</div>
</SettingsCard>
</SettingsCard>}
<SettingsCard title="账号配额" action={<span className="pt-1 text-sm text-muted-foreground"></span>}>
<div className="grid gap-3 md:grid-cols-2">
@@ -860,8 +910,8 @@ function MailPreferencesSection({
</div>
<div className="flex shrink-0 gap-1">
{!item.isDefault && <Button type="button" variant="outline" size="sm" disabled={signaturesPending} onClick={() => onSetDefaultSignature(item.id)}></Button>}
<Button type="button" variant="ghost" size="icon" className="size-8" onClick={() => setEditingSignature(item)}><PencilLine className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => setPendingConfirm({ title: "删除签名?", description: `签名“${item.name}”将被删除。`, confirmText: "删除签名", destructive: true, onConfirm: () => { onDeleteSignature(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" className="size-8" aria-label={`编辑签名 ${item.name}`} title="编辑签名" onClick={() => setEditingSignature(item)}><PencilLine className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" className="size-8 text-destructive" aria-label={`删除签名 ${item.name}`} title="删除签名" onClick={() => setPendingConfirm({ title: "删除签名?", description: `签名“${item.name}”将被删除。`, confirmText: "删除签名", destructive: true, onConfirm: () => { onDeleteSignature(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
</div>
</div>
</div>
@@ -921,7 +971,7 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
<div className="flex size-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700"><ShieldCheck className="h-5 w-5" /></div>
<div>
<div className="font-semibold">{user.email}</div>
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
</SettingsCard>
@@ -997,27 +1047,10 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
)}
</SettingsCard>
<SettingsCard title="临时发信申请">
<div className="space-y-3">
{[selectedRequestRow(user.email, "已批准", "6小时"), selectedRequestRow(user.email, "已过期", "24小时")].map((item, index) => (
<div key={`${item.status}-${index}`} className="rounded-lg border px-4 py-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="font-medium">{item.email}</span>
<Badge variant="secondary">{item.status}</Badge>
</div>
<div className="mt-1 text-muted-foreground">{item.duration} · </div>
</div>
))}
</div>
</SettingsCard>
</div>
)
}
function selectedRequestRow(email: string, status: string, duration: string) {
return { email, status, duration }
}
function CleanupQueueSection({ mailbox, stats }: { mailbox?: Mailbox; stats?: MailStats }) {
const rows = (stats?.byFolder || []).filter((item) => item.role === "trash" || item.role === "spam").map((item) => ({ name: folderLabel(item.folder), count: item.count, bytes: item.bytes }))
return (
@@ -1093,13 +1126,14 @@ function MailboxManagement({
const [mailboxSearch, setMailboxSearch] = React.useState("")
const [forwardingMailbox, setForwardingMailbox] = React.useState<Mailbox | null>(null)
const [forwardDraft, setForwardDraft] = React.useState<string[]>([])
const [forwardingTargetPreview, setForwardingTargetPreview] = React.useState<{ title: string; subtitle: string; source: string; targets: string[] } | null>(null)
const [accountForwardTargets, setAccountForwardTargets] = React.useState<string[]>([])
const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
const [pendingExternalDelete, setPendingExternalDelete] = React.useState<ExternalImapAccount | null>(null)
const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 })
const verifiedEmailItems = forwarding.data?.verifiedEmails || []
const verifiedEmails = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified).map((item) => item.email), [verifiedEmailItems])
const verifiedEmailItems = React.useMemo(() => [...(forwarding.data?.verifiedEmails || [])].sort((a, b) => forwardingTargetCollator.compare(a.email, b.email)), [forwarding.data?.verifiedEmails])
const verifiedEmails = React.useMemo(() => sortForwardingTargets(verifiedEmailItems.filter((item) => item.verified).map((item) => item.email)), [verifiedEmailItems])
const hasPendingVerifiedEmails = verifiedEmailItems.some((item) => !item.verified)
const mailboxForwards = React.useMemo<Record<string, string[]>>(() => {
const next: Record<string, string[]> = {}
@@ -1113,9 +1147,10 @@ function MailboxManagement({
const domainOptions = applyOptions?.domains || []
const selectedDomain = domainOptions.find((domain) => domain.id === domainId) || domainOptions[0]
const selectedMailbox = mailboxes.find((mailbox) => mailbox.id === selectedMailboxId) || mailboxes[0]
const sortedMailboxes = React.useMemo(() => [...mailboxes].sort((a, b) => forwardingTargetCollator.compare(a.address, b.address)), [mailboxes])
const filteredMailboxes = normalizedMailboxSearch
? mailboxes.filter((mailbox) => mailbox.address.toLowerCase().includes(normalizedMailboxSearch))
: mailboxes
? sortedMailboxes.filter((mailbox) => mailbox.address.toLowerCase().includes(normalizedMailboxSearch))
: sortedMailboxes
const setForwardingCache = React.useCallback((settings: ForwardingSettings) => {
qc.setQueryData(["forwarding-settings"], settings)
}, [qc])
@@ -1203,13 +1238,14 @@ function MailboxManagement({
}
function openMailboxForward(mailbox: Mailbox) {
const mailboxTargets = mailboxForwards[mailbox.id]
setForwardingMailbox(mailbox)
setForwardDraft(mailboxForwards[mailbox.id] || [])
setForwardDraft(withoutForwardingTargets(mailboxTargets || [], accountForwardTargets))
}
function saveMailboxForward() {
if (!forwardingMailbox) return
saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmails: forwardDraft })
saveMailboxForwarding.mutate({ mailboxId: forwardingMailbox.id, targetEmails: withoutForwardingTargets(forwardDraft, accountForwardTargets) })
}
function submitVerifiedEmail(event: React.FormEvent<HTMLFormElement>) {
@@ -1275,9 +1311,11 @@ function MailboxManagement({
<div className="divide-y">
{filteredMailboxes.map((mailbox) => {
const forwardTargets = mailboxForwards[mailbox.id] || []
const accountForwardTargetActive = forwardTargets.length === 0 && accountForwardTargets.length > 0
const effectiveForwardTargets = forwardTargets.length > 0 ? forwardTargets : accountForwardTargetActive ? accountForwardTargets : []
const effectiveForwardTargets = mergeForwardingTargets(accountForwardTargets, forwardTargets)
const forwardingActive = effectiveForwardTargets.length > 0
const forwardingSource = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "账号级 + 邮箱单独" : accountForwardTargets.length > 0 ? "账号级" : "邮箱单独"
const forwardingSubtitle = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "账号级目标固定生效,并追加邮箱单独目标" : accountForwardTargets.length > 0 ? "继承账号级转发" : "邮箱单独转发"
const forwardingPrefix = accountForwardTargets.length > 0 && forwardTargets.length > 0 ? "转发:账号级+单独" : accountForwardTargets.length > 0 ? "转发:使用账号级" : "转发:"
return (
<div key={mailbox.id} className={cn("grid gap-3 px-6 py-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center", selectedMailboxId === mailbox.id && "bg-muted/50")}>
<div className="flex min-w-0 items-center gap-4">
@@ -1291,8 +1329,13 @@ function MailboxManagement({
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span> {formatDateTime(mailbox.createdAt)}</span>
{forwardTargets.length > 0 && <span className="max-w-full truncate font-semibold text-foreground">{forwardTargets.join("、")}</span>}
{accountForwardTargetActive && <span className="max-w-full truncate font-semibold text-foreground">使 {accountForwardTargets.join("、")}</span>}
{effectiveForwardTargets.length > 0 && (
<ForwardingTargetSummary
prefix={forwardingPrefix}
targets={effectiveForwardTargets}
onView={() => setForwardingTargetPreview({ title: mailbox.address, subtitle: forwardingSubtitle, source: forwardingSource, targets: effectiveForwardTargets })}
/>
)}
</div>
</div>
</div>
@@ -1313,7 +1356,7 @@ function MailboxManagement({
</div>
<div className="rounded-xl bg-muted/20 px-5 py-5">
<div className="mb-3 text-sm font-medium"></div>
<div className="mb-4 text-sm text-muted-foreground"></div>
<div className="mb-4 text-sm text-muted-foreground"></div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_72px] md:items-start">
<ForwardingTargetPicker emails={verifiedEmails} selected={accountForwardTargets} onChange={setAccountForwardTargets} disabled={forwardingBusy} />
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
@@ -1333,7 +1376,7 @@ function MailboxManagement({
</div>
)}
{verifiedEmails.length === 0 && <p className="mt-4 text-sm text-muted-foreground"></p>}
<p className="mt-3 text-sm text-muted-foreground"></p>
<p className="mt-3 text-sm text-muted-foreground"></p>
</section>
{externalImapEnabled && (
@@ -1390,16 +1433,42 @@ function MailboxManagement({
<div className="space-y-2">
<Label className="text-sm font-medium"></Label>
<div className="grid grid-cols-[minmax(0,1fr)_64px_64px] items-start gap-2">
<ForwardingTargetPicker emails={verifiedEmails} selected={forwardDraft} onChange={setForwardDraft} disabled={forwardingBusy} />
<ForwardingTargetPicker emails={verifiedEmails} selected={forwardDraft} lockedSelected={accountForwardTargets} lockedLabel="账号级" onChange={(targets) => setForwardDraft(withoutForwardingTargets(targets, accountForwardTargets))} disabled={forwardingBusy} />
<Button type="button" variant="outline" className="h-[37px] px-0" disabled={forwardingBusy} onClick={() => setForwardingMailbox(null)}></Button>
<Button type="button" className="h-[37px] px-0" disabled={forwardingBusy} onClick={saveMailboxForward}>{saveMailboxForwarding.isPending ? "保存中" : "保存"}</Button>
</div>
{accountForwardTargets.length > 0 && <p className="text-xs text-muted-foreground"></p>}
</div>
{verifiedEmails.length === 0 && <p className="text-sm text-muted-foreground"></p>}
</div>
</DialogContent>
</Dialog>
<Dialog open={!!forwardingTargetPreview} onOpenChange={(open) => { if (!open) setForwardingTargetPreview(null) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
{forwardingTargetPreview && (
<div className="space-y-4">
<div className="min-w-0">
<div className="truncate text-sm text-muted-foreground">{forwardingTargetPreview.title}</div>
<div className="mt-2 flex items-center gap-2">
<Badge variant="secondary">{forwardingTargetPreview.source}</Badge>
<span className="text-sm text-muted-foreground">{forwardingTargetPreview.subtitle}</span>
</div>
</div>
<div className="max-h-[320px] overflow-y-auto rounded-lg border">
{forwardingTargetPreview.targets.map((email) => (
<div key={email} className="flex min-h-11 items-center gap-3 border-b px-3 py-2 text-sm last:border-b-0">
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
<span className="min-w-0 break-all font-medium">{email}</span>
</div>
))}
</div>
</div>
)}
</DialogContent>
</Dialog>
<Dialog open={verifiedDialogOpen} onOpenChange={setVerifiedDialogOpen}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
<DialogHeader className="px-8 pt-8">
@@ -1632,36 +1701,81 @@ function formatDateTime(value: string) {
return date.toLocaleString()
}
function ForwardingTargetSummary({ targets, prefix = "转发:", onView }: { targets: string[]; prefix?: string; onView: () => void }) {
if (targets.length === 0) return null
const prefixText = prefix.endsWith("") ? prefix : `${prefix} `
return (
<span className="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold text-foreground" title={`${prefixText}${targets.join("、")}`}>
<span className="min-w-0 truncate">{prefixText}{targets[0]}</span>
{targets.length > 1 && (
<Button type="button" variant="link" className="h-auto shrink-0 px-1 py-0 text-xs font-semibold" onClick={onView}>
{targets.length}
</Button>
)}
</span>
)
}
function normalizeForwardingTarget(value: string) {
return value.trim().toLowerCase()
}
function sortForwardingTargets(targets: string[]) {
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean))).sort((a, b) => forwardingTargetCollator.compare(a, b))
}
function mergeForwardingTargets(...groups: string[][]) {
const seen = new Set<string>()
const merged: string[] = []
for (const target of groups.flat()) {
const value = target.trim()
const key = normalizeForwardingTarget(value)
if (!value || seen.has(key)) continue
seen.add(key)
merged.push(value)
}
return sortForwardingTargets(merged)
}
function withoutForwardingTargets(targets: string[], lockedTargets: string[]) {
const locked = new Set(lockedTargets.map(normalizeForwardingTarget))
return sortForwardingTargets(targets.filter((target) => !locked.has(normalizeForwardingTarget(target))))
}
function forwardingTargetsFromRule(rule: { targetEmail?: string; targetEmails?: string[] }) {
const targets = rule.targetEmails?.length ? rule.targetEmails : rule.targetEmail ? [rule.targetEmail] : []
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean)))
return sortForwardingTargets(targets)
}
function forwardingTargetsFromSettings(settings?: ForwardingSettings) {
const targets = settings?.accountTargetEmails?.length ? settings.accountTargetEmails : settings?.accountTargetEmail ? [settings.accountTargetEmail] : []
return Array.from(new Set(targets.map((item) => item.trim()).filter(Boolean)))
return sortForwardingTargets(targets)
}
function ForwardingTargetPicker({ emails, selected, onChange, disabled, placement = "bottom" }: { emails: string[]; selected: string[]; onChange: (targets: string[]) => void; disabled?: boolean; placement?: "bottom" | "top" }) {
function ForwardingTargetPicker({ emails, selected, lockedSelected = [], lockedLabel = "账号级", onChange, disabled, placement = "bottom" }: { emails: string[]; selected: string[]; lockedSelected?: string[]; lockedLabel?: string; onChange: (targets: string[]) => void; disabled?: boolean; placement?: "bottom" | "top" }) {
const [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState("")
const selectedSet = React.useMemo(() => new Set(selected), [selected])
const lockedEmails = React.useMemo(() => sortForwardingTargets(lockedSelected), [lockedSelected])
const lockedSet = React.useMemo(() => new Set(lockedEmails.map(normalizeForwardingTarget)), [lockedEmails])
const selectedEmailsOnly = React.useMemo(() => withoutForwardingTargets(selected, lockedEmails), [lockedEmails, selected])
const selectedSet = React.useMemo(() => new Set(selectedEmailsOnly.map(normalizeForwardingTarget)), [selectedEmailsOnly])
const sortedEmails = React.useMemo(() => {
const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
return Array.from(new Set(emails.map((email) => email.trim()).filter(Boolean))).sort((a, b) => collator.compare(a, b))
return sortForwardingTargets(emails)
}, [emails])
const selectedEmails = React.useMemo(() => sortedEmails.filter((email) => selectedSet.has(email)), [selectedSet, sortedEmails])
const regularEmails = React.useMemo(() => sortedEmails.filter((email) => !lockedSet.has(normalizeForwardingTarget(email))), [lockedSet, sortedEmails])
const selectedEmails = React.useMemo(() => [...lockedEmails, ...regularEmails.filter((email) => selectedSet.has(normalizeForwardingTarget(email)))], [lockedEmails, regularEmails, selectedSet])
const normalizedQuery = query.trim().toLowerCase()
const filteredEmails = normalizedQuery ? sortedEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : sortedEmails
const filteredLockedEmails = normalizedQuery ? lockedEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : lockedEmails
const filteredEmails = normalizedQuery ? regularEmails.filter((email) => email.toLowerCase().includes(normalizedQuery)) : regularEmails
const label = selectedEmails.length
? `已选择 ${selectedEmails.length} 个:${selectedEmails.slice(0, 2).join("、")}${selectedEmails.length > 2 ? `${selectedEmails.length}` : ""}`
: "不转发,点击选择邮箱"
function toggle(email: string, checked: boolean) {
if (disabled) return
const next = checked ? Array.from(new Set([...selected, email])) : selected.filter((item) => item !== email)
onChange(next)
const next = checked ? mergeForwardingTargets(selectedEmailsOnly, [email]) : selectedEmailsOnly.filter((item) => normalizeForwardingTarget(item) !== normalizeForwardingTarget(email))
onChange(withoutForwardingTargets(next, lockedEmails))
}
if (emails.length === 0) {
if (sortedEmails.length === 0 && lockedEmails.length === 0) {
return <div className="rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground"></div>
}
return (
@@ -1693,7 +1807,7 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
autoFocus
/>
{query && (
<Button type="button" variant="ghost" size="icon" className="absolute right-0.5 top-0.5 size-7 text-muted-foreground" onClick={() => setQuery("")}>
<Button type="button" variant="ghost" size="icon" className="absolute right-0.5 top-0.5 size-7 text-muted-foreground" aria-label="清除搜索" title="清除搜索" onClick={() => setQuery("")}>
<X className="h-3.5 w-3.5" />
</Button>
)}
@@ -1704,8 +1818,18 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
</div>
<div className="mt-2 max-h-[132px] overflow-y-auto pr-1">
<div className="space-y-1">
{filteredLockedEmails.map((email) => (
<label
key={`locked-${email}`}
className="flex min-h-[36px] cursor-not-allowed items-center gap-2 rounded-md border border-border bg-muted/70 px-2.5 py-1.5 text-sm font-semibold text-muted-foreground"
>
<Checkbox checked disabled />
<span className="min-w-0 flex-1 truncate">{email}</span>
<Badge variant="outline" className="h-5 shrink-0 rounded px-1.5 text-[10px]">{lockedLabel}</Badge>
</label>
))}
{filteredEmails.map((email) => {
const checked = selectedSet.has(email)
const checked = selectedSet.has(normalizeForwardingTarget(email))
return (
<label
key={email}
@@ -1719,7 +1843,7 @@ function ForwardingTargetPicker({ emails, selected, onChange, disabled, placemen
</label>
)
})}
{filteredEmails.length === 0 && <div className="px-2 py-8 text-center text-sm text-muted-foreground"></div>}
{filteredLockedEmails.length === 0 && filteredEmails.length === 0 && <div className="px-2 py-8 text-center text-sm text-muted-foreground"></div>}
</div>
</div>
</div>
@@ -1844,7 +1968,7 @@ function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelec
<div className="text-muted-foreground"></div>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate text-right sm:text-left">{selected.address}</span>
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(selected.address)}><Copy className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label="复制邮箱地址" title="复制邮箱地址" onClick={() => onCopy(selected.address)}><Copy className="h-4 w-4" /></Button>
</div>
<div className="text-muted-foreground"></div>
<div></div>
@@ -1868,7 +1992,7 @@ function ClientConfigRow({ label, value, security, onCopy }: { label: string; va
<code className="truncate rounded border bg-background px-2 py-1 text-xs">{value}</code>
<div className="flex shrink-0 items-center gap-1">
<span className="text-xs font-medium text-emerald-600">{security}</span>
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(value)}><Copy className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label={`复制${label}`} title={`复制${label}`} onClick={() => onCopy(value)}><Copy className="h-4 w-4" /></Button>
</div>
</div>
</div>
@@ -2035,8 +2159,8 @@ function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }
<div className="truncate text-xs text-muted-foreground">{item.email}{item.note ? ` · ${item.note}` : ""}</div>
</div>
<div className="flex shrink-0 gap-1">
<Button variant="ghost" size="icon" className="size-8" onClick={() => onCopy(item.email)}><Copy className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" className="size-8 text-destructive" onClick={() => setPendingConfirm({ title: "删除联系人?", description: `${item.email} 将从联系人列表中移除。`, confirmText: "删除联系人", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" className="size-8" aria-label={`复制联系人邮箱 ${item.email}`} title="复制邮箱" onClick={() => onCopy(item.email)}><Copy className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" className="size-8 text-destructive" aria-label={`删除联系人 ${item.email}`} title="删除联系人" onClick={() => setPendingConfirm({ title: "删除联系人?", description: `${item.email} 将从联系人列表中移除。`, confirmText: "删除联系人", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
</div>
</div>
))}
@@ -2204,8 +2328,8 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
</Select>
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" aria-label={`删除第 ${index + 1} 个条件`} title="删除条件" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" aria-label="添加条件" title="添加条件" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
</div>
))}
</div>
@@ -2221,8 +2345,8 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, verifiedEmails, pen
<SelectContent>{(Object.keys(ruleActionLabels) as MailRuleAction["type"][]).map((value) => <SelectItem key={value} value={value}>{ruleActionLabels[value]}</SelectItem>)}</SelectContent>
</Select>
<RuleActionValue action={action} labels={availableLabels} verifiedEmails={verifiedEmails} onChange={(patch) => updateAction(index, patch)} />
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" onClick={addAction}><Plus className="h-4 w-4" /></Button>}
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" aria-label={`删除第 ${index + 1} 个操作`} title="删除操作" onClick={() => removeAction(index)} disabled={actions.length === 1}><X className="h-4 w-4" /></Button>
{action.type !== "forward" && <Button type="button" variant="ghost" size="icon" aria-label="添加操作" title="添加操作" onClick={addAction}><Plus className="h-4 w-4" /></Button>}
</div>
))}
</div>
@@ -2407,7 +2531,7 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang
<div className="truncate text-sm font-semibold text-foreground">{item.email}</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">{item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address : "全部邮箱"}{item.reason ? ` · ${item.reason}` : ""}</div>
</div>
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" className="size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`移除拦截规则 ${item.email}`} title="移除拦截规则" onClick={() => setPendingConfirm({ title: "移除拦截规则?", description: `${item.email} 之后将不再被此规则拦截。`, confirmText: "移除规则", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
</div>
))}
{items.length === 0 && <EmptyState icon={<ShieldCheck />} text="没有被拦截的邮件" description="当前没有发件人拦截规则,所有邮件都会按正常规则投递。" />}
@@ -2678,10 +2802,10 @@ function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name:
<div className="min-w-0 truncate text-sm font-semibold leading-5">{displayName}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" onClick={onToggleTheme}>
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label={darkMode ? "切换浅色模式" : "切换深色模式"} title={darkMode ? "浅色模式" : "深色模式"} onClick={onToggleTheme}>
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" onClick={onBack}>
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label="返回邮箱" title="返回邮箱" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
+5 -3
View File
@@ -13,6 +13,7 @@ import { useToast } from "@/hooks/use-toast"
import { PasswordInput } from "@/components/ui/password-input"
import { TurnstileBox } from "@/components/turnstile-box"
import { validatePasswordConfirm } from "@/lib/validation"
import { AuthError, AuthLoading } from "@/components/auth-states"
export function RegisterPage() {
const me = useMe()
@@ -57,8 +58,10 @@ export function RegisterPage() {
})
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
if (me.data?.user) return <Navigate to="/" replace />
if (publicSettings.isLoading) return <AuthLoading />
if (publicSettings.isError) return <AuthError message={publicSettings.error.message} onRetry={() => { void publicSettings.refetch() }} />
return (
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<main className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
<div className="w-full max-w-[420px]">
<div className="mb-7 text-center">
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
@@ -103,7 +106,6 @@ export function RegisterPage() {
<div className="space-y-2">
<Label htmlFor="displayName" className="text-sm font-medium"></Label>
<Input id="displayName" name="displayName" autoComplete="name" required className="h-11 text-base" />
<p className="text-xs leading-5 text-muted-foreground"></p>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"></Label>
@@ -130,6 +132,6 @@ export function RegisterPage() {
</div>
)}
</div>
</div>
</main>
)
}
+38 -13
View File
@@ -1,19 +1,44 @@
import path from "node:path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { defineConfig, loadEnv } from "vite"
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "")
const apiTarget = env.VITE_API_TARGET || "http://localhost:8080"
return {
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
},
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8080",
"/healthz": "http://localhost:8080",
server: {
port: 5173,
proxy: {
"/api": apiTarget,
"/healthz": apiTarget,
},
},
},
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: "prosemirror",
test: /node_modules[\\/]prosemirror-/,
priority: 30,
},
{
name: "tiptap",
test: /node_modules[\\/]@tiptap/,
priority: 20,
},
],
},
},
},
},
}
})
+5 -4
View File
@@ -44,7 +44,7 @@
| NSX-20260806-007 | 2026-08-06 | 已完成 | 前端/UI;后端/通知;部署运维/CI | 邮箱下拉层越界、验证码漏识别、邮件与版本通知链接样式 | S3 | v1.2.18 | 随 v1.2.18 发布 |
| NSX-20260807-008 | 2026-08-07 | 已完成 | 前端/UI;邮件发送;部署运维/安装 | 全部邮箱写信无法选择发件邮箱且默认项错误,写信窗口过宽;安装管理员邮箱流程需明确 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-009 | 2026-08-07 | 已完成 | 部署运维/安装;文档;质量复核 | 一键安装主菜单未按安装状态区分,命令前置条件和状态显示需复核 | S2 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-010 | 2026-08-07 | 待验收 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 发布,线上实发验收 |
| NSX-20260807-010 | 2026-08-07 | 已完成 | 邮件投递;DKIM;部署运维;质量复核 | Rspamd 在域名密钥变化后继续使用旧私钥,导致外发邮件 DKIM 验证失败 | S2 | v1.2.19 | 随 v1.2.19 发布,线上 DKIM 已验收通过 |
| NSX-20260807-011 | 2026-08-07 | 已完成 | 后端/通知;验证码识别;质量复核 | 邮件地址中的字母数字片段触发验证码候选冲突,导致 Telegram 不显示验证码与复制按钮 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-012 | 2026-08-07 | 已完成 | 前端/UI;域名管理;质量复核 | DNS 记录复制按钮把类型、名称和值拼成整行,无法直接粘贴到域名服务商对应字段 | S3 | v1.2.19 | 随 v1.2.19 发布 |
| NSX-20260807-013 | 2026-08-07 | 已完成 | 邮件核心;前端/标签;数据迁移;质量复核 | 邮箱创建后没有常用默认标签,需要手动逐个建立 | S3 | v1.2.19 | 随 v1.2.19 发布 |
@@ -336,16 +336,16 @@
| --- | --- |
| 编号 | NSX-20260807-010 |
| 日期 | 2026-08-07 |
| 状态 | 待验收 |
| 状态 | 已完成 |
| 模块 | 邮件投递;DKIM;部署运维;质量复核 |
| 现象 | Gmail 显示 SPF 和 DMARC 通过,但 NewSzxcn 发出的邮件 DKIM 验证失败;同内容的 NodeSeek 对照邮件三项认证均通过。 |
| 诊断 | NewSzxcn 邮件的 relaxed 正文哈希与 Gmail 收到的 `bh` 精确一致,排除正文传输修改;邮件签名无法由当前 DNS 公钥验证,说明发信时使用了不同私钥。`key not secure` 仅表示 DNSSEC 未验证,TXT 分段也属于正常 DNS 表示。 |
| 根因 | DKIM 同步任务会覆盖容器内密钥文件,但 Rspamd 已载入的签名密钥不会随文件替换自动更新,域名重建或密钥变化后可能继续使用内存中的旧私钥。 |
| 实现 | DKIM 同步改为先比较密钥内容;相同密钥不再重复替换,密钥新增或变化后立即重新载入 Rspamd;后台 DNS 检查从“仅判断 DKIM 记录存在”升级为核对实际 `p=` 公钥,明确区分缺失与公钥不一致;新增密钥同步和 DNS 公钥匹配回归,并纳入 CI 与发布检查。 |
| 兼容性 | 不轮换现有 DKIM 密钥、不修改 DNS;已有数据库、邮件和域名配置保持不变。 |
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上仍需升级后重新发信,确认 Gmail 原始邮件显示 `dkim=pass`。 |
| 测试结果 | DKIM 同步专项测试、DNS 公钥匹配测试、Go 全量测试与 vet、安装脚本回归均通过;线上 DKIM 已验收通过。 |
| 目标版本 | v1.2.19 |
| 发布状态 | 已随 v1.2.19 发布,待服务器升级后实发验收。 |
| 发布状态 | 已随 v1.2.19 发布,线上 DKIM 已验收通过。 |
### 历史
@@ -354,6 +354,7 @@
| 2026-08-07 | 用户提供 Gmail 原始邮件和 NodeSeek 对照邮件,完成正文哈希、签名公钥和认证结果比对。 |
| 2026-08-07 | 完成 DKIM 密钥热更新修复和专项回归,等待服务器实发验收。 |
| 2026-08-07 | 修复已随 v1.2.19 发布;保留待验收状态,需升级后确认 Gmail 原始邮件显示 `dkim=pass`。 |
| 2026-08-08 | 用户确认 DKIM 已通过,状态流转为已完成。 |
## NSX-20260807-011
+5 -5
View File
@@ -81,8 +81,8 @@ importers:
specifier: 2.1.1
version: 2.1.1
dompurify:
specifier: 3.4.12
version: 3.4.12
specifier: 3.4.13
version: 3.4.13
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@18.3.1)
@@ -1022,8 +1022,8 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dompurify@3.4.12:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
dompurify@3.4.13:
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
electron-to-chromium@1.5.375:
resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==}
@@ -2447,7 +2447,7 @@ snapshots:
dlv@1.1.3: {}
dompurify@3.4.12:
dompurify@3.4.13:
optionalDependencies:
'@types/trusted-types': 2.0.7