From d345ae7e088a09b2471d363ca5c0b24a276a5d92 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:28:40 +0800 Subject: [PATCH 1/2] release: prepare v1.2.44 --- .github/release-notes/v1.2.44.md | 5 ++ VERSION | 2 +- apps/web/src/pages/admin.tsx | 117 ++++++++++++++++++------------- apps/web/src/pages/profile.tsx | 17 ++++- 4 files changed, 90 insertions(+), 51 deletions(-) create mode 100644 .github/release-notes/v1.2.44.md diff --git a/.github/release-notes/v1.2.44.md b/.github/release-notes/v1.2.44.md new file mode 100644 index 0000000..a218f15 --- /dev/null +++ b/.github/release-notes/v1.2.44.md @@ -0,0 +1,5 @@ +- 修复刷新后台仪表盘时,注册、自助申请邮箱等功能在设置读取完成前短暂显示为关闭的问题。 +- 为后台统计、系统状态、域名与列表页面、备份设置和个人中心补充统一加载状态,避免将未加载数据误显示为 0、未配置或暂无数据。 +- 优化受限权限账号的状态展示:无权读取的系统设置明确显示为不可查看,不再误判为关闭或未配置。 + +**完整更新日志**:[v1.2.43...v1.2.44](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.43...v1.2.44) diff --git a/VERSION b/VERSION index 7b6476a..8a30807 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.43 +1.2.44 diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 8a14121..0205d65 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -17,6 +17,7 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" +import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Textarea } from "@/components/ui/textarea" import { ConfirmDialog } from "@/components/confirm-dialog" @@ -86,11 +87,13 @@ export function AdminPage() { const canMessagesView = hasPermission(user, "admin.messages.view") const canSettingsView = hasPermission(user, "admin.settings.view") const canTemplatesView = hasPermission(user, "admin.templates.view") + const canLoadDomains = canUsersView || canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView + const canLoadMailboxes = canMailboxesView || canMessagesView || canSettingsView const overview = useQuery({ queryKey: ["admin", "overview"], queryFn: api.adminOverview, enabled: !!user && canOverview }) const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users, enabled: !!user && (canUsersView || canMailboxesView) }) const permissionGroups = useQuery({ queryKey: ["admin", "permission-groups"], queryFn: api.permissionGroups, enabled: !!user && (canPermissionGroupsView || canUsersView) }) - const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && (canUsersView || canDomainsView || canDNSView || canMailboxesView || canAliasesView || canSettingsView || canTemplatesView) }) - const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && (canMailboxesView || canMessagesView || canSettingsView) }) + const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains, enabled: !!user && canLoadDomains }) + const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes, enabled: !!user && canLoadMailboxes }) const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView }) const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView }) const [params, setParams] = useSearchParams() @@ -104,14 +107,18 @@ export function AdminPage() { const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]) && (key !== "backups" || user?.role === "admin")) const rawSection = params.get("section") as Section | null const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview" - const sectionQuery = section === "overview" ? overview - : section === "users" ? users - : section === "permissionGroups" ? permissionGroups - : section === "domains" ? domains - : section === "mailboxes" ? mailboxes - : section === "aliases" ? aliases - : section === "settings" ? settings - : null + const sectionQueries = section === "overview" ? [overview, ...(canLoadDomains ? [domains] : []), ...(canSettingsView ? [settings] : [])] + : section === "users" ? [users, permissionGroups, domains] + : section === "permissionGroups" ? [permissionGroups] + : section === "domains" ? [domains] + : section === "mailboxes" ? [mailboxes, users, domains] + : section === "aliases" ? [aliases, domains] + : section === "messages" || section === "sendAudit" ? [mailboxes] + : section === "settings" ? [domains, ...(canLoadMailboxes ? [mailboxes] : []), ...(canSettingsView ? [settings] : [])] + : [] + const sectionLoading = sectionQueries.some((query) => query.isPending) + const sectionError = sectionQueries.find((query) => query.isError) + const sectionReady = !sectionLoading && !sectionError async function refreshAdminPage() { if (refreshing) return @@ -130,17 +137,18 @@ export function AdminPage() { } } - const overviewChecklist = setupChecklist(overview.data, domainItems, settings.data).filter((item) => visibleSections.includes(item.section)) + const overviewChecklist = sectionReady && section === "overview" ? setupChecklist(overview.data, domainItems, settings.data).filter((item) => visibleSections.includes(item.section)) : undefined const changeSection = (next: Section) => setParams(next === "overview" ? {} : { section: next }) return (
- + - {sectionQuery?.isError && { void sectionQuery.refetch() }} />} + {sectionError && { void Promise.all(sectionQueries.map((query) => query.refetch())) }} />} + {sectionLoading && } - {section === "overview" && canOverview && ( + {sectionReady && section === "overview" && canOverview && (
} 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 正常` : "待检测"} /> @@ -149,21 +157,32 @@ export function AdminPage() {
)} - {section === "overview" && } - {section === "users" && } - {section === "permissionGroups" && } - {section === "domains" && } - {section === "mailboxes" && } - {section === "aliases" && } - {section === "messages" && } - {section === "sendAudit" && } - {section === "backups" && } - {section === "settings" && } + {sectionReady && section === "overview" && } + {sectionReady && section === "users" && } + {sectionReady && section === "permissionGroups" && } + {sectionReady && section === "domains" && } + {sectionReady && section === "mailboxes" && } + {sectionReady && section === "aliases" && } + {sectionReady && section === "messages" && } + {sectionReady && section === "sendAudit" && } + {sectionReady && section === "backups" && } + {sectionReady && section === "settings" && }
) } +function AdminSectionLoading({ overview = false }: { overview?: boolean }) { + return ( +
+ {overview &&
{Array.from({ length: 4 }).map((_, index) => )}
} + + {overview && } + 加载中... +
+ ) +} + type SetupChecklistItem = ReturnType[number] function AdminPageHeader({ section, refreshing, onRefresh, checklist, onSectionChange }: { section: Section; refreshing: boolean; onRefresh: () => void; checklist?: SetupChecklistItem[]; onSectionChange: (section: Section) => void }) { @@ -215,7 +234,7 @@ function SetupChecklistDialog({ checklist, onSectionChange }: { checklist: Setup ) } -function OverviewSection({ overview, domains, settings, visibleSections, onSectionChange }: { overview?: AdminOverview; domains: Domain[]; settings?: SystemSettings; visibleSections: Section[]; onSectionChange: (section: Section) => void }) { +function OverviewSection({ overview, domains, domainsAvailable, settings, settingsAvailable, visibleSections, onSectionChange }: { overview?: AdminOverview; domains: Domain[]; domainsAvailable: boolean; settings?: SystemSettings; settingsAvailable: boolean; 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") @@ -242,22 +261,22 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti 系统健康
} /> - } /> - } /> + : } /> + : } />
服务信息
- copyOverviewValue(settings.publicBaseUrl, "公网地址", toast) : undefined} /> - copyOverviewValue(`${settings.smtpHost}:${settings.smtpPort}`, "SMTP 地址", toast) : undefined} /> + copyOverviewValue(settings.publicBaseUrl, "公网地址", toast) : undefined} /> + copyOverviewValue(`${settings.smtpHost}:${settings.smtpPort}`, "SMTP 地址", toast) : undefined} />
功能状态
- } /> - } /> + : } /> + : } />
@@ -266,7 +285,7 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti
域名状态{domains.length} 个域名
{visibleSections.includes("domains") && }
- {domains.length > 0 ?
+ {!domainsAvailable ? : domains.length > 0 ?
邮件域名使用状态DNS 状态最近检测
{domains.slice(0, 5).map((domain) => { const dnsDisplay = dnsStatusDisplay(domain.dnsStatus) @@ -528,6 +547,8 @@ function BackupsSection() { ;(groups[transfer.name] ||= []).push(transfer) return groups }, {})).sort((left, right) => Date.parse(right[0]?.startedAt || "") - Date.parse(left[0]?.startedAt || "")) + if (backups.isPending) return + if (backups.isError) return { void backups.refetch() }} compact /> return (
@@ -1542,23 +1563,23 @@ function SystemSettingsSection({ settings, domains, mailboxes, initialTab }: { s const requestedTab = initialTab as SettingsTab | undefined const [settingsTab, setSettingsTab] = React.useState(() => 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 [smtpRequireTls, setSmtpRequireTls] = React.useState(false) - const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true) - const [openRegistration, setOpenRegistration] = React.useState(false) - const [twoFactorEnabled, setTwoFactorEnabled] = React.useState(false) - const [turnstileEnabled, setTurnstileEnabled] = React.useState(false) - const [catchAllEnabled, setCatchAllEnabled] = React.useState(false) - const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true) - const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(false) - const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState([]) - const [externalImapEnabled, setExternalImapEnabled] = React.useState(false) - const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(false) - const [telegramMailEnabled, setTelegramMailEnabled] = React.useState(false) + const [smtpRequireTls, setSmtpRequireTls] = React.useState(() => settings?.smtpRequireTls ?? false) + const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(() => settings?.allowInsecureHttp ?? true) + const [openRegistration, setOpenRegistration] = React.useState(() => settings?.openRegistration ?? false) + const [twoFactorEnabled, setTwoFactorEnabled] = React.useState(() => settings?.twoFactorEnabled ?? false) + const [turnstileEnabled, setTurnstileEnabled] = React.useState(() => settings?.turnstileEnabled ?? false) + const [catchAllEnabled, setCatchAllEnabled] = React.useState(() => settings?.catchAllEnabled ?? false) + const [mailAutoRefresh, setMailAutoRefresh] = React.useState(() => settings?.mailAutoRefresh ?? true) + const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(() => settings?.userMailboxApplyEnabled ?? false) + const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState(() => settings?.userMailboxDomainIds || []) + const [externalImapEnabled, setExternalImapEnabled] = React.useState(() => settings?.externalImapEnabled ?? false) + const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(() => settings?.externalImapAllowPrivateHosts ?? false) + const [telegramMailEnabled, setTelegramMailEnabled] = React.useState(() => settings?.telegramMailEnabled ?? false) const [telegramBotToken, setTelegramBotToken] = React.useState("") - const [telegramPrivateChatId, setTelegramPrivateChatId] = React.useState("") - const [telegramBodyMode, setTelegramBodyMode] = React.useState<"summary" | "full">("summary") - const [telegramMailboxIds, setTelegramMailboxIds] = React.useState([]) - const [telegramIncludeUnregistered, setTelegramIncludeUnregistered] = React.useState(false) + const [telegramPrivateChatId, setTelegramPrivateChatId] = React.useState(() => settings?.telegramPrivateChatId || "") + const [telegramBodyMode, setTelegramBodyMode] = React.useState<"summary" | "full">(() => settings?.telegramBodyMode === "full" ? "full" : "summary") + const [telegramMailboxIds, setTelegramMailboxIds] = React.useState(() => settings?.telegramMailboxIds || []) + const [telegramIncludeUnregistered, setTelegramIncludeUnregistered] = React.useState(() => settings?.telegramIncludeUnregistered ?? false) const [telegramPairing, setTelegramPairing] = React.useState(null) React.useEffect(() => { if (!settings) return diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index bbdb7cc..7789e28 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -26,13 +26,14 @@ import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/s import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select" import { Separator } from "@/components/ui/separator" import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" import { ConfirmDialog } from "@/components/confirm-dialog" import { useToast } from "@/hooks/use-toast" type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "cleanupQueue" | "rules" | "blocked" | "stats" | "apiTokens" type AccountSettingsTab = "account" | "mail" | "clients" | "security" type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void } -type RetryableQuery = { isError: boolean; error: Error | null; refetch: () => Promise } +type RetryableQuery = { isLoading: boolean; isError: boolean; error: Error | null; refetch: () => Promise } const tabs: Record = { profile: { label: "账号设置", icon: }, mailboxes: { label: "邮箱管理", icon: }, @@ -434,7 +435,8 @@ export function ProfilePage() {
) function renderTab() { - const failedQueries = visibleTabQueries().filter((query) => query.isError && query.error) + const visibleQueries = visibleTabQueries() + const failedQueries = visibleQueries.filter((query) => query.isError && query.error) if (failedQueries.length > 0) { return ( ) } + if (visibleQueries.some((query) => query.isLoading)) return if (tab === "profile") return ( + + + 加载中... +
+ ) +} + function contentFrameClass(tab: Tab) { return cn( "w-full", From 75c399e178d2834f0fa3c59af0bfd92bf188d6e0 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:14:56 +0800 Subject: [PATCH 2/2] refine DNS record guidance --- apps/web/src/pages/admin.tsx | 131 +++++++++++++++++++++++++++-------- 1 file changed, 103 insertions(+), 28 deletions(-) diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 0205d65..01fafb8 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -1128,7 +1128,7 @@ function DomainDNSDialog({ domain }: { domain: Domain }) { - + {domain.name} DNS @@ -2590,27 +2590,77 @@ function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: bo const check = useMutation({ mutationFn: () => api.checkDns(domain!.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) }, onError: (error) => toast({ title: "DNS 检测失败", description: error.message }) }) if (!domain) return 请选择域名 const content = <> -

以下为需要在域名 DNS 管理中添加的记录:

- {records.isError ? { void records.refetch() }} compact /> :
{records.data?.items.map((r) => )}
} - {check.data && <> - -
检测结果
-
{Object.entries(check.data.checks).map(([k, v]) => )}
- } - const checkButton = canCheckDNS ? : null +

以下内容可直接填写到常见 DNS 控制台,根域名的主机记录使用 @。

+ {check.isPending && } + {check.isError && } + {check.data && !check.isPending && } + {records.isError ? { void records.refetch() }} compact /> :
{records.data?.items.map((r) => )}
} + + const checkButton = canCheckDNS ? : null const header =
DNS 记录{checkButton}
- if (embedded) return
DNS 记录
{checkButton}
{content}
+ if (embedded) return
DNS 记录
{checkButton}
{content}
return {header}{content} } +const dnsCheckMeta: Record = { + mx: { label: "MX", description: "收信地址" }, + spf: { label: "SPF", description: "发信授权" }, + dkim: { label: "DKIM", description: "邮件签名" }, + dmarc: { label: "DMARC", description: "防伪策略" }, +} + +function DNSCheckPending() { + return
+
+
正在检测 DNS

正在查询最新解析结果,请稍候...

+
+} + +function DNSCheckFailure({ error }: { error: Error }) { + return
+
+
DNS 检测失败

{error.message || "暂时无法查询 DNS,请稍后重新检测。"}

+
+} + +function DNSCheckSummary({ checks }: { checks: Record }) { + const entries = Object.entries(checks).sort(([left], [right]) => { + const order = ["mx", "spf", "dkim", "dmarc"] + return order.indexOf(left) - order.indexOf(right) + }) + const passed = entries.filter(([, item]) => item.ok).length + const allPassed = entries.length > 0 && passed === entries.length + return
+
+
+ {allPassed ? : } +
+
+
+

{allPassed ? "DNS 配置全部通过" : "DNS 配置需要处理"}

+ {passed}/{entries.length} 项通过 +
+

{allPassed ? "所有邮件相关记录均已正确解析,可以正常使用。" : "请处理下面标红的项目,修改 DNS 后再重新检测。"}

+
+
+
+ {entries.map(([name, item]) => )} +
+
+} + function DNSCheckRow({ name, check }: { name: string; check: { ok: boolean; message: string; found?: string[] } }) { const visibleRecords = check.found?.filter(Boolean) ?? [] - return
-
- -
{name.toUpperCase()}: {check.message}
+ const meta = dnsCheckMeta[name.toLowerCase()] || { label: name.toUpperCase(), description: "DNS 记录" } + return
+
+ {check.ok ? : } +
+
{meta.label}({meta.description})
+
{check.message}
+
- {!check.ok && visibleRecords.length > 0 &&
+ {!check.ok && visibleRecords.length > 0 &&
当前解析
{visibleRecords.map((record, index) =>
{record}
)}
} @@ -2621,36 +2671,61 @@ function dnsDescription(record: DNSRecord): string { if (record.type === "TXT" && record.name.startsWith("_dmarc")) return "声明域名的 DMARC 策略(如何处理未通过 SPF/DKIM 验证的邮件)。" if (record.type === "TXT" && record.value.includes("DKIM1")) return "DKIM 公钥。收件服务器用此密钥验证邮件是否由你发出。" if (record.type === "TXT" && record.value.includes("spf1")) return "声明哪些服务器有权使用你的域名发件,防止伪造。" - if (record.type === "MX") return `确保 ${record.name} 的 A 记录已指向你的服务器 IP,邮件才能到达。` + if (record.type === "MX") { + const mx = mxRecordParts(record.value) + return `邮件由 ${mx.target} 接收,请确保它的 A 记录指向服务器 IP。优先级 ${mx.priority},数值越小越优先。` + } return "" } -function DNSRecordRow({ record }: { record: DNSRecord }) { +function mxRecordParts(value: string) { + const [rawPriority = "10", ...rawTarget] = value.trim().split(/\s+/) + const priority = /^\d+$/.test(rawPriority) ? rawPriority : "10" + const target = (rawTarget.length > 0 ? rawTarget.join(" ") : value).replace(/\.$/, "") + return { priority, target } +} + +function dnsHostForProvider(recordName: string, domainName: string) { + const name = recordName.replace(/\.$/, "") + const domain = domainName.replace(/\.$/, "") + if (name.toLowerCase() === domain.toLowerCase()) return "@" + const suffix = `.${domain}` + if (name.toLowerCase().endsWith(suffix.toLowerCase())) return name.slice(0, -suffix.length) + return name +} + +function DNSRecordRow({ record, domainName }: { record: DNSRecord; domainName: string }) { const { toast } = useToast() const desc = dnsDescription(record) + const longValue = record.value.length > 180 + const mx = record.type === "MX" ? mxRecordParts(record.value) : null + const displayHost = dnsHostForProvider(record.name, domainName) + const displayValue = mx?.target || record.value + const [valueExpanded, setValueExpanded] = React.useState(false) async function copyField(label: string, value: string) { await navigator.clipboard.writeText(value) toast({ title: `${label}已复制` }) } - return
-
+ return
+
{record.type} + {longValue && }
- {desc &&

{desc}

} -
+ {desc &&

{desc}

} +
主机记录 - {record.name} - + {displayHost} +
记录值 - {record.value} - + {displayValue} +
-
- TTL - {record.ttl} 秒 +
+ {mx &&
优先级{mx.priority}
} +
TTL{record.ttl} 秒