release: prepare v1.2.32

This commit is contained in:
zxyszx
2026-08-12 16:15:19 +08:00
parent 9a572e0100
commit fc3a3462cf
18 changed files with 2463 additions and 16 deletions
+3
View File
@@ -38,6 +38,8 @@ type App struct {
telegramPairMu sync.Mutex
telegramPairs map[string]telegramPairing
telegramDeliveryMu sync.Mutex
backupMu sync.Mutex
backupJob *backupJob
}
const (
@@ -130,6 +132,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
a.startWorker(func() { a.telegramMailWorker(workerCtx) })
a.startWorker(func() { a.backupScheduleWorker(workerCtx) })
return a, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
package app
import (
"context"
"encoding/json"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
server := httptest.NewServer(a.Router())
defer server.Close()
admin := &testClient{t: t, server: server}
var response map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &response); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, response)
}
response = nil
if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
t.Fatalf("manual backup mismatch code=%d body=%v", code, response)
}
response = nil
if code := admin.do("POST", "/api/admin/backups/settings", map[string]any{"enabled": false, "days": 7, "password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
t.Fatalf("scheduled backup mismatch code=%d body=%v", code, response)
}
}
func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) {
telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"ok":true,"result":[`+
`{"update_id":1,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
`{"update_id":2,"message":{"text":"/newszxcn ABC123","chat":{"id":-1002,"type":"group","title":"异地备份"}}},`+
`{"update_id":3,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
`{"update_id":4,"message":{"text":"/newszxcn WRONG","chat":{"id":-1003,"type":"group","title":"无关群组"}}}]}`)
}))
defer telegramServer.Close()
a := newTestApp(t)
stopTestWorkers(a)
a.telegramURL = telegramServer.URL
groups, err := a.discoverTelegramGroups(context.Background(), "test-token", "ABC123")
if err != nil {
t.Fatal(err)
}
if len(groups) != 2 || groups[0].ChatID != "-1001" || groups[1].ChatID != "-1002" {
t.Fatalf("unexpected groups: %+v", groups)
}
}
func TestGoogleDriveUploadRequestUsesMultipartRelated(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
t.Fatal(err)
}
req, err := newGoogleDriveUploadRequest(context.Background(), path, "folder-123")
if err != nil {
t.Fatal(err)
}
mediaType, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
if err != nil || mediaType != "multipart/related" || params["boundary"] == "" {
t.Fatalf("content type = %q, %v", req.Header.Get("Content-Type"), err)
}
reader := multipart.NewReader(req.Body, params["boundary"])
metadataPart, err := reader.NextPart()
if err != nil {
t.Fatal(err)
}
var metadata struct {
Name string `json:"name"`
Parents []string `json:"parents"`
}
if err := json.NewDecoder(metadataPart).Decode(&metadata); err != nil {
t.Fatal(err)
}
if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
t.Fatalf("metadata = %+v", metadata)
}
filePart, err := reader.NextPart()
if err != nil {
t.Fatal(err)
}
raw, err := io.ReadAll(filePart)
if err != nil || string(raw) != "encrypted backup" {
t.Fatalf("uploaded bytes = %q, %v", raw, err)
}
}
func TestBackupEncryptionRequiresDeploymentSecret(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", AllowInsecureHTTP: true,
})
if _, err := a.encryptBackupPassword("BackupPassword123!"); err == nil {
t.Fatal("backup password encryption succeeded without a deployment secret")
}
}
func TestBackupPasswordValidation(t *testing.T) {
for _, valid := range []string{"12345678", "Restore Password 123!"} {
if !validBackupPassword(valid) {
t.Errorf("valid password rejected: %q", valid)
}
}
for _, invalid := range []string{"1234567", "password\nvalue", "password\x00value", strings.Repeat("x", 1025)} {
if validBackupPassword(invalid) {
t.Errorf("invalid password accepted: %q", invalid)
}
}
}
func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0", AppVersion: "v1.2.31", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@newszxcn.com", AdminPassword: "ChangeMe123!",
PublicHostname: "mail.newszxcn.com", PublicBaseURL: "https://mail.newszxcn.com", AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
})
ciphertext, err := a.encryptBackupPassword("BackupPassword123!")
if err != nil || ciphertext == "BackupPassword123!" {
t.Fatalf("password encryption failed: %q %v", ciphertext, err)
}
plain, err := a.decryptBackupPassword(ciphertext)
if err != nil || plain != "BackupPassword123!" {
t.Fatalf("password decryption = %q, %v", plain, err)
}
if !validTelegramPrivateChatID("-1001234567890") {
t.Fatal("private Telegram group chat ID was rejected")
}
now := a.now().UTC().Format("2006-01-02T15:04:05Z")
if _, err := a.db.Exec(`INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at) VALUES('domain_xyes','xyes.me','active','mail','','','unchecked',?,?)`, now, now); err != nil {
t.Fatal(err)
}
if _, err := a.db.Exec(`INSERT INTO users(id,login_name,email,display_name,role,password_hash,created_at,updated_at) VALUES('user_xyes','user@xyes.me','user@xyes.me','User','user','hash',?,?)`, now, now); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc")
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
report, err := a.backupTelegramReport(context.Background(), path, info)
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"备份成功", "mail.newszxcn.com", "已有域名", "newszxcn.com", "xyes.me", "管理员账号", "admin@newszxcn.com", "普通用户账号", "user@xyes.me", "请不要解压", "本地上传", "1Password"} {
if !strings.Contains(report, expected) {
t.Errorf("report missing %q: %s", expected, report)
}
}
if strings.Contains(report, "newszxcn.com(管理员)") {
t.Fatal("domain list incorrectly contains account role")
}
if strings.Contains(report, "BackupPassword123!") || strings.Contains(report, "ChangeMe123!") {
t.Fatal("report leaked a password")
}
}
+4
View File
@@ -67,6 +67,8 @@ type Config struct {
ReleaseAPIURL string
UpdateServiceURL string
UpdateServiceToken string
BackupSourceDir string
BackupDir string
}
func LoadConfig() Config {
@@ -131,6 +133,8 @@ func LoadConfig() Config {
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
BackupSourceDir: getenv("LANQIN_BACKUP_SOURCE_DIR", ""),
BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")),
}
}
+13
View File
@@ -139,6 +139,19 @@ func (a *App) Router() http.Handler {
r.Use(a.requireAdminAccess)
r.Get("/admin/system/version", a.handleSystemVersion)
r.Post("/admin/system/update", a.handleSystemUpdate)
r.Get("/admin/backups", a.handleListBackups)
r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram)
r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup)
r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect)
r.Get("/admin/backups/google-drive/callback", a.handleGoogleDriveCallback)
r.Delete("/admin/backups/google-drive", a.handleGoogleDriveDisconnect)
r.Post("/admin/backups", a.handleCreateBackup)
r.Get("/admin/backups/{name}/download", a.handleDownloadBackup)
r.Post("/admin/backups/{name}/verify", a.handleVerifyBackup)
r.Post("/admin/backups/{name}/telegram", a.handleSendBackupTelegram)
r.Post("/admin/backups/{name}/google-drive", a.handleSendBackupGoogleDrive)
r.Delete("/admin/backups/{name}", a.handleDeleteBackup)
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
+46 -1
View File
@@ -71,6 +71,7 @@ type telegramUpdate struct {
Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
@@ -112,7 +113,7 @@ func normalizeTelegramBodyMode(value string) string {
func validTelegramPrivateChatID(value string) bool {
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return err == nil && id > 0
return err == nil && id != 0
}
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
@@ -259,6 +260,50 @@ func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCod
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
}
type telegramDiscoveredChat struct {
ChatID string `json:"chatId"`
DisplayName string `json:"displayName"`
}
func (a *App) discoverTelegramGroups(ctx context.Context, token, pairingCode string) ([]telegramDiscoveredChat, error) {
var updates []telegramUpdate
if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{
"limit": 100, "timeout": 0, "allowed_updates": []string{"message"},
}, &updates); err != nil {
return nil, err
}
found := make([]telegramDiscoveredChat, 0)
seen := make(map[int64]bool)
for i := len(updates) - 1; i >= 0; i-- {
message := updates[i].Message
if message == nil || (message.Chat.Type != "group" && message.Chat.Type != "supergroup") || message.Chat.ID >= 0 {
continue
}
text := strings.TrimSpace(message.Text)
fields := strings.Fields(text)
matches := strings.EqualFold(text, pairingCode)
if len(fields) == 2 && strings.HasPrefix(strings.ToLower(fields[0]), "/newszxcn") {
matches = strings.EqualFold(fields[1], pairingCode)
}
if !matches {
continue
}
if seen[message.Chat.ID] {
continue
}
seen[message.Chat.ID] = true
name := strings.TrimSpace(message.Chat.Title)
if name == "" {
name = "Telegram 群组"
}
found = append(found, telegramDiscoveredChat{ChatID: strconv.FormatInt(message.Chat.ID, 10), DisplayName: name})
}
if len(found) == 0 {
return nil, errors.New("未找到匹配的群组,请确认机器人已加入群组,并在群里发送查询命令")
}
return found, nil
}
func newTelegramPairingCode() (string, error) {
raw := make([]byte, 6)
if _, err := rand.Read(raw); err != nil {
+3 -2
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import { Outlet, Link, useLocation } from "react-router-dom"
import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
import { ArchiveRestore, BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, 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"
@@ -35,6 +35,7 @@ const adminSections: { key: string; label: string; icon: React.ReactNode; permis
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
{ key: "backups", label: "备份与恢复", icon: <ArchiveRestore />, permissions: ["admin.settings.view"] },
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
]
@@ -56,7 +57,7 @@ function ProtectedContent() {
const isProfileRoute = location.pathname.startsWith("/profile")
const isAdminRoute = location.pathname.startsWith("/admin")
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions) && (item.key !== "backups" || user.role === "admin"))
if (isMailRoute || isProfileRoute) {
return <Outlet />
+5
View File
@@ -202,6 +202,11 @@ export type SystemUpdateResult = {
targetVersion: string
message: string
}
export type BackupItem = { name: string; size: number; createdAt: string; sha256?: string }
export type BackupJob = { status: "running" | "success" | "failed"; startedAt: string; error?: string }
export type BackupSchedule = { enabled: boolean; days: number; passwordSet: boolean; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean }
export type GoogleDriveBackupStatus = { clientId: string; clientSecretSet: boolean; connected: boolean; folderName: string }
export type BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus }
export type SystemSettings = {
publicHostname: string
publicBaseUrl: string
+12 -1
View File
@@ -1,4 +1,4 @@
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types"
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, BackupList, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken, TwoFactorEnableResponse, BulkMoveResult, TelegramPrivateChat, TelegramPairing } from "./api-types"
export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000
@@ -211,6 +211,17 @@ export const api = {
},
systemVersion: () => request<SystemVersion>("/api/admin/system/version"),
updateSystem: () => request<SystemUpdateResult>("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }),
backups: () => request<BackupList>("/api/admin/backups"),
createBackup: (password: string, confirmPassword: string, sendTelegram: boolean, uploadGoogleDrive: boolean) => request<{ ok: boolean; message: string }>("/api/admin/backups", { method: "POST", body: JSON.stringify({ password, confirmPassword, sendTelegram, uploadGoogleDrive }) }),
updateBackupSettings: (payload: { enabled: boolean; days: number; password: string; confirmPassword: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean; googleClientId: string; googleClientSecret: string; googleFolderName: string }) => request<import("./api-types").BackupSchedule>("/api/admin/backups/settings", { method: "POST", body: JSON.stringify(payload) }),
testBackupTelegram: (payload: { mode: "system" | "custom"; chatId: string }) => request<{ ok: boolean }>("/api/admin/backups/telegram/test", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
discoverBackupTelegramGroup: (pairingCode: string) => request<{ items: TelegramPrivateChat[] }>("/api/admin/backups/telegram/discover-group", { method: "POST", body: JSON.stringify({ pairingCode }) }),
connectGoogleDrive: () => request<{ url: string }>("/api/admin/backups/google-drive/connect", { method: "POST" }),
disconnectGoogleDrive: () => request<{ ok: boolean }>("/api/admin/backups/google-drive", { method: "DELETE" }),
verifyBackup: (name: string) => request<{ ok: boolean; sha256: string }>(`/api/admin/backups/${encodeURIComponent(name)}/verify`, { method: "POST", timeoutMs: 60_000 }),
sendBackupTelegram: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/telegram`, { method: "POST", timeoutMs: 10 * 60_000 }),
sendBackupGoogleDrive: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}/google-drive`, { method: "POST", timeoutMs: 30 * 60_000 }),
deleteBackup: (name: string) => request<{ ok: boolean }>(`/api/admin/backups/${encodeURIComponent(name)}`, { method: "DELETE" }),
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
+326 -4
View File
@@ -2,7 +2,7 @@ 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 { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Copy, ExternalLink, Github, Globe2, Mail, Mailbox, MoreHorizontal, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
import { ArrowRight, 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 { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -26,7 +26,7 @@ import { useToast } from "@/hooks/use-toast"
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
import type { PermissionKey, TelegramPairing } from "@/lib/api-types"
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "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 PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
@@ -39,6 +39,7 @@ const sectionMeta: Record<Section, { label: string; frontLabel: string; descript
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" },
sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" },
backups: { label: "备份与恢复", frontLabel: "数据保护", description: "创建、校验和下载可迁移的加密完整备份。" },
settings: { label: "系统设置", frontLabel: "账号设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" },
}
const sectionLabels = Object.fromEntries(Object.entries(sectionMeta).map(([key, value]) => [key, value.label])) as Record<Section, string>
@@ -52,6 +53,7 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
aliases: ["admin.aliases.view"],
messages: ["admin.messages.view"],
sendAudit: ["admin.messages.view"],
backups: ["admin.settings.view"],
settings: ["admin.settings.view", "admin.templates.view"],
}
const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
@@ -102,7 +104,7 @@ export function AdminPage() {
const aliasItems = aliases.data?.items || []
const userItems = users.data?.items || []
const assignablePermissionGroups = (permissionGroups.data?.items || []).filter((group) => group.id !== superAdminPermissionGroupId && group.id !== regularUserPermissionGroupId)
const visibleSections = sectionKeys.filter((key) => hasAnyPermission(user, sectionPermissions[key]))
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
@@ -155,6 +157,7 @@ export function AdminPage() {
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
{section === "backups" && <BackupsSection />}
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} mailboxes={mailboxItems} initialTab={params.get("settingsTab")} />}
</main>
</ScrollArea>
@@ -255,6 +258,321 @@ function InfoLine({ label, value }: { label: string; value: React.ReactNode }) {
return <div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"><span>{label}</span><span className="min-w-0 truncate font-medium text-foreground">{value}</span></div>
}
function generateBackupPassword(length = 24) {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%"
const values = new Uint32Array(length)
crypto.getRandomValues(values)
return Array.from(values, (value) => alphabet[value % alphabet.length]).join("")
}
function BackupsSection() {
const qc = useQueryClient()
const { toast } = useToast()
const backups = useQuery({
queryKey: ["admin", "backups"],
queryFn: api.backups,
refetchInterval: (query) => query.state.data?.job?.status === "running" ? 2000 : false,
})
const [createOpen, setCreateOpen] = React.useState(false)
const [password, setPassword] = React.useState("")
const [confirmPassword, setConfirmPassword] = React.useState("")
const [showCreatePassword, setShowCreatePassword] = React.useState(false)
const [sendAfterCreate, setSendAfterCreate] = React.useState(true)
const [driveAfterCreate, setDriveAfterCreate] = React.useState(false)
const [deleteName, setDeleteName] = React.useState("")
const [scheduleEnabled, setScheduleEnabled] = React.useState(false)
const [scheduleDays, setScheduleDays] = React.useState("7")
const [customDays, setCustomDays] = React.useState("14")
const [schedulePassword, setSchedulePassword] = React.useState("")
const [scheduleConfirmPassword, setScheduleConfirmPassword] = React.useState("")
const [showSchedulePassword, setShowSchedulePassword] = React.useState(false)
const [serverIp, setServerIp] = React.useState("")
const [backupChatId, setBackupChatId] = React.useState("")
const [telegramMode, setTelegramMode] = React.useState<"system" | "custom">("system")
const [telegramEnabled, setTelegramEnabled] = React.useState(true)
const [googleDriveEnabled, setGoogleDriveEnabled] = React.useState(false)
const [googleClientId, setGoogleClientId] = React.useState("")
const [googleClientSecret, setGoogleClientSecret] = React.useState("")
const [googleFolderName, setGoogleFolderName] = React.useState("NewSzxcn Backups")
const [telegramConfigOpen, setTelegramConfigOpen] = React.useState(false)
const [backupGroupPairing, setBackupGroupPairing] = React.useState<TelegramPairing | null>(null)
const [discoveredBackupGroups, setDiscoveredBackupGroups] = React.useState<{ chatId: string; displayName: string }[]>([])
const [googleConfigOpen, setGoogleConfigOpen] = React.useState(false)
React.useEffect(() => {
if (!backups.data) return
const days = backups.data.schedule.days || 7
setScheduleEnabled(backups.data.schedule.enabled)
setScheduleDays([3, 5, 7, 30].includes(days) ? String(days) : "custom")
setCustomDays(String(days))
setServerIp(backups.data.schedule.serverIp || "")
setBackupChatId(backups.data.schedule.chatId || "")
setTelegramMode(backups.data.schedule.telegramMode === "custom" ? "custom" : "system")
setTelegramEnabled(backups.data.schedule.telegramEnabled)
setGoogleDriveEnabled(backups.data.schedule.googleDriveEnabled)
setGoogleClientId(backups.data.googleDrive.clientId || "")
setGoogleFolderName(backups.data.googleDrive.folderName || "NewSzxcn Backups")
setDriveAfterCreate(backups.data.googleDrive.connected)
setSendAfterCreate(backups.data.telegramSet)
}, [backups.data])
React.useEffect(() => {
const drive = new URLSearchParams(window.location.search).get("drive")
if (!drive) return
toast({ title: drive === "connected" ? "Google 云端硬盘已连接" : "Google 授权未完成" })
window.history.replaceState({}, "", "/admin?section=backups")
}, [toast])
const create = useMutation({
mutationFn: () => api.createBackup(password, confirmPassword, sendAfterCreate, driveAfterCreate),
onSuccess: async () => {
setCreateOpen(false); setPassword(""); setConfirmPassword("")
await qc.invalidateQueries({ queryKey: ["admin", "backups"] })
toast({ title: "备份任务已开始", description: "可以留在此页面查看进度。" })
},
onError: (error) => toast({ title: "无法创建备份", description: error instanceof Error ? error.message : "请稍后重试" }),
})
const saveSchedule = useMutation({
mutationFn: () => api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: schedulePassword, confirmPassword: scheduleConfirmPassword, serverIp, chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled, googleClientId, googleClientSecret, googleFolderName }),
onSuccess: async () => { setSchedulePassword(""); setScheduleConfirmPassword(""); setGoogleClientSecret(""); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "备份设置已保存" }) },
onError: (error) => toast({ title: "保存失败", description: error instanceof Error ? error.message : "请稍后重试" }),
})
const verify = useMutation({
mutationFn: api.verifyBackup,
onSuccess: (result) => toast({ title: result.ok ? "备份校验通过" : "备份校验失败", description: result.ok ? `SHA-256${result.sha256.slice(0, 16)}...` : "文件可能已损坏,请勿用于恢复。" }),
onError: (error) => toast({ title: "校验失败", description: error instanceof Error ? error.message : "请稍后重试" }),
})
const sendTelegram = useMutation({
mutationFn: api.sendBackupTelegram,
onSuccess: () => toast({ title: "已发送到 Telegram" }),
onError: (error) => toast({ title: "发送失败", description: error instanceof Error ? error.message : "请稍后重试" }),
})
const testBackupTelegram = useMutation({
mutationFn: () => api.testBackupTelegram({ mode: telegramMode, chatId: backupChatId }),
onSuccess: () => toast({ title: "Telegram 测试通知已发送" }),
onError: (error) => toast({ title: "测试失败", description: error instanceof Error ? error.message : "请检查机器人和 Chat ID" }),
})
const createBackupGroupPairing = useMutation({
mutationFn: () => api.createTelegramPairing(""),
onSuccess: (pairing) => { setBackupGroupPairing(pairing); setDiscoveredBackupGroups([]); toast({ title: "群组查询码已生成" }) },
onError: (error) => toast({ title: "无法生成查询码", description: error instanceof Error ? error.message : "请先绑定 Telegram 机器人" }),
})
const discoverBackupGroup = useMutation({
mutationFn: () => api.discoverBackupTelegramGroup(backupGroupPairing?.code || ""),
onSuccess: ({ items }) => {
setDiscoveredBackupGroups(items)
if (items.length === 1) setBackupChatId(items[0].chatId)
toast({ title: `找到 ${items.length} 个群组`, description: items.length === 1 ? "已自动选中" : "请选择备份群组" })
},
onError: (error) => toast({ title: "未找到群组", description: error instanceof Error ? error.message : "请在群组发送查询命令后重试" }),
})
const sendDrive = useMutation({
mutationFn: api.sendBackupGoogleDrive,
onSuccess: () => toast({ title: "已上传到 Google 云端硬盘" }),
onError: (error) => toast({ title: "上传失败", description: error instanceof Error ? error.message : "请稍后重试" }),
})
const connectDrive = useMutation({
mutationFn: async () => {
await api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: schedulePassword, confirmPassword: scheduleConfirmPassword, serverIp, chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled: false, googleClientId, googleClientSecret, googleFolderName })
return api.connectGoogleDrive()
},
onSuccess: ({ url }) => { window.location.href = url },
onError: (error) => toast({ title: "无法连接 Google 云端硬盘", description: error instanceof Error ? error.message : "请检查 OAuth 配置" }),
})
const disconnectDrive = useMutation({
mutationFn: api.disconnectGoogleDrive,
onSuccess: async () => { setGoogleDriveEnabled(false); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "已断开 Google 云端硬盘" }) },
})
const remove = useMutation({
mutationFn: api.deleteBackup,
onSuccess: async () => { setDeleteName(""); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "备份已删除" }) },
onError: (error) => toast({ title: "删除失败", description: error instanceof Error ? error.message : "请稍后重试" }),
})
const job = backups.data?.job
const canCreate = backups.data?.enabled && job?.status !== "running"
function submitCreate() {
if (password.length < 8) { toast({ title: "密码至少需要 8 个字符" }); return }
if (password !== confirmPassword) { toast({ title: "两次输入的密码不一致" }); return }
create.mutate()
}
function generateCreatePassword() {
const generated = generateBackupPassword()
setPassword(generated)
setConfirmPassword(generated)
setShowCreatePassword(true)
toast({ title: "已生成 24 位备份密码", description: "请将密码保存到密码管理器,恢复时必须使用。" })
}
function generateSchedulePassword() {
const generated = generateBackupPassword()
setSchedulePassword(generated)
setScheduleConfirmPassword(generated)
setShowSchedulePassword(true)
toast({ title: "已生成 24 位备份密码", description: "保存设置前,请先将密码存入密码管理器。" })
}
async function copyBackupPassword(value: string) {
if (!value) return
await navigator.clipboard.writeText(value)
toast({ title: "备份密码已复制" })
}
function downloadBackupPassword(value: string) {
if (!value) return
const createdAt = new Date().toLocaleString("zh-CN", { hour12: false })
const content = `NewSzxcn Email 备份恢复密码\n\n密码:${value}\n生成时间:${createdAt}\n\n请妥善保管。恢复备份时必须输入此密码,系统无法找回。\n`
const url = URL.createObjectURL(new Blob([content], { type: "text/plain;charset=utf-8" }))
const link = document.createElement("a")
link.href = url
link.download = `newszxcn-backup-password-${new Date().toISOString().slice(0, 10)}.txt`
link.click()
URL.revokeObjectURL(url)
toast({ title: "密码文本已下载", description: "请导入密码管理器,不要与备份文件存放在一起。" })
}
function PasswordTools({ value, visible, onVisibleChange, onGenerate }: { value: string; visible: boolean; onVisibleChange: (visible: boolean) => void; onGenerate: () => void }) {
return <div className="flex items-center gap-0.5">
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" title="生成 24 位密码" aria-label="生成 24 位密码" onClick={onGenerate}><KeyRound className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" title={visible ? "隐藏密码" : "查看密码"} aria-label={visible ? "隐藏密码" : "查看密码"} disabled={!value} onClick={() => onVisibleChange(!visible)}>{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" title="复制密码" aria-label="复制密码" disabled={!value} onClick={() => copyBackupPassword(value)}><Copy className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" title="保存密码文件" aria-label="保存密码文件" disabled={!value} onClick={() => downloadBackupPassword(value)}><Download className="h-4 w-4" /></Button>
</div>
}
function submitSchedule() {
if (schedulePassword && schedulePassword.length < 8) { toast({ title: "备份密码至少需要 8 个字符" }); return }
if (schedulePassword !== scheduleConfirmPassword) { toast({ title: "两次输入的备份密码不一致" }); return }
if (scheduleEnabled && !schedulePassword && !backups.data?.schedule.passwordSet) { toast({ title: "请设置并确认备份密码" }); return }
saveSchedule.mutate()
}
return (
<div className="space-y-3">
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,.65fr)]">
<Card>
<CardHeader className="flex-row items-center justify-between gap-3 space-y-0 pb-3">
<div><CardTitle></CardTitle><p className="mt-1 text-sm text-muted-foreground">DKIM</p></div>
<Button type="button" disabled={!canCreate} onClick={() => setCreateOpen(true)}>
{job?.status === "running" ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <HardDrive className="mr-2 h-4 w-4" />}
{job?.status === "running" ? "生成中" : "创建备份"}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{!backups.data?.enabled && <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"></div>}
{job?.status === "failed" && <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{job.error || "备份生成失败"}</div>}
{job?.status === "success" && <div className="rounded-md border border-green-300 bg-green-50 px-3 py-2 text-sm text-green-800"></div>}
{!job && <p className="text-sm text-muted-foreground"></p>}
<div className="border-t pt-3">
<div className="mb-2 flex items-center justify-between"><span className="text-sm font-medium"></span><span className="text-xs text-muted-foreground"> 10 </span></div>
<div className="divide-y rounded-md border">
{(backups.data?.items || []).slice(0, 4).map((item) => (
<div key={item.name} className="flex items-center gap-2 px-3 py-2">
<div className="min-w-0 flex-1"><div className="truncate text-sm font-medium" title={item.name}>{item.name}</div><div className="text-xs text-muted-foreground">{formatDate(item.createdAt)} · {formatBytes(item.size)}</div></div>
<Button type="button" variant="ghost" size="icon" title="校验备份" disabled={verify.isPending} onClick={() => verify.mutate(item.name)}><ShieldCheck className="h-4 w-4" /></Button>
<DropdownMenu><DropdownMenuTrigger asChild><Button type="button" variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">
<DropdownMenuItem disabled={!backups.data?.telegramSet || item.size > (backups.data?.telegramLimit || 0)} onClick={() => sendTelegram.mutate(item.name)}><Send className="mr-2 h-4 w-4" /> Telegram</DropdownMenuItem>
<DropdownMenuItem disabled={!backups.data?.googleDrive.connected} onClick={() => sendDrive.mutate(item.name)}><Cloud className="mr-2 h-4 w-4" /> Google </DropdownMenuItem>
<DropdownMenuItem asChild><a href={`/api/admin/backups/${encodeURIComponent(item.name)}/download`}><Download className="mr-2 h-4 w-4" /></a></DropdownMenuItem>
<DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onClick={() => setDeleteName(item.name)}><Trash2 className="mr-2 h-4 w-4" /></DropdownMenuItem>
</DropdownMenuContent></DropdownMenu>
</div>
))}
{!backups.isLoading && (backups.data?.items.length || 0) === 0 && <div className="px-3 py-4 text-center text-sm text-muted-foreground"></div>}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3"><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p><strong className="text-foreground">1.</strong> <strong className="text-foreground">/root/</strong></p>
<p><strong className="text-foreground">2.</strong> <strong className="text-foreground">2</strong></p>
<p><strong className="text-foreground">3.</strong> 123</p>
<p><strong className="text-foreground">4.</strong> </p>
<div className="mt-3 rounded-md bg-muted/60 px-3 py-2 text-xs"> <strong className="text-foreground">ns</strong> </div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0 pb-3"><div><CardTitle></CardTitle><p className="mt-1 text-sm text-muted-foreground"></p></div><Switch checked={scheduleEnabled} onCheckedChange={setScheduleEnabled} /></CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2"><Label></Label><div className={cn("grid gap-2", scheduleDays === "custom" && "grid-cols-[minmax(0,1fr)_5.5rem]")}><Select value={scheduleDays} onValueChange={setScheduleDays}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="3"> 3 </SelectItem><SelectItem value="5"> 5 </SelectItem><SelectItem value="7"> 7 </SelectItem><SelectItem value="30"> 30 </SelectItem><SelectItem value="custom"></SelectItem></SelectContent></Select>{scheduleDays === "custom" && <Input id="backup-custom-days" aria-label="自定义天数" title="自定义天数" type="number" min={1} max={365} value={customDays} onChange={(event) => setCustomDays(event.target.value)} />}</div></div>
<div className="space-y-2"><Label htmlFor="backup-server-ip"> IP</Label><Input id="backup-server-ip" value={serverIp} onChange={(event) => setServerIp(event.target.value)} placeholder="例如 165.99.42.243" /></div>
<div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-schedule-password"></Label><PasswordTools value={schedulePassword} visible={showSchedulePassword} onVisibleChange={setShowSchedulePassword} onGenerate={generateSchedulePassword} /></div><Input id="backup-schedule-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={schedulePassword} onChange={(event) => setSchedulePassword(event.target.value)} placeholder={backups.data?.schedule.passwordSet ? "已保存,留空不变" : "至少 8 个字符"} /></div>
<div className="space-y-2"><div className="flex h-7 items-center"><Label htmlFor="backup-schedule-confirm-password"></Label></div><Input id="backup-schedule-confirm-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={scheduleConfirmPassword} onChange={(event) => setScheduleConfirmPassword(event.target.value)} placeholder={schedulePassword ? "再次输入备份密码" : "留空则不修改"} /></div>
</div>
<div className="divide-y rounded-md border">
<div className="flex items-center gap-3 p-3">
<Send className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1"><div className="flex items-center gap-2 sm:grid sm:grid-cols-[8.5rem_auto]"><span className="text-sm font-medium">Telegram</span><Badge className="w-fit" variant={backups.data?.telegramSet ? "default" : "secondary"}>{backups.data?.telegramSet ? "已配置" : "未配置"}</Badge></div><p className="truncate text-xs text-muted-foreground">{telegramMode === "custom" ? "使用系统机器人推送到备份群组" : "沿用邮件通知接收方"}</p></div>
<Button type="button" size="sm" variant="outline" onClick={() => setTelegramConfigOpen(true)}></Button>
<Switch checked={telegramEnabled} onCheckedChange={setTelegramEnabled} disabled={!backups.data?.telegramSet} />
</div>
<div className="flex items-center gap-3 p-3">
<Cloud className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1"><div className="flex items-center gap-2 sm:grid sm:grid-cols-[8.5rem_auto]"><span className="text-sm font-medium">Google </span><Badge className="w-fit" variant={backups.data?.googleDrive.connected ? "default" : "secondary"}>{backups.data?.googleDrive.connected ? "已连接" : "未连接"}</Badge></div><p className="truncate text-xs text-muted-foreground">{backups.data?.googleDrive.connected ? `保存到 ${googleFolderName}` : "长期保存加密备份"}</p></div>
<Button type="button" size="sm" variant="outline" onClick={() => setGoogleConfigOpen(true)}></Button>
<Switch checked={googleDriveEnabled} onCheckedChange={setGoogleDriveEnabled} disabled={!backups.data?.googleDrive.connected} />
</div>
</div>
<div className="flex justify-end border-t pt-3"><Button type="button" className="shrink-0" disabled={saveSchedule.isPending} onClick={submitSchedule}>{saveSchedule.isPending ? "保存中" : "保存设置"}</Button></div>
</CardContent>
</Card>
<Dialog open={telegramConfigOpen} onOpenChange={setTelegramConfigOpen}>
<DialogContent className="w-[calc(100vw-2rem)] max-w-md rounded-lg">
<DialogHeader><DialogTitle>Telegram </DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label></Label><Select value={telegramMode} onValueChange={(value) => setTelegramMode(value === "custom" ? "custom" : "system")}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="system">沿</SelectItem><SelectItem value="custom"></SelectItem></SelectContent></Select></div>
{telegramMode === "system" ? <div className="rounded-md bg-muted/60 px-3 py-2 text-sm text-muted-foreground">使</div> : <div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="backup-chat-id"></Label>
<div className="flex gap-2"><Input id="backup-chat-id" inputMode="numeric" value={backupChatId} onChange={(event) => setBackupChatId(event.target.value)} placeholder="群组 Chat ID" /><Button type="button" variant="outline" className="shrink-0" disabled={createBackupGroupPairing.isPending} onClick={() => createBackupGroupPairing.mutate()}>{createBackupGroupPairing.isPending ? "生成中" : "查询群组"}</Button></div>
<p className="text-xs text-muted-foreground"></p>
{backupGroupPairing && <div className="space-y-3 rounded-md border px-3 py-3">
<p className="text-sm"></p>
<div className="flex items-center gap-2"><code className="min-w-0 flex-1 truncate rounded bg-muted px-2 py-1.5 font-mono text-sm">/newszxcn {backupGroupPairing.code}</code><Button type="button" variant="ghost" size="icon" aria-label="复制群组查询命令" title="复制群组查询命令" onClick={() => navigator.clipboard.writeText(`/newszxcn ${backupGroupPairing.code}`)}><Copy className="h-4 w-4" /></Button></div>
<Button type="button" size="sm" disabled={discoverBackupGroup.isPending} onClick={() => discoverBackupGroup.mutate()}>{discoverBackupGroup.isPending ? "查询中" : "完成查询"}</Button>
{discoveredBackupGroups.length > 0 && <div className="divide-y rounded-md border">{discoveredBackupGroups.map((group) => <Button key={group.chatId} type="button" variant="ghost" className={cn("h-auto w-full justify-start rounded-none px-3 py-2 text-left", backupChatId === group.chatId && "bg-muted")} onClick={() => { setBackupChatId(group.chatId); setBackupGroupPairing(null); setDiscoveredBackupGroups([]) }}><span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{group.displayName}</span><span className="block font-mono text-xs font-normal text-muted-foreground">{group.chatId}</span></span>{backupChatId === group.chatId && <CheckCircle2 className="h-4 w-4 text-primary" />}</Button>)}</div>}
</div>}
</div>
</div>}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button asChild type="button" variant="outline"><a href="/admin?section=settings&settingsTab=notifications"></a></Button>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={testBackupTelegram.isPending || (telegramMode === "custom" && !backupChatId)} onClick={() => testBackupTelegram.mutate()}>{testBackupTelegram.isPending ? "发送中" : "测试发送"}</Button>
<Button type="button" onClick={() => setTelegramConfigOpen(false)}></Button>
</div>
</DialogFooter>
<p className="text-xs text-muted-foreground">使 Chat ID </p>
</DialogContent>
</Dialog>
<Dialog open={googleConfigOpen} onOpenChange={setGoogleConfigOpen}>
<DialogContent className="w-[calc(100vw-2rem)] max-w-lg rounded-lg">
<DialogHeader><DialogTitle>Google </DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label htmlFor="google-client-id">OAuth ID</Label><Input id="google-client-id" value={googleClientId} onChange={(e) => setGoogleClientId(e.target.value)} /></div>
<div className="space-y-2"><Label htmlFor="google-client-secret">OAuth </Label><Input id="google-client-secret" type="password" value={googleClientSecret} onChange={(e) => setGoogleClientSecret(e.target.value)} placeholder={backups.data?.googleDrive.clientSecretSet ? "已安全保存,留空不变" : "请输入客户端密钥"} /></div>
<div className="space-y-2"><Label htmlFor="google-folder-name"></Label><Input id="google-folder-name" value={googleFolderName} onChange={(e) => setGoogleFolderName(e.target.value)} /></div>
<p className="text-xs text-muted-foreground">Google Cloud {window.location.origin}/api/admin/backups/google-drive/callback</p>
</div>
<DialogFooter className="gap-2 sm:justify-between">
{backups.data?.googleDrive.connected ? <Button type="button" variant="outline" className="text-destructive" onClick={() => { disconnectDrive.mutate(); setGoogleConfigOpen(false) }}></Button> : <span />}
<div className="flex gap-2"><Button type="button" variant="outline" onClick={() => setGoogleConfigOpen(false)}></Button><Button type="button" disabled={!googleClientId || (!googleClientSecret && !backups.data?.googleDrive.clientSecretSet) || connectDrive.isPending} onClick={() => connectDrive.mutate()}>{backups.data?.googleDrive.connected ? "重新连接" : "连接 Google"}</Button></div>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={createOpen} onOpenChange={(open) => { if (!create.isPending) setCreateOpen(open) }}>
<DialogContent className="w-[calc(100vw-2rem)] max-w-md rounded-lg">
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-password"></Label><PasswordTools value={password} visible={showCreatePassword} onVisibleChange={setShowCreatePassword} onGenerate={generateCreatePassword} /></div><Input id="backup-password" type={showCreatePassword ? "text" : "password"} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="自己输入或自动生成" /></div>
<div className="space-y-2"><Label htmlFor="backup-confirm-password"></Label><Input id="backup-confirm-password" type={showCreatePassword ? "text" : "password"} autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} /></div>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2"><div><div className="text-sm font-medium"> Telegram</div><div className="text-xs text-muted-foreground"></div></div><Switch checked={sendAfterCreate} onCheckedChange={setSendAfterCreate} disabled={!backups.data?.telegramSet} /></div>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2"><div><div className="text-sm font-medium"> Google </div><div className="text-xs text-muted-foreground"></div></div><Switch checked={driveAfterCreate} onCheckedChange={setDriveAfterCreate} disabled={!backups.data?.googleDrive.connected} /></div>
<p className="text-xs text-muted-foreground"></p>
</div>
<DialogFooter><Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={create.isPending}></Button><Button type="button" onClick={submitCreate} disabled={create.isPending}>{create.isPending ? "启动中" : "开始备份"}</Button></DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog open={!!deleteName} onOpenChange={(open) => { if (!open) setDeleteName("") }} title="删除这个备份?" description="删除后无法恢复,请确认已经在其他位置保存副本。" confirmText="删除备份" destructive pending={remove.isPending} onConfirm={() => remove.mutate(deleteName)} />
</div>
)
}
function UsersSection({ users, permissionGroups, domains }: { users: AdminUser[]; permissionGroups: PermissionGroup[]; domains: Domain[] }) {
const me = useMe()
const user = me.data?.user
@@ -707,9 +1025,13 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
if (left.primary !== right.primary) return left.primary ? -1 : 1
return left.address.localeCompare(right.address, "en", { sensitivity: "base" })
}
const orphanMailboxes = new Map<string, MailboxType[]>()
for (const mailbox of mailboxes.filter((item) => !knownOwnerIDs.has(item.userId))) {
orphanMailboxes.set(mailbox.userId, [...(orphanMailboxes.get(mailbox.userId) || []), mailbox])
}
const mailboxGroups: Array<{ owner?: AdminUser; mailboxes: MailboxType[] }> = [
...users.slice().sort(compareAdminUsers).map((owner) => ({ owner, mailboxes: mailboxes.filter((mailbox) => mailbox.userId === owner.id).sort(compareMailboxes) })),
...mailboxes.filter((mailbox) => !knownOwnerIDs.has(mailbox.userId)).map((mailbox) => ({ owner: undefined, mailboxes: [mailbox] })),
...Array.from(orphanMailboxes.values()).map((items) => ({ owner: undefined, mailboxes: items.sort(compareMailboxes) })),
]
.filter((group) => group.mailboxes.length > 0)
.filter((group) => !keyword || [group.owner ? accountPrimaryEmail(group.owner) : "", group.owner?.displayName || "", ...group.mailboxes.map((mailbox) => mailbox.address)].some((value) => value.toLowerCase().includes(keyword)))