diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index 7be8332..e7eb051 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -24,23 +24,36 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) { Messages int64 `json:"messages"` UnreadMessages int64 `json:"unreadMessages"` 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 { q string dest *int64 + args []any }{ - {`SELECT COUNT(*) FROM users`, &out.Users}, - {`SELECT COUNT(*) FROM users WHERE disabled=0`, &out.ActiveUsers}, - {`SELECT COUNT(*) FROM domains`, &out.Domains}, - {`SELECT COUNT(*) FROM mailboxes`, &out.Mailboxes}, - {`SELECT COUNT(*) FROM mailboxes WHERE status='active'`, &out.ActiveMailboxes}, - {`SELECT COUNT(*) FROM aliases`, &out.Aliases}, - {`SELECT COUNT(*) FROM messages`, &out.Messages}, - {`SELECT COUNT(*) FROM messages WHERE is_read=0`, &out.UnreadMessages}, - {`SELECT COALESCE(SUM(size_bytes),0) FROM messages`, &out.StorageBytes}, + {q: `SELECT COUNT(*) FROM users`, dest: &out.Users}, + {q: `SELECT COUNT(*) FROM users WHERE disabled=0`, dest: &out.ActiveUsers}, + {q: `SELECT COUNT(*) FROM domains`, dest: &out.Domains}, + {q: `SELECT COUNT(*) FROM mailboxes`, dest: &out.Mailboxes}, + {q: `SELECT COUNT(*) FROM mailboxes WHERE status='active'`, dest: &out.ActiveMailboxes}, + {q: `SELECT COUNT(*) FROM aliases`, dest: &out.Aliases}, + {q: `SELECT COUNT(*) FROM messages`, dest: &out.Messages}, + {q: `SELECT COUNT(*) FROM messages WHERE is_read=0`, dest: &out.UnreadMessages}, + {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 { - 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") return } diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index 16e6c7e..9334732 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -21,6 +21,7 @@ import ( "golang.org/x/text/encoding" "golang.org/x/text/encoding/ianaindex" + "golang.org/x/text/encoding/simplifiedchinese" ) type maildirMailbox struct { @@ -822,6 +823,14 @@ func charsetReader(charset string, input io.Reader) (io.Reader, error) { if charset == "utf-8" || charset == "us-ascii" { 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) if err != nil { return nil, fmt.Errorf("unsupported charset %q: %w", charset, err) diff --git a/apps/api/internal/app/telegram_test.go b/apps/api/internal/app/telegram_test.go index 6694457..74fb8e0 100644 --- a/apps/api/internal/app/telegram_test.go +++ b/apps/api/internal/app/telegram_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "encoding/base64" "encoding/json" "errors" "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 \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) { var calls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/apps/web/src/components/brand-mark.tsx b/apps/web/src/components/brand-mark.tsx new file mode 100644 index 0000000..5a374e2 --- /dev/null +++ b/apps/web/src/components/brand-mark.tsx @@ -0,0 +1,11 @@ +import { Mail } from "lucide-react" + +import { cn } from "@/lib/utils" + +export function BrandMark({ className }: { className?: string }) { + return ( + + ) +} diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx index e445435..3ce207a 100644 --- a/apps/web/src/components/protected-layout.tsx +++ b/apps/web/src/components/protected-layout.tsx @@ -1,12 +1,13 @@ import * as React from "react" 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 { useLogout } from "@/hooks/use-logout" import { AuthGuard } from "@/components/auth-guard" import { Button } from "@/components/ui/button" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { SystemVersionDialog } from "@/components/system-version-dialog" +import { BrandMark } from "@/components/brand-mark" import { hasAnyPermission } from "@/lib/permissions" import type { PermissionKey } from "@/lib/api-types" import { @@ -27,7 +28,7 @@ import { } from "@/components/ui/sidebar" const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [ - { key: "overview", label: "数据总览", icon: , permissions: ["admin.overview.view"] }, + { key: "overview", label: "仪表盘", icon: , permissions: ["admin.overview.view"] }, { key: "users", label: "账号管理", icon: , permissions: ["admin.users.view"] }, { key: "permissionGroups", label: "权限配置", icon: , permissions: ["admin.permission_groups.view"] }, { key: "domains", label: "域名管理", icon: , permissions: ["admin.domains.view", "admin.dns.view"] }, @@ -72,9 +73,7 @@ function ProtectedContent() { -
- -
+
NewSzxcn 邮箱
@@ -115,7 +114,7 @@ function ProtectedContent() {
-
diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index da11088..e62b96d 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef< (({ className, ...props }, ref) => ( )) @@ -110,7 +110,7 @@ const ToastDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )) diff --git a/apps/web/src/components/ui/toaster.tsx b/apps/web/src/components/ui/toaster.tsx index 171beb4..a9342eb 100644 --- a/apps/web/src/components/ui/toaster.tsx +++ b/apps/web/src/components/ui/toaster.tsx @@ -14,11 +14,11 @@ export function Toaster() { const { toasts } = useToast() return ( - + {toasts.map(function ({ id, title, description, action, ...props }) { return ( -
+
{title && {title}} {description && ( {description} diff --git a/apps/web/src/hooks/use-toast.ts b/apps/web/src/hooks/use-toast.ts index 2c14125..3ce5128 100644 --- a/apps/web/src/hooks/use-toast.ts +++ b/apps/web/src/hooks/use-toast.ts @@ -5,8 +5,8 @@ import type { ToastProps, } from "@/components/ui/toast" -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 +const TOAST_LIMIT = 3 +const TOAST_REMOVE_DELAY = 1000 type ToasterToast = ToastProps & { id: string diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 82119af..04ddfbf 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -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 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 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 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 } diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index c75bd15..cee8504 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -2,8 +2,8 @@ import * as React from "react" import DOMPurify from "dompurify" import { useSearchParams } from "react-router-dom" 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 { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api" +import { AlertCircle, BookOpen, CheckCircle2, ChevronDown, ChevronRight, Circle, ClipboardList, Clock3, Cloud, Copy, Database, Download, ExternalLink, Eye, EyeOff, Github, Globe2, HardDrive, KeyRound, Loader2, Mail, MoreHorizontal, RefreshCcw, Scale, Search, Send, ShieldCheck, Star, Trash2, UserRound } from "lucide-react" +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 { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -30,17 +30,17 @@ type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxe type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about" type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } -const sectionMeta: Record = { - overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" }, - users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" }, - permissionGroups: { label: "权限配置", frontLabel: "账号权限", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" }, - domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" }, - mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "按归属账号查看和管理子邮箱,默认邮箱受保护。" }, - aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" }, - messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" }, - sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" }, - backups: { label: "备份与恢复", frontLabel: "数据保护", description: "创建、校验和下载可迁移的加密完整备份。" }, - settings: { label: "系统设置", frontLabel: "账号设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" }, +const sectionMeta: Record = { + overview: { label: "仪表盘", description: "邮件运行、域名与系统状态集中查看。" }, + users: { label: "账号管理", description: "管理登录账号、身份状态、邮箱数量上限和共享存储容量。" }, + permissionGroups: { label: "权限配置", description: "配置自定义权限、发信频率、附件和邮箱创建额度。" }, + domains: { label: "域名管理", description: "维护邮件域名、DKIM 和 DNS 检测。" }, + mailboxes: { label: "邮箱管理", description: "按归属账号查看和管理子邮箱,默认邮箱受保护。" }, + aliases: { label: "邮件转发", description: "管理域名转发规则。" }, + messages: { label: "全部邮件", description: "按邮箱、文件夹和关键词查看全站邮件。" }, + sendAudit: { label: "发送队列", description: "查看发信投递、重试和失败记录。" }, + backups: { label: "备份与恢复", description: "创建、校验和下载可迁移的加密完整备份。" }, + settings: { label: "系统设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" }, } const sectionLabels = Object.fromEntries(Object.entries(sectionMeta).map(([key, value]) => [key, value.label])) as Record const sectionKeys = Object.keys(sectionLabels) as Section[] @@ -133,23 +133,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 ( -
- +
+ {sectionQuery?.isError && { void sectionQuery.refetch() }} />} {section === "overview" && canOverview && ( -
- } label="账号" value={overview.data?.users || 0} detail={`${overview.data?.activeUsers || 0} 个活跃`} /> - } label="邮件域名" value={overview.data?.domains || 0} detail={`${domainItems.filter((domain) => domain.dnsStatus === "ok").length} 个 DNS 正常`} /> - } label="邮箱" value={overview.data?.mailboxes || 0} detail={`${overview.data?.activeMailboxes || 0} 个活跃`} /> - } label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} detail={`${overview.data?.unreadMessages || 0} 封未读 · ${overview.data?.aliases || 0} 个转发`} /> +
+ } tone="primary" label="账号" value={overview.data?.users || 0} detail={`${overview.data?.activeUsers || 0} 个活跃`} /> + } tone="cyan" label="邮件域名" value={overview.data?.domains || 0} detail={domainItems.some((domain) => domain.dnsStatus === "ok") ? `${domainItems.filter((domain) => domain.dnsStatus === "ok").length} 个 DNS 正常` : "待检测"} /> + } tone="sky" label="邮箱" value={overview.data?.mailboxes || 0} detail={`${overview.data?.activeMailboxes || 0} 个活跃`} /> + } tone="violet" label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} detail={`${overview.data?.unreadMessages || 0} 封未读 · ${overview.data?.aliases || 0} 个转发`} />
)} - {section === "overview" && setParams(next === "overview" ? {} : { section: next })} />} + {section === "overview" && } {section === "users" && } {section === "permissionGroups" && } {section === "domains" && } @@ -164,23 +167,22 @@ export function AdminPage() { ) } -function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section; refreshing: boolean; onRefresh: () => void }) { +type SetupChecklistItem = ReturnType[number] + +function AdminPageHeader({ section, refreshing, onRefresh, checklist, onSectionChange }: { section: Section; refreshing: boolean; onRefresh: () => void; checklist?: SetupChecklistItem[]; onSectionChange: (section: Section) => void }) { const meta = sectionMeta[section] return ( -
-
+
+
-
- 后台管理 - - 前台:{meta.frontLabel} -

{meta.label}

-

{meta.description}

+

{meta.description}

-
@@ -188,51 +190,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 }) { - const checklist = setupChecklist(overview, domains, settings).filter((item) => visibleSections.includes(item.section)) +function SetupChecklistDialog({ checklist, onSectionChange }: { checklist: SetupChecklistItem[]; onSectionChange: (section: Section) => void }) { + const [open, setOpen] = React.useState(false) + const completed = checklist.filter((item) => item.done).length + const complete = checklist.length > 0 && completed === checklist.length return ( -
- - 首次配置 - - {checklist.map((item) => ( - - ))} + + + + + + 首次配置 +
+ {checklist.map((item) => ( + + ))} +
+
+
+ ) +} + +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 ( +
+ + 邮件运行概览 + +
+ } label="今日发送" value={overview?.todaySent || 0} tone="primary" /> + } label="今日接收" value={overview?.todayReceived || 0} tone="success" /> + } label="发送成功" value={overview?.sendDelivered || 0} tone="success" /> + } label="发送失败" value={overview?.sendFailed || 0} tone={(overview?.sendFailed || 0) > 0 ? "danger" : "muted"} /> + } label="队列邮件" value={overview?.queueMessages || 0} tone={(overview?.queueMessages || 0) > 0 ? "warning" : "muted"} /> + } label="未读邮件" value={overview?.unreadMessages || 0} tone={(overview?.unreadMessages || 0) > 0 ? "primary" : "muted"} /> +
- - 系统状态 - -
-

DNS 状态

-
- {domains.map((domain) => )} - {domains.length === 0 && } + + + 系统状态 + +
+ 系统健康 +
+ } /> + } /> + } />
-
- -
-

运行信息

-
- - - - +
+
+ 服务信息 +
+ copyOverviewValue(settings.publicBaseUrl, "公网地址", toast) : undefined} /> + copyOverviewValue(`${settings.smtpHost}:${settings.smtpPort}`, "SMTP 地址", toast) : undefined} />
-
+
+
+ 功能状态 +
+ } /> + } /> +
+
+
+
+ + +
域名状态{domains.length} 个域名
{visibleSections.includes("domains") && }
+ + {domains.length > 0 ?
+
邮件域名使用状态DNS 状态最近检测
+
{domains.slice(0, 5).map((domain) => { + const dnsDisplay = dnsStatusDisplay(domain.dnsStatus) + return + })}
+ {domains.length > 5 && } +
: }
) } -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 dnsReady = domains.some((domain) => domain.dnsStatus === "ok") const hasMailbox = (overview?.activeMailboxes || 0) > 0 @@ -246,6 +304,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
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}
{label}
{value}
+} + +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 {label} +} + +function DashboardGroupTitle({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function DashboardStatusItem({ label, status }: { label: string; status: React.ReactNode }) { + return
{label}
{status}
+} + +function DashboardInfoItem({ label, value, onCopy }: { label: string; value: string; onCopy?: () => void }) { + return
{label}
{value}
{onCopy && }
+} + +async function copyOverviewValue(value: string, label: string, toast: ReturnType["toast"]) { + await navigator.clipboard.writeText(value) + toast({ title: `${label}已复制` }) +} + function InfoLine({ label, value }: { label: string; value: React.ReactNode }) { return
{label}{value}
} @@ -996,11 +1086,11 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
{domain.name}
-
selector: {domain.dkimSelector}
+
DKIM 选择器:{domain.dkimSelector}
- + {canViewDNS && } {canUpdate && } {canDelete && } @@ -1063,7 +1153,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))) const toggleOwner = (ownerID: string) => setExpandedOwners((current) => current.includes(ownerID) ? current.filter((id) => id !== ownerID) : [...current, ownerID]) return ( - +
邮箱管理 @@ -1073,7 +1163,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
- +
setQuery(event.target.value)} placeholder="搜索账号或邮箱" className="pl-9" /> @@ -1219,7 +1309,7 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy const detail = useQuery({ queryKey: ["admin", "message", selectedId], queryFn: () => api.adminMessage(selectedId!), enabled: !!selectedId }) const items = messages.data?.pages.flatMap((page) => page.items || []) || [] return ( - +
全部邮件 @@ -1228,7 +1318,7 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
- +
@@ -1277,17 +1367,17 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
))}
-
- +
+
- 邮件 - 邮箱 - 发件人 - 收件人 - 文件夹 - 时间 - + 邮件 + 邮箱 + 发件人 + 收件人 + 文件夹 + 时间 + @@ -1297,9 +1387,9 @@ function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxTy
{message.subject}
{message.snippet}
- -
{message.mailboxAddress || message.recipientAddress || "-"}
- {message.ownerEmail &&
{message.ownerEmail}
} + +
{message.mailboxAddress || message.recipientAddress || "-"}
+ {message.ownerEmail &&
{message.ownerEmail}
}
{adminSenderDisplayName(message)} {message.recipientAddress || message.to?.join(", ") || ""} @@ -2215,8 +2305,8 @@ function sendAuditBadgeVariant(event?: string) { return "secondary" } -function Stat({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: React.ReactNode; detail: string }) { - return
{icon}
{value}
{label}
{detail}
+function Stat({ icon, tone, label, value, detail }: { icon: React.ReactNode; tone: "primary" | "cyan" | "sky" | "violet"; label: string; value: React.ReactNode; detail: string }) { + return
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}
{label}
{value}
{detail}
} function InfoBox({ label, value }: { label: string; value: React.ReactNode }) { return
{value}
{label}
} function Empty({ text }: { text: string }) { return
{text}
} @@ -2232,7 +2322,6 @@ function QueryFailure({ error, onRetry, compact = false }: { error: unknown; onR ) } -function DomainBadgeRow({ domain }: { domain: Domain }) { return
{domain.name}{domain.dnsStatus === "ok" ? "正常" : domain.dnsStatus}
} function invalidateAdmin(qc: ReturnType) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) } function UserMailboxCell({ user }: { user: AdminUser }) { diff --git a/apps/web/src/pages/login.tsx b/apps/web/src/pages/login.tsx index c516226..4e63360 100644 --- a/apps/web/src/pages/login.tsx +++ b/apps/web/src/pages/login.tsx @@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label" import { useToast } from "@/hooks/use-toast" import { safeReturnPath } from "@/lib/navigation" import { AuthError, AuthLoading } from "@/components/auth-states" +import { BrandMark } from "@/components/brand-mark" export function LoginPage() { const me = useMe() @@ -43,7 +44,8 @@ export function LoginPage() { return (
-
+
+

NewSzxcn 邮箱

diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index d8acc41..d250663 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -653,8 +653,8 @@ export function MailPage() { const first = newMessages[0] const firstSender = senderDisplayName(first) - const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${first.subject || "(无主题)"}` - const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : `${firstSender}${first.snippet ? ` · ${first.snippet}` : ""}` + const title = newMessages.length > 1 ? `收到 ${newMessages.length} 封新邮件` : `新邮件:${messageSubject(first)}` + const description = newMessages.length > 1 ? `${firstSender} 等发来新邮件` : firstSender const openFirstMessage = () => { setMailView("folder") setFolder("Inbox") @@ -869,11 +869,12 @@ export function MailPage() { } function confirmDeleteMessage(message: MailMessage) { const permanent = message.folder === "Trash" + const subject = messageSubject(message) setPendingConfirm({ title: permanent ? "永久删除这封邮件?" : "将这封邮件移入已删除?", description: permanent - ? `邮件“${message.subject || "无主题"}”将被永久删除,且无法恢复。` - : `邮件“${message.subject || "无主题"}”将移入已删除。`, + ? `邮件“${subject}”将被永久删除,且无法恢复。` + : `邮件“${subject}”将移入已删除。`, confirmText: permanent ? "永久删除" : "移入已删除", onConfirm: () => del.mutate({ id: message.id, permanent }), }) @@ -886,11 +887,11 @@ export function MailPage() { } function openReply(message: MailMessage) { 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) { 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) { if (!canManageDrafts) return @@ -1942,6 +1943,7 @@ export function MailPage() { 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) } } -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 [icon, setIcon] = React.useState("auto") const [uploadError, setUploadError] = React.useState("") @@ -2856,6 +2858,7 @@ function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: b
setName(event.target.value)} placeholder="例如:客户、账单、项目归档" /> +

创建位置:{scope}

图标 @@ -3190,7 +3193,7 @@ function CompactMessageDetail({
-

{selected.subject}

+

{messageSubject(selected)}

{canOrganize && } @@ -3416,7 +3419,7 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang
- {message.subject} + {messageSubject(message)} {message.snippet} {scheduled && 已定时} {visibleLabels.map((label) => )} @@ -3494,7 +3497,7 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
@@ -3620,6 +3623,10 @@ function senderDisplayName(message: MailMessage) { return displayNameFromAddress(message.from) } +function messageSubject(message: MailMessage) { + return decodeMimeHeader(message.subject?.trim() || "") || "无主题" +} + function displayNameFromAddress(value: string) { const text = decodeMimeHeader(value.trim()) const namedAddress = text.match(/^"?([^"<]+?)"?\s*<[^>]+>$/) @@ -3875,7 +3882,7 @@ function MessageRow({
- {message.subject || "无主题"} + {messageSubject(message)} {scheduled && 已定时} {visibleLabels.map((label) => )} {hiddenLabelCount > 0 && +{hiddenLabelCount}} @@ -5329,7 +5336,7 @@ function withPrefix(subject: string, prefix: string) { return subject.toLowerCas function quoteMessage(message: MailMessage) { const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "") 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 attachmentLimitBytes(limits?: PermissionLimits) { diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 61dbbf6..bbdb7cc 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -377,7 +377,7 @@ export function ProfilePage() {
- @@ -2868,7 +2868,7 @@ function AccountHeader({ name, email, darkMode, onToggleTheme, onBack }: { name: