Merge pull request #2 from zxyszx/codex/release-v1.2.43

release: v1.2.43
This commit is contained in:
云逸
2026-08-14 05:29:58 +08:00
committed by GitHub
16 changed files with 308 additions and 210 deletions
+6
View File
@@ -0,0 +1,6 @@
- 将后台首页升级为紧凑的邮件系统仪表盘,优化核心指标、邮件运行概览、系统健康、域名状态和首次配置入口。
- 优化后台、登录与邮箱界面细节,统一品牌图标、通知位置、选中状态、按钮边框与移动端布局,并修复全部邮件页面横向溢出。
- 修复 Apple 等邮件的 GB2312、GBK、GB18030 标题乱码,改进账号切换后的邮箱文件夹显示与创建范围提示。
- 精简系统设置,移除“关于”标签和相关内容;版本检查仍可通过左侧版本号入口使用。
**完整更新日志**[v1.2.42...v1.2.43](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.42...v1.2.43)
+1 -1
View File
@@ -1 +1 @@
1.2.42 1.2.43
+23 -10
View File
@@ -24,23 +24,36 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
Messages int64 `json:"messages"` Messages int64 `json:"messages"`
UnreadMessages int64 `json:"unreadMessages"` UnreadMessages int64 `json:"unreadMessages"`
StorageBytes int64 `json:"storageBytes"` StorageBytes int64 `json:"storageBytes"`
TodaySent int64 `json:"todaySent"`
TodayReceived int64 `json:"todayReceived"`
SendDelivered int64 `json:"sendDelivered"`
SendFailed int64 `json:"sendFailed"`
QueueMessages int64 `json:"queueMessages"`
} }
now := a.now().UTC()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
queries := []struct { queries := []struct {
q string q string
dest *int64 dest *int64
args []any
}{ }{
{`SELECT COUNT(*) FROM users`, &out.Users}, {q: `SELECT COUNT(*) FROM users`, dest: &out.Users},
{`SELECT COUNT(*) FROM users WHERE disabled=0`, &out.ActiveUsers}, {q: `SELECT COUNT(*) FROM users WHERE disabled=0`, dest: &out.ActiveUsers},
{`SELECT COUNT(*) FROM domains`, &out.Domains}, {q: `SELECT COUNT(*) FROM domains`, dest: &out.Domains},
{`SELECT COUNT(*) FROM mailboxes`, &out.Mailboxes}, {q: `SELECT COUNT(*) FROM mailboxes`, dest: &out.Mailboxes},
{`SELECT COUNT(*) FROM mailboxes WHERE status='active'`, &out.ActiveMailboxes}, {q: `SELECT COUNT(*) FROM mailboxes WHERE status='active'`, dest: &out.ActiveMailboxes},
{`SELECT COUNT(*) FROM aliases`, &out.Aliases}, {q: `SELECT COUNT(*) FROM aliases`, dest: &out.Aliases},
{`SELECT COUNT(*) FROM messages`, &out.Messages}, {q: `SELECT COUNT(*) FROM messages`, dest: &out.Messages},
{`SELECT COUNT(*) FROM messages WHERE is_read=0`, &out.UnreadMessages}, {q: `SELECT COUNT(*) FROM messages WHERE is_read=0`, dest: &out.UnreadMessages},
{`SELECT COALESCE(SUM(size_bytes),0) FROM messages`, &out.StorageBytes}, {q: `SELECT COALESCE(SUM(size_bytes),0) FROM messages`, dest: &out.StorageBytes},
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role='sent' AND m.sent_at>=?`, dest: &out.TodaySent, args: []any{todayStart}},
{q: `SELECT COUNT(m.id) FROM messages m JOIN folders f ON f.id=m.folder_id WHERE f.role NOT IN ('sent','drafts') AND m.received_at>=?`, dest: &out.TodayReceived, args: []any{todayStart}},
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendDelivered, args: []any{sendQueueStatusDelivered, todayStart}},
{q: `SELECT COUNT(*) FROM send_queue WHERE status=? AND created_at>=?`, dest: &out.SendFailed, args: []any{sendQueueStatusFailed, todayStart}},
{q: `SELECT COUNT(*) FROM send_queue WHERE status IN (?,?)`, dest: &out.QueueMessages, args: []any{sendQueueStatusQueued, sendQueueStatusSending}},
} }
for _, item := range queries { for _, item := range queries {
if err := a.db.QueryRowContext(r.Context(), item.q).Scan(item.dest); err != nil { if err := a.db.QueryRowContext(r.Context(), item.q, item.args...).Scan(item.dest); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load overview") respondError(w, http.StatusInternalServerError, "failed to load overview")
return return
} }
+9
View File
@@ -21,6 +21,7 @@ import (
"golang.org/x/text/encoding" "golang.org/x/text/encoding"
"golang.org/x/text/encoding/ianaindex" "golang.org/x/text/encoding/ianaindex"
"golang.org/x/text/encoding/simplifiedchinese"
) )
type maildirMailbox struct { type maildirMailbox struct {
@@ -822,6 +823,14 @@ func charsetReader(charset string, input io.Reader) (io.Reader, error) {
if charset == "utf-8" || charset == "us-ascii" { if charset == "utf-8" || charset == "us-ascii" {
return input, nil return input, nil
} }
// GB2312 is commonly used as a label for GBK-compatible mail content.
// ianaindex does not consistently resolve these real-world aliases.
switch charset {
case "gb2312", "gb_2312-80", "x-gbk", "euc-cn", "cp936", "ms936", "windows-936":
return simplifiedchinese.GBK.NewDecoder().Reader(input), nil
case "gb18030":
return simplifiedchinese.GB18030.NewDecoder().Reader(input), nil
}
enc, err := ianaindex.IANA.Encoding(charset) enc, err := ianaindex.IANA.Encoding(charset)
if err != nil { if err != nil {
return nil, fmt.Errorf("unsupported charset %q: %w", charset, err) return nil, fmt.Errorf("unsupported charset %q: %w", charset, err)
+32
View File
@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -355,6 +356,37 @@ func TestTelegramMailboxScopeAndOriginalRecipient(t *testing.T) {
} }
} }
func TestParseMaildirMessageDecodesAppleGB2312(t *testing.T) {
subject := "验证 Apple 账户电子邮件地址"
body := "你的 Apple 验证码是 978534"
encodedSubject, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(subject))
if err != nil {
t.Fatal(err)
}
encodedBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(body))
if err != nil {
t.Fatal(err)
}
raw := []byte("From: Apple <appleid@id.apple.com>\r\n" +
"To: admin@example.com\r\n" +
"Subject: =?gb2312?B?" + base64.StdEncoding.EncodeToString(encodedSubject) + "?=\r\n" +
"Content-Type: text/plain; charset=gb2312\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
base64.StdEncoding.EncodeToString(encodedBody))
a := newTestApp(t)
stopTestWorkers(a)
msg, _, err := a.parseMaildirMessage(raw, "admin@example.com")
if err != nil {
t.Fatal(err)
}
if msg.Subject != subject {
t.Fatalf("GB2312 subject was not decoded: %q", msg.Subject)
}
if msg.BodyText != body {
t.Fatalf("GB2312 body was not decoded: %q", msg.BodyText)
}
}
func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) { func TestTelegramBadRequestFallsBackToPlainText(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+11
View File
@@ -0,0 +1,11 @@
import { Mail } from "lucide-react"
import { cn } from "@/lib/utils"
export function BrandMark({ className }: { className?: string }) {
return (
<span className={cn("grid size-9 shrink-0 place-items-center rounded-md border border-primary/20 bg-primary/[0.03] text-primary", className)} aria-hidden="true">
<Mail className="size-6 stroke-[1.8]" />
</span>
)
}
+5 -6
View File
@@ -1,12 +1,13 @@
import * as React from "react" import * as React from "react"
import { Outlet, Link, useLocation } from "react-router-dom" import { Outlet, Link, useLocation } from "react-router-dom"
import { ArchiveRestore, BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react" import { ArchiveRestore, ClipboardList, Forward, Globe2, Inbox, LayoutDashboard, LogOut, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
import { useMe } from "@/hooks/use-me" import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout" import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard" import { AuthGuard } from "@/components/auth-guard"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { SystemVersionDialog } from "@/components/system-version-dialog" import { SystemVersionDialog } from "@/components/system-version-dialog"
import { BrandMark } from "@/components/brand-mark"
import { hasAnyPermission } from "@/lib/permissions" import { hasAnyPermission } from "@/lib/permissions"
import type { PermissionKey } from "@/lib/api-types" import type { PermissionKey } from "@/lib/api-types"
import { import {
@@ -27,7 +28,7 @@ import {
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [ const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] }, { key: "overview", label: "仪表盘", icon: <LayoutDashboard />, permissions: ["admin.overview.view"] },
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] }, { key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
{ key: "permissionGroups", label: "权限配置", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] }, { key: "permissionGroups", label: "权限配置", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
{ key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] }, { key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
@@ -72,9 +73,7 @@ function ProtectedContent() {
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" asChild> <SidebarMenuButton size="lg" asChild>
<Link to="/"> <Link to="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"> <BrandMark className="size-8 rounded-md [&>svg]:size-5" />
<Mail className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">NewSzxcn </span> <span className="truncate font-semibold">NewSzxcn </span>
</div> </div>
@@ -115,7 +114,7 @@ function ProtectedContent() {
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
<div className="p-2"> <div className="p-2">
<Button variant="outline" size="sm" className="w-full gap-2 text-xs" onClick={logout}> <Button variant="outline" size="sm" className="w-full gap-2 border-destructive/35 text-xs text-destructive shadow-none hover:border-destructive/55 hover:bg-destructive/10 hover:text-destructive dark:border-destructive/45 dark:hover:bg-destructive/15" onClick={logout}>
<LogOut className="h-3.5 w-3.5" />退 <LogOut className="h-3.5 w-3.5" />退
</Button> </Button>
</div> </div>
+6 -6
View File
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", "fixed inset-x-0 top-0 z-[100] flex max-h-screen w-full flex-col items-end gap-2 p-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:left-auto sm:right-0 sm:max-w-[420px] sm:p-4",
className className
)} )}
{...props} {...props}
@@ -25,11 +25,11 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva( const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", "group pointer-events-auto relative flex w-full items-start justify-between gap-3 overflow-hidden rounded-md border bg-popover p-4 pr-9 text-popover-foreground shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-3 sm:data-[state=open]:slide-in-from-right-full",
{ {
variants: { variants: {
variant: { variant: {
default: "border bg-background text-foreground", default: "border-border/80 bg-popover text-popover-foreground",
destructive: destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground", "destructive group border-destructive bg-destructive text-destructive-foreground",
}, },
@@ -81,7 +81,7 @@ const ToastClose = React.forwardRef<
props.onClick?.(event) props.onClick?.(event)
}} }}
className={cn( className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", "absolute right-2 top-2 rounded-md p-1 text-foreground/50 transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring group-[.destructive]:text-red-200 group-[.destructive]:hover:bg-red-950/20 group-[.destructive]:hover:text-white group-[.destructive]:focus:ring-red-300",
className className
)} )}
toast-close="" toast-close=""
@@ -98,7 +98,7 @@ const ToastTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Title <ToastPrimitives.Title
ref={ref} ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)} className={cn("break-words text-sm font-semibold leading-5", className)}
{...props} {...props}
/> />
)) ))
@@ -110,7 +110,7 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Description <ToastPrimitives.Description
ref={ref} ref={ref}
className={cn("text-sm opacity-90", className)} className={cn("line-clamp-3 break-all text-sm leading-5 text-muted-foreground group-[.destructive]:text-destructive-foreground/90", className)}
{...props} {...props}
/> />
)) ))
+2 -2
View File
@@ -14,11 +14,11 @@ export function Toaster() {
const { toasts } = useToast() const { toasts } = useToast()
return ( return (
<ToastProvider> <ToastProvider duration={5000} swipeDirection="right">
{toasts.map(function ({ id, title, description, action, ...props }) { {toasts.map(function ({ id, title, description, action, ...props }) {
return ( return (
<Toast key={id} {...props}> <Toast key={id} {...props}>
<div className="grid gap-1"> <div className="min-w-0 flex-1 space-y-1">
{title && <ToastTitle>{title}</ToastTitle>} {title && <ToastTitle>{title}</ToastTitle>}
{description && ( {description && (
<ToastDescription>{description}</ToastDescription> <ToastDescription>{description}</ToastDescription>
+2 -2
View File
@@ -5,8 +5,8 @@ import type {
ToastProps, ToastProps,
} from "@/components/ui/toast" } from "@/components/ui/toast"
const TOAST_LIMIT = 1 const TOAST_LIMIT = 3
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 1000
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string
+5 -1
View File
@@ -53,7 +53,11 @@ export type PermissionGroup = { id: string; name: string; description: string; p
export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string } export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string } export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[]; storageQuotaMb: number } export type AdminUser = User & { mailboxCount: number; mailboxes?: string[]; storageQuotaMb: number }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number } export type AdminOverview = {
users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number
aliases: number; messages: number; unreadMessages: number; storageBytes: number
todaySent: number; todayReceived: number; sendDelivered: number; sendFailed: number; queueMessages: number
}
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string } export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; primary?: boolean; unreadCount?: number; createdAt: string } export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; primary?: boolean; unreadCount?: number; createdAt: string }
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string } export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
+179 -166
View File
@@ -2,8 +2,8 @@ import * as React from "react"
import DOMPurify from "dompurify" import DOMPurify from "dompurify"
import { useSearchParams } from "react-router-dom" import { useSearchParams } from "react-router-dom"
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Cloud, Copy, Download, ExternalLink, Eye, EyeOff, Github, Globe2, HardDrive, KeyRound, Loader2, Mail, Mailbox, MoreHorizontal, RefreshCcw, Scale, Search, Send, ShieldCheck, Star, Trash2, Users } from "lucide-react" import { AlertCircle, CheckCircle2, ChevronDown, ChevronRight, Circle, ClipboardList, Clock3, Cloud, Copy, Database, Download, ExternalLink, Eye, EyeOff, Globe2, HardDrive, KeyRound, Loader2, Mail, MoreHorizontal, RefreshCcw, Search, Send, ShieldCheck, Trash2, UserRound } from "lucide-react"
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api" import { api, AdminOverview, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils" import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -20,27 +20,26 @@ import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { ConfirmDialog } from "@/components/confirm-dialog" import { ConfirmDialog } from "@/components/confirm-dialog"
import { SystemVersionDialog } from "@/components/system-version-dialog"
import { useMe } from "@/hooks/use-me" import { useMe } from "@/hooks/use-me"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
import { hasAnyPermission, hasPermission } from "@/lib/permissions" import { hasAnyPermission, hasPermission } from "@/lib/permissions"
import type { BackupTransfer, PermissionKey, TelegramPairing } from "@/lib/api-types" import type { BackupTransfer, PermissionKey, TelegramPairing } from "@/lib/api-types"
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "backups" | "settings" type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "backups" | "settings"
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about" type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security"
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = { const sectionMeta: Record<Section, { label: string; description: string }> = {
overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" }, overview: { label: "仪表盘", description: "邮件运行、域名与系统状态集中查看。" },
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" }, users: { label: "账号管理", description: "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" },
permissionGroups: { label: "权限配置", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" }, permissionGroups: { label: "权限配置", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" },
domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" }, domains: { label: "域名管理", description: "维护邮件域名、DKIM 和 DNS 检测。" },
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "按归属账号查看和管理子邮箱,默认邮箱受保护。" }, mailboxes: { label: "邮箱管理", description: "按归属账号查看和管理子邮箱,默认邮箱受保护。" },
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" }, aliases: { label: "邮件转发", description: "管理域名转发规则。" },
messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" }, messages: { label: "全部邮件", description: "按邮箱、文件夹和关键词查看全站邮件。" },
sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" }, sendAudit: { label: "发送队列", description: "查看发信投递、重试和失败记录。" },
backups: { label: "备份与恢复", frontLabel: "数据保护", description: "创建、校验和下载可迁移的加密完整备份。" }, backups: { label: "备份与恢复", description: "创建、校验和下载可迁移的加密完整备份。" },
settings: { label: "系统设置", frontLabel: "账号设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" }, settings: { label: "系统设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" },
} }
const sectionLabels = Object.fromEntries(Object.entries(sectionMeta).map(([key, value]) => [key, value.label])) as Record<Section, string> const sectionLabels = Object.fromEntries(Object.entries(sectionMeta).map(([key, value]) => [key, value.label])) as Record<Section, string>
const sectionKeys = Object.keys(sectionLabels) as Section[] const sectionKeys = Object.keys(sectionLabels) as Section[]
@@ -56,8 +55,6 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
backups: ["admin.settings.view"], backups: ["admin.settings.view"],
settings: ["admin.settings.view", "admin.templates.view"], settings: ["admin.settings.view", "admin.templates.view"],
} }
const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 } const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
const defaultMailboxLimitOverride = 9 const defaultMailboxLimitOverride = 9
const defaultUserStorageQuotaMb = 100 const defaultUserStorageQuotaMb = 100
@@ -133,23 +130,26 @@ export function AdminPage() {
} }
} }
const overviewChecklist = setupChecklist(overview.data, domainItems, settings.data).filter((item) => visibleSections.includes(item.section))
const changeSection = (next: Section) => setParams(next === "overview" ? {} : { section: next })
return ( return (
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh"> <ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
<main className="admin-page mx-auto w-full max-w-[1440px] px-3 pb-8 pt-3 sm:px-4 sm:pt-4"> <main className="admin-page mx-auto w-full max-w-[1320px] px-3 pb-8 pt-3 sm:px-4 sm:pt-4">
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} /> <AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} checklist={section === "overview" ? overviewChecklist : undefined} onSectionChange={changeSection} />
{sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />} {sectionQuery?.isError && <QueryFailure error={sectionQuery.error} onRetry={() => { void sectionQuery.refetch() }} />}
{section === "overview" && canOverview && ( {section === "overview" && canOverview && (
<section className="mb-3 grid grid-cols-2 gap-3 lg:grid-cols-4"> <section className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} detail={`${overview.data?.activeUsers || 0} 个活跃`} /> <Stat icon={<UserRound />} tone="primary" label="账号" value={overview.data?.users || 0} detail={`${overview.data?.activeUsers || 0} 个活跃`} />
<Stat icon={<Globe2 />} label="邮件域名" value={overview.data?.domains || 0} detail={`${domainItems.filter((domain) => domain.dnsStatus === "ok").length} 个 DNS 正常`} /> <Stat icon={<Globe2 />} tone="cyan" label="邮件域名" value={overview.data?.domains || 0} detail={domainItems.some((domain) => domain.dnsStatus === "ok") ? `${domainItems.filter((domain) => domain.dnsStatus === "ok").length} 个 DNS 正常` : "待检测"} />
<Stat icon={<Mailbox />} label="邮箱" value={overview.data?.mailboxes || 0} detail={`${overview.data?.activeMailboxes || 0} 个活跃`} /> <Stat icon={<Mail />} tone="sky" label="邮箱" value={overview.data?.mailboxes || 0} detail={`${overview.data?.activeMailboxes || 0} 个活跃`} />
<Stat icon={<ShieldCheck />} label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} detail={`${overview.data?.unreadMessages || 0} 封未读 · ${overview.data?.aliases || 0} 个转发`} /> <Stat icon={<Database />} tone="violet" label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} detail={`${overview.data?.unreadMessages || 0} 封未读 · ${overview.data?.aliases || 0} 个转发`} />
</section> </section>
)} )}
{section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={(next) => setParams(next === "overview" ? {} : { section: next })} />} {section === "overview" && <OverviewSection overview={overview.data} domains={domainItems} settings={settings.data} visibleSections={visibleSections} onSectionChange={changeSection} />}
{section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} domains={domainItems} />} {section === "users" && <UsersSection users={userItems} permissionGroups={assignablePermissionGroups} domains={domainItems} />}
{section === "permissionGroups" && <PermissionGroupsSection groups={permissionGroups.data?.items || []} catalog={permissionGroups.data?.catalog || []} />} {section === "permissionGroups" && <PermissionGroupsSection groups={permissionGroups.data?.items || []} catalog={permissionGroups.data?.catalog || []} />}
{section === "domains" && <DomainsSection domains={domainItems} />} {section === "domains" && <DomainsSection domains={domainItems} />}
@@ -164,23 +164,22 @@ export function AdminPage() {
) )
} }
function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section; refreshing: boolean; onRefresh: () => void }) { type SetupChecklistItem = ReturnType<typeof setupChecklist>[number]
function AdminPageHeader({ section, refreshing, onRefresh, checklist, onSectionChange }: { section: Section; refreshing: boolean; onRefresh: () => void; checklist?: SetupChecklistItem[]; onSectionChange: (section: Section) => void }) {
const meta = sectionMeta[section] const meta = sectionMeta[section]
return ( return (
<div className="mb-4 border-b pb-3"> <div className="mb-4 border-b border-border/80 pb-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span></span>
<span className="h-1 w-1 rounded-full bg-muted-foreground/50" />
<span>{meta.frontLabel}</span>
</div>
<h1 className="text-[20px] font-semibold leading-7 tracking-tight">{meta.label}</h1> <h1 className="text-[20px] font-semibold leading-7 tracking-tight">{meta.label}</h1>
<p className="mt-1 text-sm leading-5 text-muted-foreground">{meta.description}</p> <p className="mt-1 text-sm leading-5 text-muted-foreground/80">{meta.description}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据"> {checklist && <SetupChecklistDialog checklist={checklist} onSectionChange={onSectionChange} />}
<Button type="button" variant="outline" size="sm" className="h-9 gap-2 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据">
<RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} /> <RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} />
<span className="hidden sm:inline"></span>
</Button> </Button>
</div> </div>
</div> </div>
@@ -188,51 +187,107 @@ function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section;
) )
} }
function OverviewSection({ overview, domains, settings, visibleSections, onSectionChange }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[]; settings?: SystemSettings; visibleSections: Section[]; onSectionChange: (section: Section) => void }) { function SetupChecklistDialog({ checklist, onSectionChange }: { checklist: SetupChecklistItem[]; onSectionChange: (section: Section) => void }) {
const checklist = setupChecklist(overview, domains, settings).filter((item) => visibleSections.includes(item.section)) const [open, setOpen] = React.useState(false)
const completed = checklist.filter((item) => item.done).length
const complete = checklist.length > 0 && completed === checklist.length
return ( return (
<div className="grid items-start gap-3 lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,.65fr)]"> <Dialog open={open} onOpenChange={setOpen}>
<Card> <DialogTrigger asChild>
<CardHeader className="pb-3"><CardTitle></CardTitle></CardHeader> <Button type="button" variant="outline" size="sm" className="h-9 gap-2 shadow-none">
<CardContent className="grid gap-2 sm:grid-cols-2"> {complete ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : <Circle className="h-4 w-4 text-amber-600" />}
{checklist.map((item) => ( <span>{complete ? "初始化完成" : `初始化 ${completed}/${checklist.length}`}</span>
<Button key={item.key} type="button" variant="outline" className="h-auto min-h-[64px] w-full justify-start gap-3 px-3 py-2 text-left font-normal last:sm:col-span-2" onClick={() => onSectionChange(item.section)}> </Button>
{item.done ? <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" /> : <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />} </DialogTrigger>
<span className="min-w-0 flex-1"> <DialogContent className="max-w-xl gap-3 p-5">
<span className="block font-medium">{item.title}</span> <DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<span className="block truncate text-xs text-muted-foreground">{item.detail}</span> <div className="grid gap-2 sm:grid-cols-2">
</span> {checklist.map((item) => (
</Button> <Button key={item.key} type="button" variant="outline" className="h-auto min-h-[62px] justify-start gap-3 px-3 py-2 text-left font-normal last:sm:col-span-2" onClick={() => { setOpen(false); onSectionChange(item.section) }}>
))} {item.done ? <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" /> : <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />}
<span className="min-w-0 flex-1"><span className="block font-medium">{item.title}</span><span className="block truncate text-xs text-muted-foreground">{item.detail}</span></span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
))}
</div>
</DialogContent>
</Dialog>
)
}
function OverviewSection({ overview, domains, settings, visibleSections, onSectionChange }: { overview?: AdminOverview; domains: Domain[]; settings?: SystemSettings; visibleSections: Section[]; onSectionChange: (section: Section) => void }) {
const { toast } = useToast()
const dnsOK = domains.length > 0 && domains.every((domain) => domain.dnsStatus === "ok")
const dnsWarning = domains.length > 0 && domains.some((domain) => domain.dnsStatus === "ok")
return (
<div className="space-y-3">
<Card className="border-border/80">
<CardHeader className="px-4 pb-2 pt-3 sm:px-4"><CardTitle className="text-base"></CardTitle></CardHeader>
<CardContent className="px-4 pb-3 sm:px-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<OverviewMetric icon={<Send />} label="今日发送" value={overview?.todaySent || 0} tone="primary" />
<OverviewMetric icon={<Download />} label="今日接收" value={overview?.todayReceived || 0} tone="success" />
<OverviewMetric icon={<CheckCircle2 />} label="发送成功" value={overview?.sendDelivered || 0} tone="success" />
<OverviewMetric icon={<AlertCircle />} label="发送失败" value={overview?.sendFailed || 0} tone={(overview?.sendFailed || 0) > 0 ? "danger" : "muted"} />
<OverviewMetric icon={<Clock3 />} label="队列邮件" value={overview?.queueMessages || 0} tone={(overview?.queueMessages || 0) > 0 ? "warning" : "muted"} />
<OverviewMetric icon={<Mail />} label="未读邮件" value={overview?.unreadMessages || 0} tone={(overview?.unreadMessages || 0) > 0 ? "primary" : "muted"} />
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="pb-3"><CardTitle></CardTitle></CardHeader> <Card className="border-border/80">
<CardContent className="space-y-4"> <CardHeader className="px-4 pb-2 pt-3"><CardTitle className="text-base"></CardTitle></CardHeader>
<section> <CardContent className="grid gap-2.5 px-4 pb-3 md:grid-cols-3">
<h3 className="mb-2 text-xs font-medium text-muted-foreground">DNS </h3> <div className="rounded-md border border-border/80 px-3 py-2">
<div className="space-y-2"> <DashboardGroupTitle></DashboardGroupTitle>
{domains.map((domain) => <DomainBadgeRow key={domain.id} domain={domain} />)} <div className="grid grid-cols-3 gap-2">
{domains.length === 0 && <Empty text="暂无域名" />} <DashboardStatusItem label="系统" status={<LightStatus state="success" label="运行中" />} />
<DashboardStatusItem label="DNS" status={<LightStatus state={dnsOK ? "success" : dnsWarning ? "warning" : "muted"} label={dnsOK ? "正常" : dnsWarning ? "部分正常" : domains.length ? "未检测" : "未配置"} />} />
<DashboardStatusItem label="SMTP" status={<LightStatus state={settings?.smtpHost ? "success" : "warning"} label={settings?.smtpHost ? "已配置" : "未配置"} />} />
</div> </div>
</section> </div>
<Separator /> <div className="rounded-md border border-border/80 px-3 py-2">
<section> <DashboardGroupTitle></DashboardGroupTitle>
<h3 className="mb-2 text-xs font-medium text-muted-foreground"></h3> <div className="grid grid-cols-2 gap-3">
<div className="space-y-2 text-sm text-muted-foreground"> <DashboardInfoItem label="公网地址" value={settings?.publicBaseUrl || "-"} onCopy={settings?.publicBaseUrl ? () => copyOverviewValue(settings.publicBaseUrl, "公网地址", toast) : undefined} />
<InfoLine label="公网地址" value={settings?.publicBaseUrl || "-"} /> <DashboardInfoItem label="SMTP" value={settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "未配置"} onCopy={settings?.smtpHost ? () => copyOverviewValue(`${settings.smtpHost}:${settings.smtpPort}`, "SMTP 地址", toast) : undefined} />
<InfoLine label="SMTP" value={settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "-"} />
<InfoLine label="注册" value={settings?.openRegistration ? "已开放" : "关闭"} />
<InfoLine label="自助申请邮箱" value={settings?.userMailboxApplyEnabled ? "已启用" : "关闭"} />
</div> </div>
</section> </div>
<div className="rounded-md border border-border/80 px-3 py-2">
<DashboardGroupTitle></DashboardGroupTitle>
<div className="grid grid-cols-2 gap-3">
<DashboardStatusItem label="注册" status={<LightStatus state={settings?.openRegistration ? "success" : "muted"} label={settings?.openRegistration ? "已开放" : "关闭"} />} />
<DashboardStatusItem label="自助申请邮箱" status={<LightStatus state={settings?.userMailboxApplyEnabled ? "success" : "muted"} label={settings?.userMailboxApplyEnabled ? "已启用" : "关闭"} />} />
</div>
</div>
</CardContent>
</Card>
<Card className="border-border/80">
<CardHeader className="flex-row items-center justify-between space-y-0 px-4 pb-2 pt-3"><div className="flex items-baseline gap-2"><CardTitle className="text-base"></CardTitle><span className="text-xs text-muted-foreground">{domains.length} </span></div>{visibleSections.includes("domains") && <Button type="button" variant="ghost" size="sm" className="h-7 gap-1 px-2 text-xs" onClick={() => onSectionChange("domains")}><ChevronRight className="h-3.5 w-3.5" /></Button>}</CardHeader>
<CardContent className="px-4 pb-3">
{domains.length > 0 ? <div className="overflow-hidden rounded-md border border-border/80">
<div className="hidden grid-cols-[minmax(0,1fr)_120px_140px_150px_24px] items-center gap-3 border-b bg-muted/20 px-3 py-1.5 text-[11px] font-medium text-muted-foreground md:grid"><span></span><span>使</span><span>DNS </span><span></span><span /></div>
<div className="divide-y">{domains.slice(0, 5).map((domain) => {
const dnsDisplay = dnsStatusDisplay(domain.dnsStatus)
return <Button key={domain.id} type="button" variant="ghost" className="grid h-auto min-h-12 w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-none px-3 py-2 text-left font-normal transition-colors hover:bg-muted/35 md:grid-cols-[minmax(0,1fr)_120px_140px_150px_24px]" onClick={() => onSectionChange("domains")}>
<span className="min-w-0 truncate text-sm font-medium">{domain.name}</span>
<span className="hidden md:block"><LightStatus state={domain.status === "active" ? "success" : "muted"} label={domain.status === "active" ? "已启用" : "已停用"} /></span>
<span className="justify-self-end md:justify-self-start"><LightStatus state={dnsDisplay.state} label={dnsDisplay.label} /></span>
<span className="hidden text-xs text-muted-foreground md:block">{domain.dnsCheckedAt ? formatDate(domain.dnsCheckedAt) : "尚未检测"}</span>
<ChevronRight className="hidden h-4 w-4 text-muted-foreground md:block" />
<span className="col-span-2 flex items-center gap-2 text-[11px] text-muted-foreground md:hidden"><span>{domain.status === "active" ? "已启用" : "已停用"}</span><span>·</span><span>{domain.dnsCheckedAt ? `检测于 ${formatDate(domain.dnsCheckedAt)}` : "尚未检测"}</span></span>
</Button>
})}</div>
{domains.length > 5 && <Button type="button" variant="ghost" className="h-auto w-full justify-start rounded-none border-t px-3 py-2 text-left text-xs font-normal text-muted-foreground transition-colors hover:bg-muted/35 hover:text-foreground" onClick={() => onSectionChange("domains")}> {domains.length - 5} </Button>}
</div> : <Empty text="暂无邮件域名" />}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) )
} }
function setupChecklist(overview: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number } | undefined, domains: Domain[], settings?: SystemSettings) { function setupChecklist(overview: AdminOverview | undefined, domains: Domain[], settings?: SystemSettings) {
const hasDomain = domains.length > 0 const hasDomain = domains.length > 0
const dnsReady = domains.some((domain) => domain.dnsStatus === "ok") const dnsReady = domains.some((domain) => domain.dnsStatus === "ok")
const hasMailbox = (overview?.activeMailboxes || 0) > 0 const hasMailbox = (overview?.activeMailboxes || 0) > 0
@@ -246,6 +301,38 @@ function setupChecklist(overview: { activeUsers: number; activeMailboxes: number
] ]
} }
function OverviewMetric({ icon, label, value, tone }: { icon: React.ReactNode; label: string; value: number; tone: "primary" | "success" | "warning" | "danger" | "muted" }) {
return <div className="flex min-h-[50px] items-center gap-2 rounded-md border border-border/80 px-2 py-1"><div className={cn("grid h-7 w-7 shrink-0 place-items-center rounded-full [&>svg]:h-3.5 [&>svg]:w-3.5", tone === "primary" && "bg-primary/5 text-primary", tone === "success" && "bg-emerald-500/10 text-emerald-600", tone === "warning" && "bg-amber-500/10 text-amber-600", tone === "danger" && "bg-destructive/10 text-destructive", tone === "muted" && "bg-muted text-muted-foreground")}>{icon}</div><div className="min-w-0"><div className="truncate text-[10px] leading-3 text-muted-foreground">{label}</div><div className={cn("text-base font-semibold leading-5 tabular-nums", tone === "success" && "text-emerald-700 dark:text-emerald-400", tone === "warning" && "text-amber-700 dark:text-amber-400", tone === "danger" && "text-destructive")}>{value}</div></div></div>
}
function dnsStatusDisplay(status: string): { state: "success" | "warning" | "danger" | "muted"; label: string } {
if (status === "ok") return { state: "success", label: "DNS 正常" }
if (status === "error") return { state: "danger", label: "DNS 异常" }
if (!status || status === "unchecked") return { state: "muted", label: "未检测" }
return { state: "warning", label: "需检查" }
}
function LightStatus({ state, label }: { state: "success" | "warning" | "danger" | "muted"; label: string }) {
return <span className={cn("inline-flex h-6 items-center gap-1.5 whitespace-nowrap rounded-full px-1.5 text-xs font-medium", state === "success" && "bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-400", state === "warning" && "bg-amber-500/[0.07] text-amber-700 dark:text-amber-400", state === "danger" && "bg-destructive/[0.07] text-destructive", state === "muted" && "bg-muted/70 text-muted-foreground")}><span className={cn("h-1.5 w-1.5 rounded-full", state === "success" && "bg-emerald-600", state === "warning" && "bg-amber-500", state === "danger" && "bg-destructive", state === "muted" && "bg-muted-foreground/60")} />{label}</span>
}
function DashboardGroupTitle({ children }: { children: React.ReactNode }) {
return <div className="mb-1.5 text-[11px] font-medium text-muted-foreground">{children}</div>
}
function DashboardStatusItem({ label, status }: { label: string; status: React.ReactNode }) {
return <div className="min-w-0"><div className="mb-0.5 truncate text-[10px] text-muted-foreground">{label}</div>{status}</div>
}
function DashboardInfoItem({ label, value, onCopy }: { label: string; value: string; onCopy?: () => void }) {
return <div className="flex min-w-0 items-end gap-1"><div className="min-w-0 flex-1"><div className="text-[10px] text-muted-foreground">{label}</div><div className="truncate text-xs font-medium" title={value}>{value}</div></div>{onCopy && <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={onCopy} title={`复制${label}`} aria-label={`复制${label}`}><Copy className="h-3 w-3" /></Button>}</div>
}
async function copyOverviewValue(value: string, label: string, toast: ReturnType<typeof useToast>["toast"]) {
await navigator.clipboard.writeText(value)
toast({ title: `${label}已复制` })
}
function InfoLine({ label, value }: { label: string; value: React.ReactNode }) { function InfoLine({ label, value }: { label: string; value: React.ReactNode }) {
return <div className="grid min-h-10 grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-md border px-3 py-2"><span className="whitespace-nowrap">{label}</span><span className="min-w-0 break-all text-right font-medium text-foreground">{value}</span></div> return <div className="grid min-h-10 grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-md border px-3 py-2"><span className="whitespace-nowrap">{label}</span><span className="min-w-0 break-all text-right font-medium text-foreground">{value}</span></div>
} }
@@ -996,11 +1083,11 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
<div key={domain.id} className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between"> <div key={domain.id} className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between">
<div> <div>
<div className="font-medium">{domain.name}</div> <div className="font-medium">{domain.name}</div>
<div className="text-xs text-muted-foreground">selector: {domain.dkimSelector}</div> <div className="text-xs text-muted-foreground">DKIM {domain.dkimSelector}</div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<StatusText active={domain.status === "active"} activeLabel="启用" inactiveLabel="停用" /> <StatusText active={domain.status === "active"} activeLabel="启用" inactiveLabel="停用" />
<StatusText active={domain.dnsStatus === "ok"} activeLabel="DNS 正常" inactiveLabel={domain.dnsStatus || "未检测"} /> <LightStatus state={dnsStatusDisplay(domain.dnsStatus).state} label={dnsStatusDisplay(domain.dnsStatus).label} />
{canViewDNS && <DomainDNSDialog domain={domain} />} {canViewDNS && <DomainDNSDialog domain={domain} />}
{canUpdate && <Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button>} {canUpdate && <Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button>}
{canDelete && <Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、转发和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" /></Button>} {canDelete && <Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、转发和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" /></Button>}
@@ -1063,7 +1150,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
.filter((group) => !keyword || [group.owner ? accountPrimaryEmail(group.owner) : "", group.owner?.displayName || "", ...group.mailboxes.map((mailbox) => mailbox.address)].some((value) => value.toLowerCase().includes(keyword))) .filter((group) => !keyword || [group.owner ? accountPrimaryEmail(group.owner) : "", group.owner?.displayName || "", ...group.mailboxes.map((mailbox) => mailbox.address)].some((value) => value.toLowerCase().includes(keyword)))
const toggleOwner = (ownerID: string) => setExpandedOwners((current) => current.includes(ownerID) ? current.filter((id) => id !== ownerID) : [...current, ownerID]) const toggleOwner = (ownerID: string) => setExpandedOwners((current) => current.includes(ownerID) ? current.filter((id) => id !== ownerID) : [...current, ownerID])
return ( return (
<Card> <Card className="min-w-0">
<CardHeader> <CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle> <CardTitle></CardTitle>
@@ -1073,7 +1160,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="min-w-0 space-y-4">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索账号或邮箱" className="pl-9" /> <Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索账号或邮箱" className="pl-9" />
@@ -1219,7 +1306,7 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
const detail = useQuery({ queryKey: ["admin", "message", selectedId], queryFn: () => api.adminMessage(selectedId!), enabled: !!selectedId }) const detail = useQuery({ queryKey: ["admin", "message", selectedId], queryFn: () => api.adminMessage(selectedId!), enabled: !!selectedId })
const items = messages.data?.pages.flatMap((page) => page.items || []) || [] const items = messages.data?.pages.flatMap((page) => page.items || []) || []
return ( return (
<Card> <Card className="min-w-0">
<CardHeader> <CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle></CardTitle> <CardTitle></CardTitle>
@@ -1228,7 +1315,7 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="min-w-0 space-y-4">
<div className="flex flex-col gap-3 xl:flex-row"> <div className="flex flex-col gap-3 xl:flex-row">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
@@ -1277,17 +1364,17 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
</div> </div>
))} ))}
</div> </div>
<div className="hidden md:block"> <div className="min-w-0 overflow-hidden md:block max-md:hidden">
<Table> <Table className="table-fixed">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead className="w-[25%]"></TableHead>
<TableHead></TableHead> <TableHead className="w-[17%]"></TableHead>
<TableHead></TableHead> <TableHead className="w-[15%]"></TableHead>
<TableHead></TableHead> <TableHead className="w-[17%]"></TableHead>
<TableHead></TableHead> <TableHead className="w-[9%]"></TableHead>
<TableHead></TableHead> <TableHead className="w-[10%]"></TableHead>
<TableHead className="w-20"></TableHead> <TableHead className="w-[7%]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -1297,9 +1384,9 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
<div className="truncate font-medium">{message.subject}</div> <div className="truncate font-medium">{message.subject}</div>
<div className="truncate text-xs text-muted-foreground">{message.snippet}</div> <div className="truncate text-xs text-muted-foreground">{message.snippet}</div>
</TableCell> </TableCell>
<TableCell> <TableCell className="min-w-0">
<div className="font-medium">{message.mailboxAddress || message.recipientAddress || "-"}</div> <div className="truncate font-medium" title={message.mailboxAddress || message.recipientAddress || "-"}>{message.mailboxAddress || message.recipientAddress || "-"}</div>
{message.ownerEmail && <div className="text-xs text-muted-foreground">{message.ownerEmail}</div>} {message.ownerEmail && <div className="truncate text-xs text-muted-foreground" title={message.ownerEmail}>{message.ownerEmail}</div>}
</TableCell> </TableCell>
<TableCell className="max-w-[220px] truncate" title={adminSenderTitle(message)}>{adminSenderDisplayName(message)}</TableCell> <TableCell className="max-w-[220px] truncate" title={adminSenderTitle(message)}>{adminSenderDisplayName(message)}</TableCell>
<TableCell className="max-w-[220px] truncate">{message.recipientAddress || message.to?.join(", ") || ""}</TableCell> <TableCell className="max-w-[220px] truncate">{message.recipientAddress || message.to?.join(", ") || ""}</TableCell>
@@ -1453,7 +1540,7 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
const canResetTemplates = hasPermission(user, "admin.templates.reset") const canResetTemplates = hasPermission(user, "admin.templates.reset")
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates }) const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
const requestedTab = initialTab as SettingsTab | undefined const requestedTab = initialTab as SettingsTab | undefined
const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "notifications", "externalImap", "templates", "security", "about"].includes(requestedTab) ? requestedTab : "base") const [settingsTab, setSettingsTab] = React.useState<SettingsTab>(() => requestedTab && ["base", "smtp", "storage", "mail", "notifications", "externalImap", "templates", "security"].includes(requestedTab) ? requestedTab : "base")
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" }) const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false) const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true) const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
@@ -1613,11 +1700,10 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
] : []), ] : []),
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []), ...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []), ...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []),
{ key: "about", label: "关于" },
] ]
React.useEffect(() => { React.useEffect(() => {
if (tabs.some((tab) => tab.key === settingsTab)) return if (tabs.some((tab) => tab.key === settingsTab)) return
setSettingsTab(tabs[0]?.key || "about") setSettingsTab(tabs[0]?.key || "base")
}, [settingsTab, tabs]) }, [settingsTab, tabs])
return ( return (
<form key={formKey} onSubmit={(event) => { event.preventDefault(); if (canUpdateSettings) save.mutate(new FormData(event.currentTarget)) }} className="space-y-6"> <form key={formKey} onSubmit={(event) => { event.preventDefault(); if (canUpdateSettings) save.mutate(new FormData(event.currentTarget)) }} className="space-y-6">
@@ -1860,9 +1946,7 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s
</CardContent> </CardContent>
</Card>} </Card>}
{settingsTab === "about" && <AboutProjectCard />} {canUpdateSettings && <div className="flex justify-end">
{settingsTab !== "about" && canUpdateSettings && <div className="flex justify-end">
<Button disabled={save.isPending || !settings}>{save.isPending ? "保存中..." : "保存设置"}</Button> <Button disabled={save.isPending || !settings}>{save.isPending ? "保存中..." : "保存设置"}</Button>
</div>} </div>}
</form> </form>
@@ -1965,76 +2049,6 @@ function queryErrorMessage(error: unknown) {
return error instanceof Error ? error.message : "读取 Maildir 同步健康失败" return error instanceof Error ? error.message : "读取 Maildir 同步健康失败"
} }
function AboutProjectCard() {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<AboutRow label="版本">
<SystemVersionDialog mode="inline" />
</AboutRow>
<AboutRow label="交流">
<div className="flex flex-wrap gap-3">
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
<a href={projectRepositoryUrl} target="_blank" rel="noreferrer">
<Github className="h-5 w-5" />
GitHub
</a>
</Button>
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
<a href={`${projectRepositoryUrl}/issues`} target="_blank" rel="noreferrer">
<Circle className="h-5 w-5 text-muted-foreground" />
Issues
</a>
</Button>
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
<a href={projectTelegramUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-5 w-5 text-sky-500" />
Telegram
</a>
</Button>
</div>
</AboutRow>
<AboutRow label="支持">
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
<a href={projectRepositoryUrl} target="_blank" rel="noreferrer">
<Star className="h-5 w-5 text-yellow-500" />
Star
</a>
</Button>
</AboutRow>
<AboutRow label="帮助">
<div className="flex flex-wrap gap-3">
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
<a href={`${projectRepositoryUrl}#readme`} target="_blank" rel="noreferrer">
<BookOpen className="h-5 w-5 text-sky-500" />
</a>
</Button>
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
<a href={`${projectRepositoryUrl}/blob/main/LICENSE`} target="_blank" rel="noreferrer">
<Scale className="h-5 w-5 text-emerald-500" />
</a>
</Button>
</div>
</AboutRow>
</CardContent>
</Card>
)
}
function AboutRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid gap-2 sm:grid-cols-[4.5rem_minmax(0,1fr)] sm:items-center">
<div className="font-medium text-muted-foreground">{label}</div>
<div className="min-w-0">{children}</div>
</div>
)
}
function TestSMTPDialog({ disabled }: { disabled?: boolean }) { function TestSMTPDialog({ disabled }: { disabled?: boolean }) {
const { toast } = useToast() const { toast } = useToast()
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false)
@@ -2215,8 +2229,8 @@ function sendAuditBadgeVariant(event?: string) {
return "secondary" return "secondary"
} }
function Stat({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: React.ReactNode; detail: string }) { function Stat({ icon, tone, label, value, detail }: { icon: React.ReactNode; tone: "primary" | "cyan" | "sky" | "violet"; label: string; value: React.ReactNode; detail: string }) {
return <Card><CardContent className="flex min-h-[84px] items-center gap-3 p-4"><div className="grid h-9 w-9 shrink-0 place-items-center rounded-md bg-muted text-foreground [&>svg]:h-4 [&>svg]:w-4">{icon}</div><div className="min-w-0"><div className="flex items-baseline gap-2"><div className="truncate text-xl font-semibold leading-7">{value}</div><div className="truncate text-sm text-muted-foreground">{label}</div></div><div className="mt-0.5 truncate text-xs text-muted-foreground">{detail}</div></div></CardContent></Card> return <Card className="border-border/80"><CardContent className="flex min-h-[88px] items-center gap-3 p-3 !pt-3"><div className={cn("grid h-10 w-10 shrink-0 place-items-center rounded-lg [&>svg]:h-5 [&>svg]:w-5", tone === "primary" && "bg-primary/5 text-primary", tone === "cyan" && "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400", tone === "sky" && "bg-sky-500/10 text-sky-600 dark:text-sky-400", tone === "violet" && "bg-violet-500/10 text-violet-600 dark:text-violet-400")}>{icon}</div><div className="min-w-0"><div className="truncate text-xs font-medium">{label}</div><div className="truncate text-2xl font-semibold leading-7 tabular-nums">{value}</div><div className="truncate text-[11px] text-muted-foreground">{detail}</div></div></CardContent></Card>
} }
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 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 Empty({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
@@ -2232,7 +2246,6 @@ function QueryFailure({ error, onRetry, compact = false }: { error: unknown; onR
</div> </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"] }) } function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) }
function UserMailboxCell({ user }: { user: AdminUser }) { function UserMailboxCell({ user }: { user: AdminUser }) {
+3 -1
View File
@@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
import { safeReturnPath } from "@/lib/navigation" import { safeReturnPath } from "@/lib/navigation"
import { AuthError, AuthLoading } from "@/components/auth-states" import { AuthError, AuthLoading } from "@/components/auth-states"
import { BrandMark } from "@/components/brand-mark"
export function LoginPage() { export function LoginPage() {
const me = useMe() const me = useMe()
@@ -43,7 +44,8 @@ export function LoginPage() {
return ( return (
<main 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="w-full max-w-[420px]">
<div className="mb-7 text-center"> <div className="mb-7 flex items-center justify-center gap-3 text-center">
<BrandMark className="size-11 [&>svg]:size-7" />
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1> <h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
</div> </div>
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7"> <div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
+19 -12
View File
@@ -653,8 +653,8 @@ export function MailPage() {
const first = newMessages[0] const first = newMessages[0]
const firstSender = senderDisplayName(first) const firstSender = senderDisplayName(first)
const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${first.subject || "(无主题)"}` const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${messageSubject(first)}`
const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : `${firstSender}${first.snippet ? ` · ${first.snippet}` : ""}` const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : firstSender
const openFirstMessage = () => { const openFirstMessage = () => {
setMailView("folder") setMailView("folder")
setFolder("Inbox") setFolder("Inbox")
@@ -869,11 +869,12 @@ export function MailPage() {
} }
function confirmDeleteMessage(message: MailMessage) { function confirmDeleteMessage(message: MailMessage) {
const permanent = message.folder === "Trash" const permanent = message.folder === "Trash"
const subject = messageSubject(message)
setPendingConfirm({ setPendingConfirm({
title: permanent ? "永久删除这封邮件?" : "将这封邮件移入已删除?", title: permanent ? "永久删除这封邮件?" : "将这封邮件移入已删除?",
description: permanent description: permanent
? `邮件“${message.subject || "无主题"}”将被永久删除,且无法恢复。` ? `邮件“${subject}”将被永久删除,且无法恢复。`
: `邮件“${message.subject || "无主题"}”将移入已删除。`, : `邮件“${subject}”将移入已删除。`,
confirmText: permanent ? "永久删除" : "移入已删除", confirmText: permanent ? "永久删除" : "移入已删除",
onConfirm: () => del.mutate({ id: message.id, permanent }), onConfirm: () => del.mutate({ id: message.id, permanent }),
}) })
@@ -886,11 +887,11 @@ export function MailPage() {
} }
function openReply(message: MailMessage) { function openReply(message: MailMessage) {
if (!canSendMail) return if (!canSendMail) return
openCompose({ key: `reply-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) openCompose({ key: `reply-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, to: message.from, subject: withPrefix(messageSubject(message), "Re:"), text: quoteMessage(message) })
} }
function openForward(message: MailMessage) { function openForward(message: MailMessage) {
if (!canSendMail) return if (!canSendMail) return
openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) openCompose({ key: `forward-${message.id}-${Date.now()}`, mailboxId: message.mailboxId, subject: withPrefix(messageSubject(message), "Fwd:"), text: quoteMessage(message) })
} }
async function openDraft(message: MailMessage) { async function openDraft(message: MailMessage) {
if (!canManageDrafts) return if (!canManageDrafts) return
@@ -1942,6 +1943,7 @@ export function MailPage() {
<CreateFolderDialog <CreateFolderDialog
open={folderDialogOpen} open={folderDialogOpen}
pending={createFolder.isPending} pending={createFolder.isPending}
scope={isAllMailboxSelected ? "全部邮箱" : selectedMailbox?.address || "当前邮箱"}
onOpenChange={setFolderDialogOpen} onOpenChange={setFolderDialogOpen}
onCreate={(payload) => createFolder.mutate(payload)} onCreate={(payload) => createFolder.mutate(payload)}
/> />
@@ -2825,7 +2827,7 @@ function contextMenuPosition(x: number, y: number) {
return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) } return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) }
} }
function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) { function CreateFolderDialog({ open, pending, scope, onOpenChange, onCreate }: { open: boolean; pending: boolean; scope: string; onOpenChange: (open: boolean) => void; onCreate: (payload: { name: string; icon: string }) => void }) {
const [name, setName] = React.useState("") const [name, setName] = React.useState("")
const [icon, setIcon] = React.useState("auto") const [icon, setIcon] = React.useState("auto")
const [uploadError, setUploadError] = React.useState("") const [uploadError, setUploadError] = React.useState("")
@@ -2856,6 +2858,7 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new-folder-name"></Label> <Label htmlFor="new-folder-name"></Label>
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" /> <Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
<p className="text-xs text-muted-foreground">{scope}</p>
</div> </div>
<fieldset className="space-y-2"> <fieldset className="space-y-2">
<legend className="text-sm font-medium"></legend> <legend className="text-sm font-medium"></legend>
@@ -3190,7 +3193,7 @@ function CompactMessageDetail({
<div className="w-full px-4 py-4 sm:px-8 sm:py-6"> <div className="w-full px-4 py-4 sm:px-8 sm:py-6">
<div className="space-y-5 border-b pb-5"> <div className="space-y-5 border-b pb-5">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<h1 className="min-w-0 flex-1 break-words text-xl font-semibold tracking-tight sm:text-2xl">{selected.subject}</h1> <h1 className="min-w-0 flex-1 break-words text-xl font-semibold tracking-tight sm:text-2xl">{messageSubject(selected)}</h1>
{canOrganize && <Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}> {canOrganize && <Button type="button" variant="ghost" size="icon" aria-label={selected.isStarred ? "取消星标" : "添加星标"} className="text-muted-foreground hover:text-yellow-500" onClick={() => onStar(selected)}>
<Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} /> <Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} />
</Button>} </Button>}
@@ -3416,7 +3419,7 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
</div> </div>
</div> </div>
<div className="mt-1 flex min-w-0 items-center gap-2 sm:mt-0"> <div className="mt-1 flex min-w-0 items-center gap-2 sm:mt-0">
<span className="truncate font-medium">{message.subject}</span> <span className="truncate font-medium">{messageSubject(message)}</span>
<span className="hidden min-w-0 truncate text-muted-foreground sm:block">{message.snippet}</span> <span className="hidden min-w-0 truncate text-muted-foreground sm:block">{message.snippet}</span>
{scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal"></Badge>} {scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal"></Badge>}
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)} {visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)}
@@ -3494,7 +3497,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onToggleTheme} title={darkMode ? "切换到浅色模式" : "切换到深色模式"} aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}> <Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onToggleTheme} title={darkMode ? "切换到浅色模式" : "切换到深色模式"} aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}>
{darkMode ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />} {darkMode ? <Sun className="h-3.5 w-3.5 text-amber-500" /> : <Moon className="h-3.5 w-3.5" />}
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -3620,6 +3623,10 @@ function senderDisplayName(message: MailMessage) {
return displayNameFromAddress(message.from) return displayNameFromAddress(message.from)
} }
function messageSubject(message: MailMessage) {
return decodeMimeHeader(message.subject?.trim() || "") || "无主题"
}
function displayNameFromAddress(value: string) { function displayNameFromAddress(value: string) {
const text = decodeMimeHeader(value.trim()) const text = decodeMimeHeader(value.trim())
const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/) const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/)
@@ -3875,7 +3882,7 @@ function MessageRow({
</div> </div>
</div> </div>
<div className="mb-1 flex min-w-0 items-center gap-2"> <div className="mb-1 flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-[13px] text-foreground">{message.subject || "无主题"}</span> <span className="min-w-0 truncate text-[13px] text-foreground">{messageSubject(message)}</span>
{scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal"></Badge>} {scheduled && <Badge variant="secondary" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal"></Badge>}
{visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)} {visibleLabels.map((label) => <MailLabelBadge key={label.id} label={label} />)}
{hiddenLabelCount > 0 && <Badge variant="outline" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal text-muted-foreground">+{hiddenLabelCount}</Badge>} {hiddenLabelCount > 0 && <Badge variant="outline" className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal text-muted-foreground">+{hiddenLabelCount}</Badge>}
@@ -5329,7 +5336,7 @@ function withPrefix(subject: string, prefix: string) { return subject.toLowerCas
function quoteMessage(message: MailMessage) { function quoteMessage(message: MailMessage) {
const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "") const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")
const quote = body.split("\n").map((line) => `> ${line}`).join("\n") const quote = body.split("\n").map((line) => `> ${line}`).join("\n")
return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}` return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${messageSubject(message)}\n\n${quote}`
} }
function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" } function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" }
function attachmentLimitBytes(limits?: PermissionLimits) { function attachmentLimitBytes(limits?: PermissionLimits) {
+2 -2
View File
@@ -377,7 +377,7 @@ export function ProfilePage() {
</div> </div>
</nav> </nav>
<div className="border-t p-2"> <div className="border-t p-2">
<Button type="button" variant="ghost" size="sm" className="h-9 w-full justify-start gap-2 px-3 text-destructive hover:text-destructive" onClick={logout}> <Button type="button" variant="outline" size="sm" className="h-9 w-full justify-start gap-2 border-destructive/35 px-3 text-destructive shadow-none hover:border-destructive/55 hover:bg-destructive/10 hover:text-destructive dark:border-destructive/45 dark:hover:bg-destructive/15" onClick={logout}>
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
<span>退</span> <span>退</span>
</Button> </Button>
@@ -2868,7 +2868,7 @@ function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name:
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label={darkMode ? "切换浅色模式" : "切换深色模式"} title={darkMode ? "浅色模式" : "深色模式"} 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" />} {darkMode ? <Sun className="h-4 w-4 text-amber-500" /> : <Moon className="h-4 w-4" />}
</Button> </Button>
<Button type="button" variant="ghost" size="icon" className="size-[28px] rounded-md text-muted-foreground" aria-label="返回邮箱" title="返回邮箱" 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" /> <ArrowLeft className="h-4 w-4" />
+3 -1
View File
@@ -14,6 +14,7 @@ import { PasswordInput } from "@/components/ui/password-input"
import { TurnstileBox } from "@/components/turnstile-box" import { TurnstileBox } from "@/components/turnstile-box"
import { validatePasswordConfirm } from "@/lib/validation" import { validatePasswordConfirm } from "@/lib/validation"
import { AuthError, AuthLoading } from "@/components/auth-states" import { AuthError, AuthLoading } from "@/components/auth-states"
import { BrandMark } from "@/components/brand-mark"
export function RegisterPage() { export function RegisterPage() {
const me = useMe() const me = useMe()
@@ -63,7 +64,8 @@ export function RegisterPage() {
return ( return (
<main 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="w-full max-w-[420px]">
<div className="mb-7 text-center"> <div className="mb-7 flex items-center justify-center gap-3 text-center">
<BrandMark className="size-11 [&>svg]:size-7" />
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1> <h1 className="text-3xl font-semibold tracking-tight">NewSzxcn </h1>
</div> </div>
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7"> <div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">