diff --git a/.github/release-notes/v1.2.12.md b/.github/release-notes/v1.2.12.md new file mode 100644 index 0000000..d821116 --- /dev/null +++ b/.github/release-notes/v1.2.12.md @@ -0,0 +1,33 @@ +## 本次更新 + +### 修复文件夹管理 + +- “全部邮箱”模式现在可以新建文件夹,并在账号下每个邮箱中创建同名文件夹。 +- “全部邮箱”模式可以删除自定义文件夹,原有邮件会分别移回各自邮箱的收件箱,不会删除邮件。 +- 文件夹排序仍限定在单个邮箱中,避免不同邮箱之间出现错误顺序。 + +### 修复导入邮件的存储显示 + +- 账号设置中的存储容量改为统计“全部邮箱”,不再因为默认选中空邮箱而显示 `0 B`。 +- 数据统计默认显示“全部邮箱”,并新增邮箱选择器,可查看单个邮箱的数据。 +- 已导入邮件及附件继续保留原始数据,现有邮件无需重新导入。 + +### 改进邮件下载 + +- 下载邮件改为浏览器直接流式下载,不再等整个压缩包载入页面内存后才开始保存。 +- 点击后立即显示下载提示,大邮箱可直接查看浏览器下载进度。 +- 下载格式为 ZIP,压缩包内每封邮件均为标准 EML 文件,保留邮件原始内容和历史时间。 + +### 优化设置与后台切换 + +- 邮箱页面空闲时预加载设置和后台页面,减少首次点击齿轮时的等待。 +- 设置页只加载当前栏目需要的数据,降低无关接口并发请求。 +- 管理员可从邮箱页面和设置侧栏直接进入后台管理,普通用户不会显示该入口。 + +### 验证 + +- 已通过完整 Go 测试、前端 TypeScript 检查、生产构建和 shadcn/ui 检查。 +- 已通过安装脚本语法和自动化回归测试。 +- 已在桌面端和移动端实测统计页、文件夹创建/删除、ZIP 下载和管理员后台跳转。 + +**完整更新日志**:[v1.2.11...v1.2.12](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.11...v1.2.12) diff --git a/VERSION b/VERSION index c114700..f2ae0b4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.11 +1.2.12 diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 306b495..3668c83 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -1648,6 +1648,45 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) { t.Fatalf("folders for selected mailbox code=%d", code) } + var sharedFolder MailFolder + if code := userClient.do("POST", "/api/mail/folders?mailboxId=all", map[string]string{"name": "Shared Project"}, &sharedFolder); code != http.StatusCreated { + t.Fatalf("create shared folder code=%d folder=%+v", code, sharedFolder) + } + var primarySharedID, secondarySharedID string + if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, primary.ID, "Shared Project").Scan(&primarySharedID); err != nil { + t.Fatalf("primary shared folder: %v", err) + } + if err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND name=?`, secondary.ID, "Shared Project").Scan(&secondarySharedID); err != nil { + t.Fatalf("secondary shared folder: %v", err) + } + if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=? WHERE id=?`, primarySharedID, "msg_multi_primary_read"); err != nil { + t.Fatal(err) + } + if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=? WHERE id=?`, secondarySharedID, "msg_multi_secondary_unread"); err != nil { + t.Fatal(err) + } + var deleted struct { + Moved int `json:"moved"` + } + deletePath := "/api/mail/folders/" + url.PathEscape(sharedFolder.ID) + "?mailboxId=all&folderName=" + url.QueryEscape(sharedFolder.Name) + if code := userClient.do("DELETE", deletePath, nil, &deleted); code != http.StatusOK || deleted.Moved != 2 { + t.Fatalf("delete shared folders code=%d moved=%d", code, deleted.Moved) + } + var sharedCount int + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM folders WHERE mailbox_id IN (?,?) AND name=?`, primary.ID, secondary.ID, "Shared Project").Scan(&sharedCount); err != nil || sharedCount != 0 { + t.Fatalf("shared folders remaining=%d err=%v", sharedCount, err) + } + var restoredPrimaryFolder, restoredSecondaryFolder string + if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_multi_primary_read").Scan(&restoredPrimaryFolder); err != nil { + t.Fatal(err) + } + if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, "msg_multi_secondary_unread").Scan(&restoredSecondaryFolder); err != nil { + t.Fatal(err) + } + if restoredPrimaryFolder != primaryInboxID || restoredSecondaryFolder != secondaryInboxID { + t.Fatalf("restored folders primary=%s secondary=%s", restoredPrimaryFolder, restoredSecondaryFolder) + } + var sent MailMessage payload := map[string]any{ "mailboxId": secondary.ID, diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 0877ef0..feb21d1 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -264,11 +264,6 @@ func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) { } func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { - mb, err := a.mailboxForCurrentUser(r) - if err != nil { - respondError(w, http.StatusNotFound, "mailbox not found") - return - } var req struct { Name string `json:"name"` } @@ -285,6 +280,47 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("system folder already exists")) return } + if isAllMailboxID(r.URL.Query().Get("mailboxId")) { + user := currentUser(r) + rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load mailboxes") + return + } + mailboxIDs := []string{} + for rows.Next() { + var mailboxID string + if err := rows.Scan(&mailboxID); err != nil { + rows.Close() + respondError(w, http.StatusInternalServerError, "failed to scan mailboxes") + return + } + mailboxIDs = append(mailboxIDs, mailboxID) + } + if err := rows.Err(); err != nil { + rows.Close() + respondError(w, http.StatusInternalServerError, "failed to scan mailboxes") + return + } + rows.Close() + if len(mailboxIDs) == 0 { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + for _, mailboxID := range mailboxIDs { + if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name); err != nil { + respondError(w, http.StatusInternalServerError, "failed to create folder") + return + } + } + respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), SortOrder: customFolderDefaultSortOrderBase}) + return + } + mb, err := a.mailboxForCurrentUser(r) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name) if err != nil { respondError(w, http.StatusInternalServerError, "failed to create folder") @@ -299,16 +335,20 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { } func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) { - mb, err := a.mailboxForCurrentUser(r) - if err != nil { - respondError(w, http.StatusNotFound, "mailbox not found") - return - } folderID := strings.TrimSpace(chi.URLParam(r, "id")) if folderID == "" { badRequest(w, errors.New("folder id is required")) return } + if isAllMailboxID(r.URL.Query().Get("mailboxId")) { + a.handleDeleteAllMailFolders(w, r, folderID) + return + } + mb, err := a.mailboxForCurrentUser(r) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } var folderName string if err := a.db.QueryRowContext(r.Context(), `SELECT name FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID).Scan(&folderName); err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -378,6 +418,115 @@ func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)}) } +func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request, folderID string) { + folderName := strings.TrimSpace(r.URL.Query().Get("folderName")) + if folderName == "" && strings.HasPrefix(strings.ToLower(folderID), "all-") { + folderName = strings.TrimSpace(folderID[4:]) + } + name, err := normalizeCustomFolderName(folderName) + if err != nil { + badRequest(w, err) + return + } + if isSystemFolderName(name) { + badRequest(w, errors.New("system folders cannot be deleted")) + return + } + user := currentUser(r) + type folderTarget struct { + folderID string + mailboxID string + inboxID string + } + rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.mailbox_id FROM folders f JOIN mailboxes mb ON mb.id=f.mailbox_id WHERE mb.user_id=? AND mb.status='active' AND lower(f.name)=lower(?)`, user.ID, name) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load folders") + return + } + targets := []folderTarget{} + for rows.Next() { + var target folderTarget + if err := rows.Scan(&target.folderID, &target.mailboxID); err != nil { + rows.Close() + respondError(w, http.StatusInternalServerError, "failed to scan folders") + return + } + targets = append(targets, target) + } + if err := rows.Err(); err != nil { + rows.Close() + respondError(w, http.StatusInternalServerError, "failed to scan folders") + return + } + rows.Close() + if len(targets) == 0 { + respondError(w, http.StatusNotFound, "folder not found") + return + } + for i := range targets { + targets[i].inboxID, err = a.ensureFolder(r.Context(), targets[i].mailboxID, "Inbox") + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load inbox") + return + } + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete folder") + return + } + defer tx.Rollback() + now := a.now().UTC().Format(time.RFC3339Nano) + moved := 0 + for _, target := range targets { + messageRows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, target.mailboxID, target.folderID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load folder messages") + return + } + messageIDs := []string{} + for messageRows.Next() { + var messageID string + if err := messageRows.Scan(&messageID); err != nil { + messageRows.Close() + respondError(w, http.StatusInternalServerError, "failed to scan folder messages") + return + } + messageIDs = append(messageIDs, messageID) + } + if err := messageRows.Err(); err != nil { + messageRows.Close() + respondError(w, http.StatusInternalServerError, "failed to scan folder messages") + return + } + messageRows.Close() + for _, messageID := range messageIDs { + meta, err := a.nextIMAPMetadata(r.Context(), tx, target.inboxID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to allocate message uid") + return + } + if _, err := tx.ExecContext(r.Context(), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, target.inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to move folder messages") + return + } + moved++ + } + if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, target.folderID, target.mailboxID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete folder") + return + } + } + if err := tx.Commit(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete folder") + return + } + for _, target := range targets { + _, _ = a.bumpFolderModSeq(r.Context(), target.inboxID) + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved}) +} + func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) { return a.ensureFolder(ctx, mailboxID, name) } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 5fad19a..5c25d79 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -69,16 +69,6 @@ async function request(path: string, init: RequestInit & { timeoutMs?: number } } -async function requestFile(path: string): Promise { - const res = await fetch(path, { credentials: "include" }) - if (!res.ok) { - let message = `${res.status} ${res.statusText}` - try { const body = await res.json(); message = body.error || message } catch {} - throw new Error(message) - } - return res.blob() -} - async function uploadForm(path: string, form: FormData): Promise { const controller = new AbortController() const timeout = window.setTimeout(() => controller.abort(), 5 * 60_000) @@ -230,7 +220,13 @@ export const api = { const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : "" return request<{ ok: boolean }>(`/api/mail/folders/reorder${query}`, { method: "POST", body: JSON.stringify(payload.folders ? { folders: payload.folders } : { folderIds: payload.folderIds }) }) }, - deleteFolder: (id: string, mailboxId?: string) => request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }), + deleteFolder: (id: string, mailboxId?: string, folderName?: string) => { + const query = new URLSearchParams() + if (mailboxId) query.set("mailboxId", mailboxId) + if (folderName) query.set("folderName", folderName) + const suffix = query.toString() + return request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${suffix ? `?${suffix}` : ""}`, { method: "DELETE" }) + }, labels: (mailboxId?: string) => request>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => { const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : "" @@ -255,12 +251,12 @@ export const api = { if (mailboxId) params.set("mailboxId", mailboxId) return request>(`/api/mail/starred?${params.toString()}`) }, - exportMail: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string }) => { + exportMailUrl: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string }) => { const query = new URLSearchParams({ view: params.view }) if (params.mailboxId) query.set("mailboxId", params.mailboxId) if (params.folder) query.set("folder", params.folder) if (params.labelId) query.set("labelId", params.labelId) - return requestFile(`/api/mail/export?${query.toString()}`) + return `/api/mail/export?${query.toString()}` }, importMail: (files: File[], payload: { mailboxId: string; folder: string }) => { const form = new FormData() diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 2fdc1a1..e34905f 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -192,6 +192,15 @@ export function MailPage() { const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false + React.useEffect(() => { + if (!user) return + const timer = window.setTimeout(() => { + void import("@/pages/profile") + if (user.role === "admin") void import("@/pages/admin") + }, 400) + return () => window.clearTimeout(timer) + }, [user]) + const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail }) const externalMailAccounts = useQuery({ queryKey: ["mail-external-accounts"], queryFn: api.externalMailAccounts, enabled: canAccessMail && canReadMail && externalImapEnabled }) const selectedExternalAccount = React.useMemo(() => externalImapEnabled ? externalMailAccounts.data?.items.find((item) => item.id === selectedExternalAccountId) : undefined, [externalImapEnabled, externalMailAccounts.data?.items, selectedExternalAccountId]) @@ -205,6 +214,7 @@ export function MailPage() { const activeMailboxId = selectedMailboxId === "all" ? "all" : selectedMailbox?.id || "" const selectedComposeMailbox = selectedMailbox || (isAllMailboxSelected ? mailboxList.data?.items?.[0] : undefined) const hasMailboxes = (mailboxList.data?.items.length || 0) > 0 + const canManageFolders = canOrganizeMail && hasMailboxes const showMailboxCopy = !!selectedMailbox && !isAllMailboxSelected const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId && canReadMail }) const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) }) @@ -460,7 +470,7 @@ export function MailPage() { onSettled: () => qc.invalidateQueries({ queryKey: ["folders", activeMailboxId] }), }) const deleteFolder = useMutation({ - mutationFn: (item: Extract) => api.deleteFolder(item.folderId, activeMailboxId), + mutationFn: (item: Extract) => api.deleteFolder(item.folderId, activeMailboxId, item.folderName), onSuccess: async (result, item) => { setPendingConfirm(null) if (mailView === "folder" && folder === item.folderName) { @@ -967,7 +977,9 @@ export function MailPage() { if (item.type !== "folder" || !item.custom) return setPendingConfirm({ title: `删除文件夹“${item.label}”?`, - description: "文件夹内的邮件会移回收件箱,不会被删除。", + description: isAllMailboxSelected + ? "所有邮箱中的同名文件夹都会删除,文件夹内邮件会移回各自的收件箱。" + : "文件夹内的邮件会移回收件箱,不会被删除。", confirmText: "删除文件夹", onConfirm: () => deleteFolder.mutate(item), }) @@ -1096,28 +1108,20 @@ export function MailPage() { async function exportCurrentMail() { if (!canExportCurrentView || exportingMail) return setExportingMail(true) - try { - const exportView = mailView === "unknown" ? "unknown" : mailView === "starred" ? "starred" : mailView === "label" ? "label" : "folder" - const blob = await api.exportMail({ - view: exportView, - mailboxId: mailView === "unknown" ? undefined : activeMailboxId, - folder: exportView === "folder" ? folder : undefined, - labelId: exportView === "label" ? selectedLabelId : undefined, - }) - const href = URL.createObjectURL(blob) - const anchor = document.createElement("a") - anchor.href = href - anchor.download = `${viewTitle.replace(/[\\/:*?"<>|]+/g, "-") || "邮件"}-${new Date().toISOString().slice(0, 10)}.zip` - document.body.appendChild(anchor) - anchor.click() - anchor.remove() - window.setTimeout(() => URL.revokeObjectURL(href), 1000) - toast({ title: "邮件已导出", description: `${viewTitle} 已打包为 ZIP` }) - } catch (error) { - toast({ title: "导出失败", description: error instanceof Error ? error.message : "请稍后重试" }) - } finally { - setExportingMail(false) - } + const exportView = mailView === "unknown" ? "unknown" : mailView === "starred" ? "starred" : mailView === "label" ? "label" : "folder" + const anchor = document.createElement("a") + anchor.href = api.exportMailUrl({ + view: exportView, + mailboxId: mailView === "unknown" ? undefined : activeMailboxId, + folder: exportView === "folder" ? folder : undefined, + labelId: exportView === "label" ? selectedLabelId : undefined, + }) + anchor.download = `${viewTitle.replace(/[\\/:*?"<>|]+/g, "-") || "邮件"}-${new Date().toISOString().slice(0, 10)}.zip` + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + toast({ title: "已开始下载", description: "邮件将打包为 ZIP,压缩包内为标准 EML 文件;邮件较多时请查看浏览器下载进度。" }) + window.setTimeout(() => setExportingMail(false), 1000) } function chooseMailImport() { if (!canImportCurrentView || importingMail) { @@ -1166,6 +1170,9 @@ export function MailPage() { function openSettings() { navigate("/profile") } + function openAdmin() { + navigate("/admin") + } function toggleAdvancedSearch() { setAdvancedSearchDraft(advancedSearch) setAdvancedSearchOpen((open) => !open) @@ -1216,6 +1223,7 @@ export function MailPage() { language={language} onLanguageChange={setLanguage} onSettings={openSettings} + onAdmin={user?.role === "admin" ? openAdmin : undefined} />
} - {(customMailMenuItems.length > 0 || canOrganizeMail) && + {(customMailMenuItems.length > 0 || canManageFolders) && {!sidebarCollapsed && (
文件夹 - {canOrganizeCurrentMailbox && ( - )} @@ -1784,8 +1792,10 @@ export function MailPage() { /> { closeSidebarContextMenu() @@ -2455,7 +2465,7 @@ function BulkActionToolbar({ pending, currentFolder, folders = [], readAction = ) } -function SidebarContextMenu({ state, canOrganize, pending, onClose, onOpen, onRefresh, onCreateFolder, onMove, onDelete }: { state: SidebarContextMenuState | null; canOrganize: boolean; pending: boolean; onClose: () => void; onOpen: (item: MailMenuItem) => void; onRefresh: () => void; onCreateFolder: () => void; onMove: (item: MailMenuItem, action: "top" | "up" | "down" | "bottom") => void; onDelete: (item: MailMenuItem) => void }) { +function SidebarContextMenu({ state, canCreate, canReorder, canDelete, pending, onClose, onOpen, onRefresh, onCreateFolder, onMove, onDelete }: { state: SidebarContextMenuState | null; canCreate: boolean; canReorder: boolean; canDelete: boolean; pending: boolean; onClose: () => void; onOpen: (item: MailMenuItem) => void; onRefresh: () => void; onCreateFolder: () => void; onMove: (item: MailMenuItem, action: "top" | "up" | "down" | "bottom") => void; onDelete: (item: MailMenuItem) => void }) { React.useEffect(() => { if (!state) return const close = () => onClose() @@ -2493,30 +2503,32 @@ function SidebarContextMenu({ state, canOrganize, pending, onClose, onOpen, onRe - {canOrganize && ( + {canCreate && ( )} - {canOrganize && customFolder && ( + {customFolder && (canReorder || canDelete) && ( <>
- - - - -
- + {canReorder && <> + + + + + } + {canReorder && canDelete &&
} + {canDelete && } )}
@@ -3187,7 +3199,7 @@ function NewLabelButton({ collapsed, pending, onCreate, editing, onEditingChange ) } -function AccountHeader({ collapsed, name, email, darkMode, language, onToggleTheme, onLanguageChange, onSettings }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; language: Language; onToggleTheme: () => void; onLanguageChange: (language: Language) => void; onSettings: () => void }) { +function AccountHeader({ collapsed, name, email, darkMode, language, onToggleTheme, onLanguageChange, onSettings, onAdmin }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; language: Language; onToggleTheme: () => void; onLanguageChange: (language: Language) => void; onSettings: () => void; onAdmin?: () => void }) { const displayName = cleanAccountName(name, email) const currentLanguage = languageOptions.find((item) => item.value === language) || languageOptions[0] if (collapsed) { @@ -3210,6 +3222,9 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe
+ {onAdmin && } diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 172a7f2..c785f92 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -59,6 +59,7 @@ export function ProfilePage() { const passwordFormRef = React.useRef(null) const twoFactorFormRef = React.useRef(null) const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "") + const [statsMailboxId, setStatsMailboxId] = React.useState("all") const [statsRangeDays, setStatsRangeDays] = React.useState(30) const [darkMode, setDarkMode] = React.useState(getInitialTheme) const [displayMode, setDisplayMode] = useDisplayMode() @@ -98,29 +99,32 @@ export function ProfilePage() { const tab: Tab = rawTab && visibleTabKeys.includes(rawTab) ? rawTab : "profile" const accountTab: AccountSettingsTab = rawAccountTab && accountSettingTabs.some((item) => item.key === rawAccountTab) ? rawAccountTab : "account" const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail }) - const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox }) - const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) - const apiTokens = useQuery({ queryKey: ["api-tokens"], queryFn: api.apiTokens }) - const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts }) - const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures }) - const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules }) - const ruleForwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: canManageRules && canAccessMail }) + const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox && tab === "mailboxes" }) + const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings, enabled: tab === "mailboxes" || (tab === "profile" && accountTab === "clients") }) + const apiTokens = useQuery({ queryKey: ["api-tokens"], queryFn: api.apiTokens, enabled: tab === "apiTokens" }) + const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts && tab === "contacts" }) + const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures && tab === "profile" && accountTab === "mail" }) + const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules && tab === "rules" }) + const ruleForwarding = useQuery({ queryKey: ["forwarding-settings"], queryFn: api.forwardingSettings, enabled: canManageRules && canAccessMail && tab === "rules" }) const ruleVerifiedEmails = React.useMemo(() => ruleForwarding.data?.verifiedEmails.filter((item) => item.verified).map((item) => item.email) || [], [ruleForwarding.data?.verifiedEmails]) - const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked }) + const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked && tab === "blocked" }) const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId]) const activeMailboxId = selectedMailbox?.id || "" const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false - const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail && externalImapEnabled }) + const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: tab === "mailboxes" && !!activeMailboxId && canAccessMail && externalImapEnabled }) React.useEffect(() => { if (!externalRunAccountId) return if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return setExternalRunAccountId("") }, [externalImapAccounts.data?.items, externalRunAccountId]) const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId) - const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled }) - const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled }) - const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels || canManageRules) }) - const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId, statsRangeDays], queryFn: () => api.mailStats(activeMailboxId, statsRangeDays), enabled: !!activeMailboxId && canViewStats }) + const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: tab === "mailboxes" && !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled }) + const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: tab === "mailboxes" && !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled }) + const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && ((tab === "profile" && accountTab === "mail" && (canReadMail || canManageLabels)) || (tab === "rules" && canManageRules)) }) + const accountStats = useQuery({ queryKey: ["mail-stats", "all", 30], queryFn: () => api.mailStats("all", 30), enabled: canViewStats && tab === "profile" && accountTab === "account" }) + const mailboxStats = useQuery({ queryKey: ["mail-stats", activeMailboxId, 30], queryFn: () => api.mailStats(activeMailboxId, 30), enabled: !!activeMailboxId && canViewStats && (tab === "cleanup" || tab === "cleanupQueue") }) + const blockedStats = useQuery({ queryKey: ["mail-stats", blockedMailboxId, 30], queryFn: () => api.mailStats(blockedMailboxId, 30), enabled: canViewStats && tab === "blocked" }) + const dashboardStats = useQuery({ queryKey: ["mail-stats", statsMailboxId, statsRangeDays], queryFn: () => api.mailStats(statsMailboxId, statsRangeDays), enabled: canViewStats && tab === "stats" }) const profile = useMutation({ mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }), @@ -356,6 +360,16 @@ export function ProfilePage() { {tabs[key].label} ))} + {user.role === "admin" && ( + + )}
@@ -370,7 +384,10 @@ export function ProfilePage() { const pageTitle = tabs[tab].label const pageSubtitle = tab === "stats" ? "查看邮件收发趋势、分布情况和常用联系人。" : undefined const pageAction = tab === "stats" - ? + ?
+
+ +
: tab === "apiTokens" ? : undefined @@ -420,7 +437,7 @@ export function ProfilePage() { profile={profile} password={password} passwordFormRef={passwordFormRef} - stats={canViewStats ? stats.data : undefined} + stats={canViewStats ? accountStats.data : undefined} showStats={canViewStats} displayMode={displayMode} onDisplayModeChange={setDisplayMode} @@ -477,11 +494,11 @@ export function ProfilePage() { /> ) if (tab === "contacts") return createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> - if (tab === "cleanup") return cleanup.mutate(target)} /> - if (tab === "cleanupQueue") return + if (tab === "cleanup") return cleanup.mutate(target)} /> + if (tab === "cleanupQueue") return if (tab === "rules") return createRule.mutate(payload)} onUpdate={(id, payload) => updateRule.mutate({ id, payload })} onToggle={(item) => updateRule.mutate({ id: item.id, payload: { enabled: !item.enabled } })} onMove={(id, direction) => moveRule.mutate({ id, direction })} onApply={(id) => applyRule.mutate(id)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending || updateRule.isPending || moveRule.isPending || applyRule.isPending} /> - if (tab === "blocked") return f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} /> - if (tab === "stats") return stats.refetch()} /> + if (tab === "blocked") return f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} /> + if (tab === "stats") return if (tab === "apiTokens") return createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} /> return null } @@ -2394,7 +2411,7 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang ) } -function StatsSection({ stats }: { stats?: MailStats; mailbox?: Mailbox; rangeDays: number; onRangeChange: (days: number) => void; onRefresh: () => void }) { +function StatsSection({ stats }: { stats?: MailStats }) { const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0) const quotaPct = Math.min(stats?.quotaUsedPct || 0, 100) const primaryCards = [