fix: restore mail folder and export workflows
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions

This commit is contained in:
zxyszx
2026-08-04 19:07:21 +08:00
parent 06388465a2
commit cefbee4f7f
7 changed files with 342 additions and 93 deletions
+39
View File
@@ -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,
+159 -10
View File
@@ -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)
}
+9 -13
View File
@@ -69,16 +69,6 @@ async function request<T>(path: string, init: RequestInit & { timeoutMs?: number
}
}
async function requestFile(path: string): Promise<Blob> {
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<T>(path: string, form: FormData): Promise<T> {
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<ListResponse<MailLabel>>(`/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<ListResponse<MailMessage>>(`/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()
+64 -49
View File
@@ -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<MailMenuItem, { type: "folder" }>) => api.deleteFolder(item.folderId, activeMailboxId),
mutationFn: (item: Extract<MailMenuItem, { type: "folder" }>) => 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}
/>
<div className={cn("mt-2 gap-1.5", sidebarCollapsed ? "flex justify-center" : showMailboxCopy ? "grid grid-cols-[minmax(0,1fr)_2rem]" : "grid grid-cols-1")}>
<MailboxSwitcher
@@ -1352,12 +1360,12 @@ export function MailPage() {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>}
{(customMailMenuItems.length > 0 || canOrganizeMail) && <SidebarGroup>
{(customMailMenuItems.length > 0 || canManageFolders) && <SidebarGroup>
{!sidebarCollapsed && (
<div className="flex items-center justify-between px-2 py-1">
<SidebarGroupLabel className="m-0 h-auto gap-1 p-0 text-xs font-semibold text-muted-foreground"><ChevronDown className="h-3 w-3" /></SidebarGroupLabel>
{canOrganizeCurrentMailbox && (
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)} disabled={!selectedMailbox}>
{canManageFolders && (
<Button type="button" variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setFolderDialogOpen(true)}>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
@@ -1784,8 +1792,10 @@ export function MailPage() {
/>
<SidebarContextMenu
state={sidebarContextMenu}
canOrganize={canOrganizeMail}
pending={reorderFolders.isPending}
canCreate={canManageFolders}
canReorder={canOrganizeCurrentMailbox}
canDelete={canManageFolders}
pending={reorderFolders.isPending || deleteFolder.isPending}
onClose={closeSidebarContextMenu}
onOpen={(item) => {
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
<Button type="button" variant="ghost" className={itemClass} onClick={onRefresh}>
<RefreshCcw className="h-4 w-4" />
</Button>
{canOrganize && (
{canCreate && (
<Button type="button" variant="ghost" className={itemClass} onClick={onCreateFolder}>
<Plus className="h-4 w-4" />
</Button>
)}
{canOrganize && customFolder && (
{customFolder && (canReorder || canDelete) && (
<>
<div className="my-1 h-px bg-border" />
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "top")}>
<ArrowLeft className="h-4 w-4 rotate-90" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "up")}>
<ChevronDown className="h-4 w-4 rotate-180" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "down")}>
<ChevronDown className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "bottom")}>
<ArrowLeft className="h-4 w-4 -rotate-90" />
</Button>
<div className="my-1 h-px bg-border" />
<Button type="button" variant="ghost" className={cn(itemClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>
{canReorder && <>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "top")}>
<ArrowLeft className="h-4 w-4 rotate-90" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "up")}>
<ChevronDown className="h-4 w-4 rotate-180" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "down")}>
<ChevronDown className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" className={itemClass} disabled={pending} onClick={() => onMove(item, "bottom")}>
<ArrowLeft className="h-4 w-4 -rotate-90" />
</Button>
</>}
{canReorder && canDelete && <div className="my-1 h-px bg-border" />}
{canDelete && <Button type="button" variant="ghost" className={cn(itemClass, "text-destructive hover:bg-destructive/10 hover:text-destructive")} disabled={pending} onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>}
</>
)}
</div>
@@ -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
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{onAdmin && <Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onAdmin} title="后台管理" aria-label="后台管理">
<ShieldCheck className="h-3.5 w-3.5" />
</Button>}
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={onToggleTheme}>
{darkMode ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
</Button>
+37 -20
View File
@@ -59,6 +59,7 @@ export function ProfilePage() {
const passwordFormRef = React.useRef<HTMLFormElement>(null)
const twoFactorFormRef = React.useRef<HTMLFormElement>(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() {
<span className="truncate">{tabs[key].label}</span>
</button>
))}
{user.role === "admin" && (
<button
type="button"
className="flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
onClick={() => navigate("/admin")}
>
<ShieldCheck className="h-4 w-4 stroke-[1.8]" />
<span className="truncate"></span>
</button>
)}
</div>
</nav>
<div className="border-t p-2">
@@ -370,7 +384,10 @@ export function ProfilePage() {
const pageTitle = tabs[tab].label
const pageSubtitle = tab === "stats" ? "查看邮件收发趋势、分布情况和常用联系人。" : undefined
const pageAction = tab === "stats"
? <StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
? <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="w-full sm:w-[220px]"><MailboxSelect value={statsMailboxId} mailboxes={mailboxes.data?.items || []} onChange={setStatsMailboxId} /></div>
<StatsRangeTabs rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} />
</div>
: tab === "apiTokens"
? <Button asChild variant="outline" size="sm" className="h-8 px-3 text-xs"><a href="https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/API.md" target="_blank" rel="noreferrer"><BookOpen className="h-4 w-4" />API </a></Button>
: 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 <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} />
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? mailboxStats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
if (tab === "cleanupQueue") return <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? mailboxStats.data : undefined} />
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={labels.data?.items || []} verifiedEmails={ruleVerifiedEmails} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => 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 <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => 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 <StatsSection stats={stats.data} mailbox={selectedMailbox} rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} onRefresh={() => stats.refetch()} />
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? blockedStats.data?.byFolder.find((f) => 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 <StatsSection stats={dashboardStats.data} />
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => 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 = [