feat(mail): 支持自定义邮件文件夹排序

- 后端为 `folders` 增加 `sort_order`,并提供文件夹重排接口。
- 前端邮件侧边栏支持自定义文件夹拖拽排序与乐观更新。
- 补充文件夹排序相关测试,并同步更新 API 类型定义。
This commit is contained in:
LanQin_
2026-06-25 15:42:36 +08:00
parent 7799d5d5b2
commit d2a762a426
10 changed files with 453 additions and 24 deletions
+9 -1
View File
@@ -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
}
+5 -1
View File
@@ -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
}
+84
View File
@@ -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 {
+32
View File
@@ -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 {
+129 -8
View File
@@ -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
+1
View File
@@ -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)
+1
View File
@@ -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"`