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..01fafb8 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 (
@@ -1107,7 +1128,7 @@ function DomainDNSDialog({ domain }: { domain: Domain }) {
-
+
{domain.name} DNS
@@ -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
@@ -2569,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
+ if (embedded) return
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 ? "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}
)}
}
@@ -2600,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} 秒
diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx
index 30b0081..7835832 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",