Compare commits

..

4 Commits

Author SHA1 Message Date
zxyszx 9cb3f13b02 fix: remove duplicate verified email entry
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 21:16:34 +08:00
zxyszx d2cfad3cc4 feat: streamline forwarding email verification
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 20:24:02 +08:00
zxyszx dbd5b95143 fix: support SMTP LOGIN authentication
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 15:42:05 +08:00
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
30 changed files with 608 additions and 230 deletions
+8
View File
@@ -0,0 +1,8 @@
- 优化登录与会话流程:登录后完整返回原页面及查询条件,区分未登录、超时、取消和网络故障,并为服务不可用状态提供明确的重新连接入口。
- 完善后台与邮箱错误处理:管理员设置、邮件列表、邮件详情、发送队列、个人资料、签名、DNS 检查及注册流程均增加真实失败提示和重试反馈,避免静默失败或错误成功提示。
- 优化邮箱与个人设置:恢复显示名称编辑,新增可持久化的标准/紧凑邮件布局,移除无效时区和模拟资料,修正发信成功后草稿删除失败的提示逻辑。
- 保持并强化转发规则:账号级转发目标在所有邮箱中默认勾选、置顶且不可取消,单邮箱可继续追加独立目标,现有单邮箱配置不受影响,目标按数字和字母排序。
- 统一界面视觉与交互:采用更清晰的中性色、文字层级、焦点状态和紧凑圆角,补充图标按钮名称、工具提示及全屏页面主区域,改善桌面与移动端可访问性。
- 优化前端加载与开发体验:拆分邮件编辑器相关代码包,控制单个构建文件体积,并支持通过 `VITE_API_TARGET` 指定本地后端代理地址。
- 加固邮件 HTML 安全:升级 DOMPurify 至已修复版本,依赖审计无已知漏洞;同步清理过时演示数据、无效状态和旧页面逻辑。
- 完成全量质量复核:前后端构建、Go 全量及竞态测试、SMTP、外部 IMAP、OAuth、Telegram、Webhook、转发、队列、权限隔离、安装、备份、回滚和 DKIM 同步测试均已通过。
+5
View File
@@ -0,0 +1,5 @@
- 修复 QQ 邮箱、网易邮箱、Gmail 等第三方客户端可以收信但无法发信的问题。
- SMTP 提交服务新增 `AUTH LOGIN` 认证支持,并继续兼容 `AUTH PLAIN`;两种方式均只允许在 TLS 加密连接中使用。
- 兼容带初始用户名和标准两步用户名/密码挑战的 LOGIN 流程,适配常见手机邮箱、Apple Mail 和 Thunderbird。
- 客户端配置保持 IMAP 993/SSL、POP3 995/SSL、SMTP 465/SSL,不增加额外服务器地址或备用配置。
- 新增 SMTP 能力声明、LOGIN 认证、STARTTLS、隐式 TLS 和完整发信回归测试。
+5
View File
@@ -0,0 +1,5 @@
- 优化转发验证完成页:移除“返回邮箱”入口,外部收件人确认 Netflix、ChatGPT 等验证码转发授权后不会进入邮箱登录页,只显示验证结果和关闭页面提示。
- 合并验证邮箱搜索与添加入口:输入内容会实时筛选已添加地址,输入新邮箱时可直接发送验证邮件,已存在地址会明确显示为“已添加”。
- 重整验证邮箱管理列表:待验证邮箱置顶展示,已验证邮箱按数字和字母排序并聚合为可折叠分组,邮箱数量较多时仍便于查找和管理。
- 精简邮件转发主页面:不再平铺全部验证邮箱标签,改为显示已验证与待验证数量汇总,点击即可进入管理列表。
- 补充验证完成页回归测试,确保页面不再出现邮箱首页或登录入口,并完成前端构建、组件规范、后端全量测试与静态检查。
+3
View File
@@ -0,0 +1,3 @@
- 精简邮件转发页面入口,移除右上角重复的“管理验证邮箱”按钮。
- 保留下方汇总入口并统一命名为“管理验证邮箱”,继续显示已验证与待验证邮箱数量。
- 没有验证邮箱时仍显示该入口,用户可直接进入添加和验证邮箱。
+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.24
+63 -1
View File
@@ -2613,6 +2613,25 @@ func TestMailSendQueuesSMTPFailureForRetry(t *testing.T) {
}
}
func TestForwardingVerificationPageDoesNotLinkToMailbox(t *testing.T) {
a := newTestApp(t)
recorder := httptest.NewRecorder()
a.renderForwardingVerificationPage(recorder, http.StatusOK, true, "friend@example.test", "该邮箱已通过转发验证")
body := recorder.Body.String()
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d", recorder.Code)
}
for _, forbidden := range []string{`href="/"`, "返回邮箱", "登录"} {
if strings.Contains(body, forbidden) {
t.Fatalf("verification page contains forbidden navigation %q: %s", forbidden, body)
}
}
if !strings.Contains(body, "可以关闭此页面") {
t.Fatalf("verification page is missing close guidance: %s", body)
}
}
func TestInboundForwardingSettingsAndDelivery(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
@@ -4492,6 +4511,49 @@ func TestSubmissionTLSConfigReloadsCertificateFiles(t *testing.T) {
}
}
func TestSubmissionLoginAuthenticationWithAndWithoutInitialResponse(t *testing.T) {
a := newTestApp(t)
for _, withInitialResponse := range []bool{false, true} {
t.Run(fmt.Sprintf("initial-response-%t", withInitialResponse), func(t *testing.T) {
session := &submissionSession{app: a}
if mechanisms := strings.Join(session.AuthMechanisms(), " "); mechanisms != "PLAIN LOGIN" {
t.Fatalf("submission auth mechanisms=%q", mechanisms)
}
server, err := session.Auth(sasl.Login)
if err != nil {
t.Fatal(err)
}
var response []byte
if withInitialResponse {
response = []byte("admin@lanqin.local")
}
challenge, done, err := server.Next(response)
if err != nil || done {
t.Fatalf("initial LOGIN response err=%v done=%t", err, done)
}
if !withInitialResponse {
if string(challenge) != "Username:" {
t.Fatalf("username challenge=%q", challenge)
}
challenge, done, err = server.Next([]byte("admin@lanqin.local"))
if err != nil || done {
t.Fatalf("username response err=%v done=%t", err, done)
}
}
if string(challenge) != "Password:" {
t.Fatalf("password challenge=%q", challenge)
}
challenge, done, err = server.Next([]byte("ChangeMe123!"))
if err != nil || !done || challenge != nil {
t.Fatalf("password response challenge=%q err=%v done=%t", challenge, err, done)
}
if session.user == nil || session.mailbox == nil {
t.Fatal("LOGIN authentication did not populate submission session")
}
})
}
}
func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 2)
@@ -4529,7 +4591,7 @@ func TestSubmissionServersAcceptStartTLSAndImplicitTLS(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := client.Auth(sasl.NewPlainClient("", "admin@lanqin.local", "ChangeMe123!")); err != nil {
if err := client.Auth(sasl.NewLoginClient("admin@lanqin.local", "ChangeMe123!")); err != nil {
t.Fatal(err)
}
if err := client.SendMail("admin@lanqin.local", []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
+8 -4
View File
@@ -165,7 +165,7 @@ func (a *App) handleVerifyForwardingEmail(w http.ResponseWriter, r *http.Request
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
return
}
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "验证完成,可以回到设置页选择此转发目标")
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "该邮箱已通过转发验证")
}
func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
@@ -439,14 +439,18 @@ func (a *App) renderForwardingVerificationPage(w http.ResponseWriter, status int
title := "邮箱转发验证"
heading := "验证失败"
color := "#dc2626"
statusMark := "!"
closingMessage := "请联系验证发起人重新发送链接"
if ok {
heading = "验证完成"
color = "#2563eb"
color = "#16a34a"
statusMark = "✓"
closingMessage = "验证结果已记录,可以关闭此页面"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:14px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 26px;font-size:15px;color:#64748b">%s</p><a href="/" style="display:inline-block;border-radius:8px;background:%s;color:white;text-decoration:none;padding:12px 18px;font-weight:700">返回邮箱</a></section></main></body></html>`,
title, heading, htmlEscape(message), htmlEscape(email), color)
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:8px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><div aria-hidden="true" style="display:grid;place-items:center;width:44px;height:44px;margin:0 0 20px;border-radius:50%%;background:%s;color:white;font-size:24px;font-weight:700">%s</div><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 24px;font-size:15px;color:#64748b;word-break:break-all">%s</p><p style="margin:0;padding-top:20px;border-top:1px solid #e2e8f0;font-size:15px;color:#64748b">%s</p></section></main></body></html>`,
title, color, statusMark, heading, htmlEscape(message), htmlEscape(email), htmlEscape(closingMessage))
}
func (a *App) cleanForwardingVerificationEmail(w http.ResponseWriter, r *http.Request, userID, value string) (string, bool) {
+44 -6
View File
@@ -121,21 +121,59 @@ type submissionSession struct {
}
func (s *submissionSession) AuthMechanisms() []string {
return []string{sasl.Plain}
return []string{sasl.Plain, sasl.Login}
}
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
if !strings.EqualFold(mech, sasl.Plain) {
return nil, smtpserver.ErrAuthUnknownMechanism
}
return sasl.NewPlainServer(func(identity, username, password string) error {
authenticate := func(username, password string) error {
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
if err != nil {
return smtpserver.ErrAuthFailed
}
s.user, s.mailbox = user, mailbox
return nil
}), nil
}
switch {
case strings.EqualFold(mech, sasl.Plain):
return sasl.NewPlainServer(func(_, username, password string) error {
return authenticate(username, password)
}), nil
case strings.EqualFold(mech, sasl.Login):
return &submissionLoginServer{authenticate: authenticate}, nil
default:
return nil, smtpserver.ErrAuthUnknownMechanism
}
}
type submissionLoginServer struct {
authenticate func(username, password string) error
username string
step int
}
func (s *submissionLoginServer) Next(response []byte) ([]byte, bool, error) {
switch s.step {
case 0:
if response == nil {
s.step = 1
return []byte("Username:"), false, nil
}
s.username = string(response)
s.step = 2
return []byte("Password:"), false, nil
case 1:
s.username = string(response)
s.step = 2
return []byte("Password:"), false, nil
case 2:
if err := s.authenticate(s.username, string(response)); err != nil {
return nil, false, err
}
s.step = 3
return nil, true, nil
default:
return nil, false, sasl.ErrUnexpectedClientResponse
}
}
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
+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>
)
}
+173 -105
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" /> },
@@ -178,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: "签名已保存" }) },
@@ -194,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
@@ -216,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: "收件规则已更新" }) },
@@ -243,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: "标签已创建" }) },
@@ -433,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}
@@ -506,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) {
@@ -677,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
@@ -685,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>
@@ -712,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">
@@ -862,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>
@@ -923,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>
@@ -999,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 (
@@ -1099,10 +1130,21 @@ function MailboxManagement({
const [accountForwardTargets, setAccountForwardTargets] = React.useState<string[]>([])
const [verifiedDialogOpen, setVerifiedDialogOpen] = React.useState(false)
const [verifiedEmailDraft, setVerifiedEmailDraft] = React.useState("")
const [verifiedEmailsExpanded, setVerifiedEmailsExpanded] = React.useState(false)
const [pendingExternalDelete, setPendingExternalDelete] = React.useState<ExternalImapAccount | null>(null)
const forwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: mailboxes.length > 0 })
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 pendingVerifiedEmailItems = React.useMemo(() => verifiedEmailItems.filter((item) => !item.verified), [verifiedEmailItems])
const completedVerifiedEmailItems = React.useMemo(() => verifiedEmailItems.filter((item) => item.verified), [verifiedEmailItems])
const normalizedVerifiedEmailDraft = verifiedEmailDraft.trim().toLowerCase()
const matchingPendingVerifiedEmailItems = React.useMemo(() => normalizedVerifiedEmailDraft
? pendingVerifiedEmailItems.filter((item) => item.email.toLowerCase().includes(normalizedVerifiedEmailDraft))
: pendingVerifiedEmailItems, [normalizedVerifiedEmailDraft, pendingVerifiedEmailItems])
const matchingCompletedVerifiedEmailItems = React.useMemo(() => normalizedVerifiedEmailDraft
? completedVerifiedEmailItems.filter((item) => item.email.toLowerCase().includes(normalizedVerifiedEmailDraft))
: completedVerifiedEmailItems, [completedVerifiedEmailItems, normalizedVerifiedEmailDraft])
const verifiedEmailDraftExists = verifiedEmailItems.some((item) => item.email.toLowerCase() === normalizedVerifiedEmailDraft)
const hasPendingVerifiedEmails = verifiedEmailItems.some((item) => !item.verified)
const mailboxForwards = React.useMemo<Record<string, string[]>>(() => {
const next: Record<string, string[]> = {}
@@ -1319,9 +1361,8 @@ function MailboxManagement({
</section>
<section id="mailbox-forwarding-section" className="rounded-lg border bg-card px-6 py-5">
<div className="mb-5 flex items-center justify-between gap-4">
<div className="mb-5 flex items-center gap-4">
<h2 className="text-lg font-semibold leading-7"></h2>
<Button type="button" variant="outline" size="sm" onClick={() => setVerifiedDialogOpen(true)}></Button>
</div>
<div className="rounded-xl bg-muted/20 px-5 py-5">
<div className="mb-3 text-sm font-medium"></div>
@@ -1331,20 +1372,14 @@ function MailboxManagement({
<Button type="button" className="h-[37px]" disabled={forwardingBusy} onClick={() => saveAccountForwarding.mutate(accountForwardTargets)}>{saveAccountForwarding.isPending ? "保存中" : "保存"}</Button>
</div>
</div>
{verifiedEmailItems.length > 0 && (
<div className="mt-5 flex flex-wrap gap-2">
{verifiedEmailItems.map((item) => {
const tone = forwardingEmailTone(item)
return (
<span key={item.id} className={cn("inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1 text-sm", tone.chipClass)}>
<span className={cn("size-2 shrink-0 rounded-full", tone.dotClass)} />
<span className="min-w-0 truncate">{item.email} · {tone.shortLabel}</span>
</span>
)
})}
</div>
)}
{verifiedEmails.length === 0 && <p className="mt-4 text-sm text-muted-foreground"></p>}
<Button type="button" variant="outline" className="mt-5 h-auto w-full justify-start gap-3 px-4 py-3 text-left font-normal shadow-none" onClick={() => setVerifiedDialogOpen(true)}>
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-emerald-50 text-emerald-700"><MailCheck className="h-4 w-4" /></span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium"></span>
<span className="block truncate text-sm text-muted-foreground"> {completedVerifiedEmailItems.length} {pendingVerifiedEmailItems.length > 0 ? `,待验证 ${pendingVerifiedEmailItems.length}` : ""}</span>
</span>
<ChevronDown className="h-4 w-4 shrink-0 -rotate-90 text-muted-foreground" />
</Button>
<p className="mt-3 text-sm text-muted-foreground"></p>
</section>
@@ -1438,45 +1473,58 @@ function MailboxManagement({
</DialogContent>
</Dialog>
<Dialog open={verifiedDialogOpen} onOpenChange={setVerifiedDialogOpen}>
<Dialog open={verifiedDialogOpen} onOpenChange={(open) => {
setVerifiedDialogOpen(open)
if (!open) setVerifiedEmailDraft("")
}}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
<DialogHeader className="px-8 pt-8">
<DialogTitle className="text-2xl leading-8"></DialogTitle>
</DialogHeader>
<div className="px-8 pt-5 text-[17px] leading-8 text-muted-foreground">
<div className="px-8 pt-4 text-sm leading-6 text-muted-foreground">
</div>
<form className="grid gap-3 px-8 pt-6 sm:grid-cols-[minmax(0,1fr)_96px]" onSubmit={submitVerifiedEmail}>
<Input type="email" value={verifiedEmailDraft} onChange={(event) => setVerifiedEmailDraft(event.target.value)} className="h-12 text-base shadow-none" placeholder="输入邮箱地址" disabled={forwardingBusy} />
<Button className="h-12 px-0 text-base" disabled={forwardingBusy || !verifiedEmailDraft.trim()}>{addVerifiedEmail.isPending ? "添加中" : "添加"}</Button>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input type="email" value={verifiedEmailDraft} onChange={(event) => setVerifiedEmailDraft(event.target.value)} className="h-12 pl-10 text-base shadow-none" placeholder="搜索或输入新邮箱" disabled={forwardingBusy} aria-label="搜索或输入新邮箱" />
</div>
<Button className="h-12 px-0 text-base" disabled={forwardingBusy || !verifiedEmailDraft.trim() || verifiedEmailDraftExists}>{addVerifiedEmail.isPending ? "添加中" : verifiedEmailDraftExists ? "已添加" : "添加"}</Button>
</form>
<div className="mx-8 mt-6 max-h-[360px] overflow-y-auto rounded-lg border">
{verifiedEmailItems.map((item) => (
<div key={item.id} className="grid min-h-[82px] gap-3 border-b px-4 py-4 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="min-w-0">
<div className="truncate text-lg font-semibold leading-6">{item.email}</div>
<div className="mt-1 text-sm text-muted-foreground">{item.verified ? `已验证 - ${formatDateTime(item.verifiedAt || item.createdAt)}` : "待验证"}</div>
{!item.verified && (
<div className={cn("mt-1 text-sm leading-5", forwardingEmailTone(item).detailClass)}>
{forwardingEmailStatusText(item)}
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-end gap-2">
<span className={cn("size-2.5 rounded-full", forwardingEmailTone(item).dotClass)} />
{!item.verified && (
<Button type="button" variant="outline" className="h-10 px-4" disabled={forwardingBusy} onClick={() => resendVerification(item)}></Button>
)}
<Button type="button" variant="outline" className="h-10 px-4 text-destructive hover:text-destructive" disabled={forwardingBusy} onClick={() => removeVerifiedEmail(item.id, item.email)} aria-label={`移除 ${item.email}`}>
</Button>
</div>
<div className="mx-8 mt-6 max-h-[380px] space-y-4 overflow-y-auto pr-1">
{pendingVerifiedEmailItems.length > 0 && (
<div className="rounded-lg border">
<div className="border-b bg-muted/30 px-4 py-3 text-sm font-medium"> ({pendingVerifiedEmailItems.length})</div>
{matchingPendingVerifiedEmailItems.map((item) => (
<VerifiedEmailRow key={item.id} item={item} busy={forwardingBusy} onResend={resendVerification} onRemove={removeVerifiedEmail} />
))}
{matchingPendingVerifiedEmailItems.length === 0 && <div className="px-4 py-8 text-center text-sm text-muted-foreground"></div>}
</div>
))}
{verifiedEmailItems.length === 0 && <div className="py-10 text-center text-sm text-muted-foreground"></div>}
)}
{completedVerifiedEmailItems.length > 0 && (
<div className="rounded-lg border">
<Button type="button" variant="ghost" className="h-12 w-full justify-start gap-3 rounded-none px-4 font-normal" onClick={() => setVerifiedEmailsExpanded((value) => !value)} aria-expanded={verifiedEmailsExpanded || !!normalizedVerifiedEmailDraft}>
<MailCheck className="h-4 w-4 text-emerald-600" />
<span className="flex-1 text-sm font-medium"> ({completedVerifiedEmailItems.length})</span>
{verifiedEmailsExpanded || normalizedVerifiedEmailDraft ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</Button>
{(verifiedEmailsExpanded || !!normalizedVerifiedEmailDraft) && (
<div className="border-t">
{matchingCompletedVerifiedEmailItems.map((item) => (
<VerifiedEmailRow key={item.id} item={item} busy={forwardingBusy} onResend={resendVerification} onRemove={removeVerifiedEmail} />
))}
{matchingCompletedVerifiedEmailItems.length === 0 && <div className="px-4 py-8 text-center text-sm text-muted-foreground"></div>}
</div>
)}
</div>
)}
{verifiedEmailItems.length === 0 && <div className="rounded-lg border py-10 text-center text-sm text-muted-foreground"></div>}
{normalizedVerifiedEmailDraft && !verifiedEmailDraftExists && matchingPendingVerifiedEmailItems.length === 0 && matchingCompletedVerifiedEmailItems.length === 0 && verifiedEmailItems.length > 0 && (
<div className="rounded-md bg-muted/40 px-4 py-3 text-sm text-muted-foreground"></div>
)}
</div>
<DialogFooter className="border-t px-8 py-6">
<Button type="button" variant="outline" className="h-12 px-8 text-base" onClick={() => setVerifiedDialogOpen(false)}></Button>
<Button type="button" variant="outline" className="h-12 px-8 text-base" onClick={() => { setVerifiedDialogOpen(false); setVerifiedEmailDraft("") }}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1776,7 +1824,7 @@ function ForwardingTargetPicker({ emails, selected, lockedSelected = [], lockedL
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>
)}
@@ -1821,35 +1869,55 @@ function ForwardingTargetPicker({ emails, selected, lockedSelected = [], lockedL
)
}
function VerifiedEmailRow({ item, busy, onResend, onRemove }: {
item: ForwardingVerifiedEmail
busy: boolean
onResend: (item: ForwardingVerifiedEmail) => void
onRemove: (id: string, email: string) => void
}) {
const tone = forwardingEmailTone(item)
return (
<div className="grid min-h-[72px] gap-3 border-b px-4 py-3 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn("size-2 shrink-0 rounded-full", tone.dotClass)} />
<span className="min-w-0 truncate text-sm font-semibold" title={item.email}>{item.email}</span>
</div>
<div className={cn("mt-1 pl-4 text-xs leading-5", item.verified ? "text-muted-foreground" : tone.detailClass)}>
{item.verified ? `验证于 ${formatDateTime(item.verifiedAt || item.createdAt)}` : forwardingEmailStatusText(item)}
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-2">
{!item.verified && (
<Button type="button" variant="outline" size="sm" className="h-9" disabled={busy} onClick={() => onResend(item)}></Button>
)}
<Button type="button" variant="ghost" size="sm" className="h-9 text-destructive hover:bg-destructive/10 hover:text-destructive" disabled={busy} onClick={() => onRemove(item.id, item.email)} aria-label={`删除 ${item.email}`}></Button>
</div>
</div>
)
}
function forwardingEmailTone(item: ForwardingVerifiedEmail) {
if (item.verified) {
return {
shortLabel: "已验证",
dotClass: "bg-emerald-500",
chipClass: "bg-emerald-100 text-emerald-800",
detailClass: "text-emerald-700",
}
}
if (item.deliveryStatus === "failed") {
return {
shortLabel: "发送失败",
dotClass: "bg-destructive",
chipClass: "bg-destructive/10 text-destructive",
detailClass: "text-destructive",
}
}
if (item.deliveryStatus === "delivered") {
return {
shortLabel: "待验证",
dotClass: "bg-amber-500",
chipClass: "bg-amber-100 text-amber-800",
detailClass: "text-amber-700",
}
}
return {
shortLabel: "待验证",
dotClass: "bg-muted-foreground",
chipClass: "bg-muted text-foreground",
detailClass: "text-foreground",
}
}
@@ -1937,7 +2005,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>
@@ -1961,7 +2029,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>
@@ -2128,8 +2196,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>
))}
@@ -2297,8 +2365,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>
@@ -2314,8 +2382,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>
@@ -2500,7 +2568,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="当前没有发件人拦截规则,所有邮件都会按正常规则投递。" />}
@@ -2771,10 +2839,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