diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index 81fcec1..1f09a31 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -1149,6 +1149,14 @@ func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (strin } role := strings.ToLower(folder) id = newID("fld") - _, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, a.newUIDValidity(), 1, 1, a.now().UTC().Format(time.RFC3339Nano)) + sortOrder := 0 + if !isSystemFolderName(folder) { + var err error + sortOrder, err = a.nextCustomFolderSortOrder(ctx, mailboxID) + if err != nil { + return "", err + } + } + _, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,sort_order,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, sortOrder, a.newUIDValidity(), 1, 1, a.now().UTC().Format(time.RFC3339Nano)) return id, err } diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 88d4010..cabde5e 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -202,6 +202,7 @@ func (a *App) migrate(ctx context.Context) error { mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, name TEXT NOT NULL, role TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, uid_validity INTEGER NOT NULL DEFAULT 0, uid_next INTEGER NOT NULL DEFAULT 1, highest_modseq INTEGER NOT NULL DEFAULT 1, @@ -448,6 +449,9 @@ func (a *App) migrate(ctx context.Context) error { if err := a.migrateIMAPMetadata(ctx); err != nil { return err } + if err := a.migrateFolderSortOrder(ctx); err != nil { + return err + } if err := a.ensureDefaultPermissionGroups(ctx); err != nil { return err } @@ -1131,7 +1135,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI return "", err } for _, f := range defaultFolderDefs() { - _, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, newID("fld"), id, f.name, f.role, a.newUIDValidity(), 1, 1, now) + _, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,sort_order,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, newID("fld"), id, f.name, f.role, 0, a.newUIDValidity(), 1, 1, now) if err != nil { return "", err } diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 9828611..1249362 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -1047,6 +1047,80 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) { } } +func TestCustomMailFoldersReorder(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + + var login map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("login code=%d body=%v", code, login) + } + + createFolder := func(name string) MailFolder { + t.Helper() + var folder MailFolder + if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": name}, &folder); code != http.StatusCreated { + t.Fatalf("create folder %s code=%d folder=%+v", name, code, folder) + } + return folder + } + customer := createFolder("客户") + bills := createFolder("账单") + project := createFolder("项目") + + var ok map[string]any + if code := admin.do("POST", "/api/mail/folders/reorder", map[string]any{"folderIds": []string{project.ID, customer.ID, bills.ID}}, &ok); code != http.StatusOK { + t.Fatalf("reorder code=%d body=%v", code, ok) + } + if code := admin.do("POST", "/api/mail/folders/reorder", map[string]any{"folders": []map[string]any{ + {"id": customer.ID, "sortOrder": 500}, + {"id": project.ID, "sortOrder": 2500}, + {"id": bills.ID, "sortOrder": 3500}, + }}, &ok); code != http.StatusOK { + t.Fatalf("reorder with explicit sort order code=%d body=%v", code, ok) + } + var folders struct { + Items []MailFolder `json:"items"` + } + if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK { + t.Fatalf("list folders code=%d items=%+v", code, folders.Items) + } + got := customFolderNames(folders.Items) + want := []string{"客户", "项目", "账单"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("custom folder order=%v want=%v", got, want) + } + if folders.Items[0].ID != customer.ID { + t.Fatalf("customer folder should be before inbox after explicit sort order, first=%+v", folders.Items[0]) + } + var inboxID string + for _, item := range folders.Items { + if item.Name == "Inbox" { + inboxID = item.ID + break + } + } + if inboxID == "" { + t.Fatalf("inbox not found in folders: %+v", folders.Items) + } + var bad map[string]any + if code := admin.do("POST", "/api/mail/folders/reorder", map[string]any{"folderIds": []string{project.ID, inboxID, customer.ID, bills.ID}}, &bad); code != http.StatusBadRequest { + t.Fatalf("reorder with system folder should be rejected code=%d body=%v", code, bad) + } + + domain := createTestDomain(t, admin, "folders.test") + other := createTestMailbox(t, admin, domain.ID, "other", "Other", "Password123!", nil) + otherFolderID, err := a.ensureFolder(context.Background(), other.ID, "其他") + if err != nil { + t.Fatal(err) + } + if code := admin.do("POST", "/api/mail/folders/reorder", map[string]any{"folderIds": []string{project.ID, customer.ID, otherFolderID}}, &bad); code != http.StatusBadRequest { + t.Fatalf("reorder with other mailbox folder should be rejected code=%d body=%v", code, bad) + } +} + func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) @@ -3803,6 +3877,16 @@ func folderListContains(items []MailFolder, name string) bool { return false } +func customFolderNames(items []MailFolder) []string { + names := []string{} + for _, item := range items { + if !isSystemFolderName(item.Name) { + names = append(names, item.Name) + } + } + return names +} + func withoutPermissions(items []string, removed ...string) []string { removedSet := map[string]bool{} for _, item := range removed { diff --git a/apps/api/internal/app/imap_metadata.go b/apps/api/internal/app/imap_metadata.go index cf4f6fb..cb1079a 100644 --- a/apps/api/internal/app/imap_metadata.go +++ b/apps/api/internal/app/imap_metadata.go @@ -44,6 +44,38 @@ func (a *App) migrateIMAPMetadata(ctx context.Context) error { return err } +func (a *App) migrateFolderSortOrder(ctx context.Context) error { + if err := a.ensureTableColumn(ctx, "folders", "sort_order", `ALTER TABLE folders ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`); err != nil { + return err + } + rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders WHERE lower(name) NOT IN ('inbox','sent','drafts','archive','spam','trash') ORDER BY mailbox_id, created_at, name, id`) + if err != nil { + return err + } + var folderIDs []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + folderIDs = append(folderIDs, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + order := customFolderDefaultSortOrderBase + 1 + for _, id := range folderIDs { + if _, err := a.db.ExecContext(ctx, `UPDATE folders SET sort_order=? WHERE id=? AND sort_order=0`, order, id); err != nil { + return err + } + order++ + } + return nil +} + func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error { rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`) if err != nil { diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index da6899c..5b53ad4 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -22,6 +22,8 @@ import ( // mailMessagesPageSize is the max number of messages returned per page in mail listing. const mailMessagesPageSize = 30 +const customFolderDefaultSortOrderBase = 100000 + type AttachmentInput struct { Filename string `json:"filename"` ContentType string `json:"contentType"` @@ -87,10 +89,18 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role, COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, COUNT(m.id) AS total, - f.uid_validity,f.uid_next,f.highest_modseq + f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq FROM folders f LEFT JOIN messages m ON m.folder_id=f.id - WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role - ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END, f.name`, mb.ID) + WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq + ORDER BY CASE + WHEN lower(f.name)='inbox' THEN 1000 + WHEN lower(f.name)='sent' THEN 5000 + WHEN lower(f.name)='drafts' THEN 6000 + WHEN lower(f.name)='archive' THEN 7000 + WHEN lower(f.name)='spam' THEN 8000 + WHEN lower(f.name)='trash' THEN 9000 + ELSE f.sort_order + END, f.created_at,f.name`, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load folders") return @@ -99,7 +109,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) { items := []MailFolder{} for rows.Next() { var f MailFolder - if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { + if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { respondError(w, http.StatusInternalServerError, "failed to scan folders") return } @@ -108,6 +118,102 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{"items": items}) } +func (a *App) handleReorderMailFolders(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 { + FolderIDs []string `json:"folderIds"` + Folders []struct { + ID string `json:"id"` + SortOrder int `json:"sortOrder"` + } `json:"folders"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + seen := map[string]bool{} + if len(req.Folders) == 0 { + for i, id := range req.FolderIDs { + id = strings.TrimSpace(id) + req.Folders = append(req.Folders, struct { + ID string `json:"id"` + SortOrder int `json:"sortOrder"` + }{ID: id, SortOrder: customFolderDefaultSortOrderBase + i + 1}) + } + } + for i := range req.Folders { + req.Folders[i].ID = strings.TrimSpace(req.Folders[i].ID) + if req.Folders[i].ID == "" || req.Folders[i].SortOrder <= 0 || seen[req.Folders[i].ID] { + badRequest(w, errors.New("invalid folder order")) + return + } + seen[req.Folders[i].ID] = true + } + if len(req.Folders) == 0 { + badRequest(w, errors.New("folderIds is required")) + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT id,name FROM folders WHERE mailbox_id=?`, mb.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load folders") + return + } + customIDs := map[string]bool{} + for rows.Next() { + var id, name string + if err := rows.Scan(&id, &name); err != nil { + rows.Close() + respondError(w, http.StatusInternalServerError, "failed to scan folders") + return + } + if !isSystemFolderName(name) { + customIDs[id] = true + } + } + if err := rows.Err(); err != nil { + rows.Close() + respondError(w, http.StatusInternalServerError, "failed to scan folders") + return + } + rows.Close() + if len(customIDs) != len(req.Folders) { + badRequest(w, errors.New("invalid folder order")) + return + } + for _, item := range req.Folders { + if !customIDs[item.ID] { + badRequest(w, errors.New("invalid folder order")) + return + } + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to reorder folders") + return + } + defer tx.Rollback() + for _, item := range req.Folders { + res, err := tx.ExecContext(r.Context(), `UPDATE folders SET sort_order=? WHERE id=? AND mailbox_id=?`, item.SortOrder, item.ID, mb.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to reorder folders") + return + } + if affected, err := res.RowsAffected(); err != nil || affected != 1 { + badRequest(w, errors.New("invalid folder order")) + return + } + } + if err := tx.Commit(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to reorder folders") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { @@ -130,7 +236,7 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("system folder already exists")) return } - folderID, err := a.ensureFolder(r.Context(), mb.ID, name) + folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name) if err != nil { respondError(w, http.StatusInternalServerError, "failed to create folder") return @@ -143,6 +249,21 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, folder) } +func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) { + return a.ensureFolder(ctx, mailboxID, name) +} + +func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) { + var maxOrder int + if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order),0) FROM folders WHERE mailbox_id=? AND lower(name) NOT IN ('inbox','sent','drafts','archive','spam','trash')`, mailboxID).Scan(&maxOrder); err != nil { + return 0, err + } + if maxOrder < customFolderDefaultSortOrderBase { + maxOrder = customFolderDefaultSortOrderBase + } + return maxOrder + 1, nil +} + func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { @@ -1491,11 +1612,11 @@ func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*Mail row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role, COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, COUNT(m.id) AS total, - f.uid_validity,f.uid_next,f.highest_modseq + f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq FROM folders f LEFT JOIN messages m ON m.folder_id=f.id - WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role`, folderID, mailboxID) + WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID) var f MailFolder - if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { + if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { return nil, err } return &f, nil diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 6c746fc..0713f02 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -66,6 +66,7 @@ func (a *App) Router() http.Handler { r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/folders", a.handleMailFolders) r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/folders", a.handleCreateMailFolder) + r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/folders/reorder", a.handleReorderMailFolders) r.With(a.requireAnyPermission(PermissionMailRead, PermissionMailLabels)).Get("/mail/labels", a.handleMailLabels) r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/labels", a.handleCreateMailLabel) r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/labels/{id}", a.handleDeleteMailLabel) diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index 48ec764..47270bb 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -60,6 +60,7 @@ type MailFolder struct { ID string `json:"id"` Name string `json:"name"` Role string `json:"role"` + SortOrder int `json:"sortOrder"` UnreadCount int `json:"unreadCount"` TotalCount int `json:"totalCount"` UIDValidity int64 `json:"uidValidity"` diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 111de41..f51a322 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -56,7 +56,7 @@ export type AdminOverview = { users: number; activeUsers: number; domains: numbe export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string } export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string } export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string } -export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number } +export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number } export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string } export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number } export type MailMessage = { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 3024337..162d4ed 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -120,6 +120,10 @@ export const api = { const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : "" return request(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) }) }, + reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => { + 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 }) }) + }, 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)}` : "" diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 65f9e27..0f0400a 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -67,11 +67,12 @@ type PendingConfirm = { title: string; description?: string; confirmText: string type MailNotificationState = { latestId: string; latestReceivedAt: string } type ComposeSendIntent = { title: string; description: string; confirmText: string; onConfirm: () => void } type MessageContextMenuState = { message: MailMessage; x: number; y: number } +type FolderDropTarget = { key: string; edge: "before" | "after" | "end" } type MailMenuItem = - | { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number } - | { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number } - | { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number } - | { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number } + | { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number; order: number } + | { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number; order: number } + | { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number; order: number } + | { type: "folder"; key: string; folderId: string; folderName: string; label: string; icon: React.ReactNode; count: number; custom: boolean; order: number } const filterLabels: Record = { all: "全部邮件", @@ -113,6 +114,8 @@ export function MailPage() { const [newLabelEditing, setNewLabelEditing] = React.useState(false) const [messageContextMenu, setMessageContextMenu] = React.useState(null) const [folderDialogOpen, setFolderDialogOpen] = React.useState(false) + const [draggingFolderId, setDraggingFolderId] = React.useState("") + const [folderDropTarget, setFolderDropTarget] = React.useState(null) const sidebarPanelRef = React.useRef(null) const themeMountedRef = React.useRef(false) const mailNotifyStateRef = React.useRef>({}) @@ -296,6 +299,29 @@ export function MailPage() { }, onError: (error) => toast({ title: "创建文件夹失败", description: error instanceof Error ? error.message : "请稍后重试" }), }) + const reorderFolders = useMutation({ + mutationFn: (items: { id: string; sortOrder: number }[]) => api.reorderFolders({ mailboxId: activeMailboxId, folderIds: items.map((item) => item.id), folders: items }), + onMutate: async (items) => { + await qc.cancelQueries({ queryKey: ["folders", activeMailboxId] }) + const previous = qc.getQueryData>(["folders", activeMailboxId]) + qc.setQueryData>(["folders", activeMailboxId], (current) => { + if (!current?.items) return current + const order = new Map(items.map((item) => [item.id, item.sortOrder])) + return { + ...current, + items: current.items + .map((item) => order.has(item.id) ? { ...item, sortOrder: order.get(item.id) || item.sortOrder } : item) + .sort(compareMailFolders), + } + }) + return { previous } + }, + onError: (error, _items, context) => { + if (context?.previous) qc.setQueryData(["folders", activeMailboxId], context.previous) + toast({ title: "文件夹排序失败", description: error instanceof Error ? error.message : "请稍后重试" }) + }, + onSettled: () => qc.invalidateQueries({ queryKey: ["folders", activeMailboxId] }), + }) const retrySendQueue = useMutation({ mutationFn: (item: SendQueueItem) => api.retrySendQueue(item.id), onMutate: (item) => setSendQueuePendingId(item.id), @@ -650,6 +676,69 @@ export function MailPage() { function closeMessageContextMenu() { setMessageContextMenu(null) } + function reorderCustomFolder(draggedId: string, target: FolderDropTarget) { + if (!canOrganizeMail || reorderFolders.isPending) return + const foldersByID = new Map((folders.data?.items || []).map((item) => [item.id, item])) + const dragged = foldersByID.get(draggedId) + if (!dragged || !isCustomMailFolder(dragged)) return + const menuWithoutDragged = mailMenuItems.filter((item) => !(item.type === "folder" && item.folderId === draggedId)) + let insertIndex = menuWithoutDragged.length + if (target.edge !== "end") { + const targetIndex = menuWithoutDragged.findIndex((item) => item.key === target.key) + if (targetIndex < 0) return + insertIndex = target.edge === "before" ? targetIndex : targetIndex + 1 + } + const draggedMenuItem: MailMenuItem = { + type: "folder", + key: dragged.id, + folderId: dragged.id, + folderName: dragged.name, + label: folderLabels[dragged.name] || dragged.name, + icon: folderIcons[dragged.role] || , + count: dragged.name === "Drafts" ? dragged.totalCount : dragged.unreadCount, + custom: true, + order: dragged.sortOrder, + } + const nextMenu = [...menuWithoutDragged] + nextMenu.splice(insertIndex, 0, draggedMenuItem) + const nextFolders = assignCustomFolderOrders(nextMenu) + reorderFolders.mutate(nextFolders) + } + function handleFolderDragStart(event: React.DragEvent, item: MailMenuItem) { + if (item.type !== "folder" || !item.custom || sidebarCollapsed || !canOrganizeMail) return + event.dataTransfer.effectAllowed = "move" + event.dataTransfer.setData("text/plain", item.folderId) + setDraggingFolderId(item.folderId) + } + function handleFolderDragOver(event: React.DragEvent, item: MailMenuItem) { + if (!draggingFolderId || item.key === draggingFolderId) return + event.preventDefault() + event.dataTransfer.dropEffect = "move" + const rect = event.currentTarget.getBoundingClientRect() + setFolderDropTarget({ key: item.key, edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after" }) + } + function handleFolderDrop(event: React.DragEvent, item: MailMenuItem) { + if (!draggingFolderId) return + event.preventDefault() + const draggedId = event.dataTransfer.getData("text/plain") || draggingFolderId + const rect = event.currentTarget.getBoundingClientRect() + const edge = event.clientY < rect.top + rect.height / 2 ? "before" : "after" + setDraggingFolderId("") + setFolderDropTarget(null) + reorderCustomFolder(draggedId, { key: item.key, edge }) + } + function handleFolderDropEnd(event: React.DragEvent) { + if (!draggingFolderId) return + event.preventDefault() + const draggedId = event.dataTransfer.getData("text/plain") || draggingFolderId + setDraggingFolderId("") + setFolderDropTarget(null) + reorderCustomFolder(draggedId, { key: "__end__", edge: "end" }) + } + function clearFolderDragState() { + setDraggingFolderId("") + setFolderDropTarget(null) + } function runMessageContextAction(action: "open" | "reply" | "forward" | "read" | "star" | "archive" | "trash" | "spam" | "delete", message: MailMessage) { closeMessageContextMenu() if (action === "open") { @@ -778,10 +867,25 @@ export function MailPage() { {mailMenuItems.map((item) => ( - + handleFolderDragStart(event, item)} + onDragOver={(event) => handleFolderDragOver(event, item)} + onDragLeave={() => { if (folderDropTarget?.key === item.key) setFolderDropTarget(null) }} + onDrop={(event) => handleFolderDrop(event, item)} + onDragEnd={clearFolderDragState} + > item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : item.type === "sendQueue" ? openSendQueue() : openFolder(item.folderName)} > {item.icon} @@ -790,6 +894,19 @@ export function MailPage() { ))} + {!sidebarCollapsed && canOrganizeMail && ( +
{ + if (!draggingFolderId) return + event.preventDefault() + event.dataTransfer.dropEffect = "move" + setFolderDropTarget({ key: "__end__", edge: "end" }) + }} + onDragLeave={() => { if (folderDropTarget?.edge === "end") setFolderDropTarget(null) }} + onDrop={handleFolderDropEnd} + /> + )} {folders.isLoading && } @@ -1146,27 +1263,84 @@ export function MailPage() { function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean): MailMenuItem[] { const byName = new Map(folders.map((item) => [item.name, item])) - const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), unreadCount: 0, totalCount: 0 }) + const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), sortOrder: 0, unreadCount: 0, totalCount: 0, uidValidity: 0, uidNext: 1, highestModseq: 1 }) for (const item of folders) { if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item) } const folderItems: MailMenuItem[] = normalizedFolders.map((item) => ({ type: "folder", key: item.id, + folderId: item.id, folderName: item.name, label: folderLabels[item.name] || item.name, icon: folderIcons[item.role] || , count: item.name === "Drafts" ? item.totalCount : item.unreadCount, + custom: isCustomMailFolder(item), + order: isCustomMailFolder(item) ? item.sortOrder || 100000 : menuAnchorOrder(item.name), })) - const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: , count: starredCount } - const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: , count: scheduledCount } - const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: , count: sendQueueCount } - const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox") - const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0 + const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: , count: starredCount, order: 2000 } + const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: , count: scheduledCount, order: 3000 } + const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: , count: sendQueueCount, order: 4000 } const specialItems: MailMenuItem[] = [starredItem] if (includeScheduled) specialItems.push(scheduledItem) if (includeSendQueue) specialItems.push(sendQueueItem) - return [...folderItems.slice(0, insertAt), ...specialItems, ...folderItems.slice(insertAt)] + return [...folderItems, ...specialItems].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)) +} + +function isCustomMailFolder(folder: Pick) { + return !folder.id.startsWith("virtual-") && !["inbox", "sent", "drafts", "archive", "spam", "trash"].includes(folder.name.trim().toLowerCase()) +} + +function compareMailFolders(a: MailFolder, b: MailFolder) { + return (isCustomMailFolder(a) ? a.sortOrder || 100000 : menuAnchorOrder(a.name)) - (isCustomMailFolder(b) ? b.sortOrder || 100000 : menuAnchorOrder(b.name)) || a.name.localeCompare(b.name) +} + +function assignCustomFolderOrders(items: MailMenuItem[]) { + const out: { id: string; sortOrder: number }[] = [] + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (!isCustomMenuFolder(item)) continue + let start = 0 + for (let j = i - 1; j >= 0; j--) { + if (!isCustomMenuFolder(items[j])) { + start = items[j].order + break + } + } + const groupStart = i + let groupEnd = i + while (groupEnd + 1 < items.length && isCustomMenuFolder(items[groupEnd + 1])) groupEnd++ + let end = start + (groupEnd - groupStart + 2) * 1000 + for (let j = groupEnd + 1; j < items.length; j++) { + if (!isCustomMenuFolder(items[j])) { + end = items[j].order + break + } + } + const step = Math.max(1, Math.floor((end - start) / (groupEnd - groupStart + 2))) + for (let j = groupStart; j <= groupEnd; j++) { + const folder = items[j] as Extract + out.push({ id: folder.folderId, sortOrder: start + step * (j - groupStart + 1) }) + } + i = groupEnd + } + return out +} + +function isCustomMenuFolder(item: MailMenuItem): item is Extract { + return item.type === "folder" && item.custom +} + +function menuAnchorOrder(name: string) { + switch (name) { + case "Inbox": return 1000 + case "Sent": return 5000 + case "Drafts": return 6000 + case "Archive": return 7000 + case "Spam": return 8000 + case "Trash": return 9000 + default: return 100000 + } } function FolderSkeleton() { return
}