diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 1249362..9f296c0 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -1047,6 +1047,59 @@ func TestCustomMailFoldersCreateAndMove(t *testing.T) { } } +func TestCustomMailFoldersDeleteMovesMessagesToInbox(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) + } + var custom MailFolder + if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "临时项目"}, &custom); code != http.StatusCreated { + t.Fatalf("create custom folder code=%d folder=%+v", code, custom) + } + var sent MailMessage + if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "delete folder keeps message", "text": "body"}, &sent); code != http.StatusCreated { + t.Fatalf("send code=%d msg=%+v", code, sent) + } + var ok map[string]any + if code := admin.do("POST", "/api/mail/messages/"+sent.ID+"/move", map[string]string{"folder": "临时项目"}, &ok); code != http.StatusOK { + t.Fatalf("move to custom folder code=%d body=%v", code, ok) + } + if code := admin.do("DELETE", "/api/mail/folders/"+custom.ID, nil, &ok); code != http.StatusOK { + t.Fatalf("delete custom folder 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 || folderListContains(folders.Items, "临时项目") { + t.Fatalf("folder should be deleted code=%d items=%+v", code, folders.Items) + } + var inbox struct { + Items []MailMessage `json:"items"` + } + if code := admin.do("GET", "/api/mail/messages?folder=Inbox&q="+url.QueryEscape("delete folder keeps message"), nil, &inbox); code != http.StatusOK || len(inbox.Items) == 0 || inbox.Items[0].ID != sent.ID { + t.Fatalf("message should be moved to inbox code=%d items=%+v", code, inbox.Items) + } + var bad map[string]any + var inboxID string + for _, item := range folders.Items { + if item.Name == "Inbox" { + inboxID = item.ID + break + } + } + if inboxID == "" { + t.Fatalf("inbox id not found") + } + if code := admin.do("DELETE", "/api/mail/folders/"+inboxID, nil, &bad); code != http.StatusBadRequest { + t.Fatalf("delete system folder should be rejected code=%d body=%v", code, bad) + } +} + func TestCustomMailFoldersReorder(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 5b53ad4..e0afca3 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -249,6 +249,86 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, folder) } +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 + } + 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) { + respondError(w, http.StatusNotFound, "folder not found") + return + } + respondError(w, http.StatusInternalServerError, "failed to load folder") + return + } + if isSystemFolderName(folderName) { + badRequest(w, errors.New("system folders cannot be deleted")) + return + } + inboxID, err := a.ensureFolder(r.Context(), mb.ID, "Inbox") + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load inbox") + return + } + now := a.now().UTC().Format(time.RFC3339Nano) + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete folder") + return + } + defer tx.Rollback() + rows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, mb.ID, folderID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load folder messages") + return + } + var messageIDs []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + respondError(w, http.StatusInternalServerError, "failed to scan folder messages") + return + } + messageIDs = append(messageIDs, id) + } + if err := rows.Err(); err != nil { + rows.Close() + respondError(w, http.StatusInternalServerError, "failed to scan folder messages") + return + } + rows.Close() + for _, messageID := range messageIDs { + meta, err := a.nextIMAPMetadata(r.Context(), tx, 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=?`, inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to move folder messages") + return + } + } + if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID); 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 + } + _, _ = a.bumpFolderModSeq(r.Context(), inboxID) + respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)}) +} + func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) { return a.ensureFolder(ctx, mailboxID, name) } diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 0713f02..3208826 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -67,6 +67,7 @@ func (a *App) Router() http.Handler { 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.requirePermission(PermissionMailOrganize)).Delete("/mail/folders/{id}", a.handleDeleteMailFolder) 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/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 162d4ed..789e525 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -124,6 +124,7 @@ 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" }), 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 c94b51d..1ec4db0 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -324,6 +324,19 @@ export function MailPage() { }, onSettled: () => qc.invalidateQueries({ queryKey: ["folders", activeMailboxId] }), }) + const deleteFolder = useMutation({ + mutationFn: (item: Extract) => api.deleteFolder(item.folderId, activeMailboxId), + onSuccess: async (result, item) => { + setPendingConfirm(null) + if (mailView === "folder" && folder === item.folderName) { + setFolder("Inbox") + setSelectedId(null) + } + await refreshMailData() + toast({ title: "文件夹已删除", description: result.moved > 0 ? `已将 ${result.moved} 封邮件移回收件箱` : undefined }) + }, + onError: (error) => toast({ title: "删除文件夹失败", description: error instanceof Error ? error.message : "请稍后重试" }), + }) const retrySendQueue = useMutation({ mutationFn: (item: SendQueueItem) => api.retrySendQueue(item.id), onMutate: (item) => setSendQueuePendingId(item.id), @@ -737,6 +750,15 @@ export function MailPage() { if (!target) return reorderCustomFolder(item.folderId, { key: target.key, edge: action === "up" ? "before" : "after" }) } + function confirmDeleteFolder(item: MailMenuItem) { + if (item.type !== "folder" || !item.custom) return + setPendingConfirm({ + title: `删除文件夹“${item.label}”?`, + description: "文件夹内的邮件会移回收件箱,不会被删除。", + confirmText: "删除文件夹", + onConfirm: () => deleteFolder.mutate(item), + }) + } function handleFolderDragStart(event: React.DragEvent, item: MailMenuItem) { if (item.type !== "folder" || !item.custom || sidebarCollapsed || !canOrganizeMail) return event.dataTransfer.effectAllowed = "move" @@ -1296,6 +1318,10 @@ export function MailPage() { closeSidebarContextMenu() moveSidebarFolder(item, action) }} + onDelete={(item) => { + closeSidebarContextMenu() + confirmDeleteFolder(item) + }} /> void; onOpen: (item: MailMenuItem) => void; onRefresh: () => void; onCreateFolder: () => void; onMove: (item: MailMenuItem, action: "top" | "up" | "down" | "bottom") => void }) { +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 }) { React.useEffect(() => { if (!state) return const close = () => onClose() @@ -1778,6 +1804,10 @@ function SidebarContextMenu({ state, canOrganize, pending, onClose, onOpen, onRe +
+ )}