feat(mail): 支持自定义邮件文件夹排序
- 后端为 `folders` 增加 `sort_order`,并提供文件夹重排接口。 - 前端邮件侧边栏支持自定义文件夹拖拽排序与乐观更新。 - 补充文件夹排序相关测试,并同步更新 API 类型定义。
This commit is contained in:
@@ -1149,6 +1149,14 @@ func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (strin
|
|||||||
}
|
}
|
||||||
role := strings.ToLower(folder)
|
role := strings.ToLower(folder)
|
||||||
id = newID("fld")
|
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
|
return id, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||||
uid_next INTEGER NOT NULL DEFAULT 1,
|
uid_next INTEGER NOT NULL DEFAULT 1,
|
||||||
highest_modseq 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 {
|
if err := a.migrateIMAPMetadata(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1131,7 +1135,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
for _, f := range defaultFolderDefs() {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ts := httptest.NewServer(a.Router())
|
ts := httptest.NewServer(a.Router())
|
||||||
@@ -3803,6 +3877,16 @@ func folderListContains(items []MailFolder, name string) bool {
|
|||||||
return false
|
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 {
|
func withoutPermissions(items []string, removed ...string) []string {
|
||||||
removedSet := map[string]bool{}
|
removedSet := map[string]bool{}
|
||||||
for _, item := range removed {
|
for _, item := range removed {
|
||||||
|
|||||||
@@ -44,6 +44,38 @@ func (a *App) migrateIMAPMetadata(ctx context.Context) error {
|
|||||||
return err
|
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 {
|
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
|
||||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import (
|
|||||||
// mailMessagesPageSize is the max number of messages returned per page in mail listing.
|
// mailMessagesPageSize is the max number of messages returned per page in mail listing.
|
||||||
const mailMessagesPageSize = 30
|
const mailMessagesPageSize = 30
|
||||||
|
|
||||||
|
const customFolderDefaultSortOrderBase = 100000
|
||||||
|
|
||||||
type AttachmentInput struct {
|
type AttachmentInput struct {
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
ContentType string `json:"contentType"`
|
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,
|
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,
|
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||||
COUNT(m.id) AS total,
|
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
|
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
|
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 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)
|
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 {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load folders")
|
respondError(w, http.StatusInternalServerError, "failed to load folders")
|
||||||
return
|
return
|
||||||
@@ -99,7 +109,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
items := []MailFolder{}
|
items := []MailFolder{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f MailFolder
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -108,6 +118,102 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
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) {
|
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
if err != nil {
|
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"))
|
badRequest(w, errors.New("system folder already exists"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folderID, err := a.ensureFolder(r.Context(), mb.ID, name)
|
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||||
return
|
return
|
||||||
@@ -143,6 +249,21 @@ func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusCreated, folder)
|
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) {
|
func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
if err != nil {
|
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,
|
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,
|
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||||
COUNT(m.id) AS total,
|
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
|
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
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &f, nil
|
return &f, nil
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func (a *App) Router() http.Handler {
|
|||||||
r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
|
r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
|
||||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/folders", a.handleMailFolders)
|
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", 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.requireAnyPermission(PermissionMailRead, PermissionMailLabels)).Get("/mail/labels", a.handleMailLabels)
|
||||||
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/labels", a.handleCreateMailLabel)
|
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/labels", a.handleCreateMailLabel)
|
||||||
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/labels/{id}", a.handleDeleteMailLabel)
|
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/labels/{id}", a.handleDeleteMailLabel)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type MailFolder struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
UnreadCount int `json:"unreadCount"`
|
UnreadCount int `json:"unreadCount"`
|
||||||
TotalCount int `json:"totalCount"`
|
TotalCount int `json:"totalCount"`
|
||||||
UIDValidity int64 `json:"uidValidity"`
|
UIDValidity int64 `json:"uidValidity"`
|
||||||
|
|||||||
@@ -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 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 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 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 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 MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
||||||
export type MailMessage = {
|
export type MailMessage = {
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ export const api = {
|
|||||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||||
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
|
return request<MailFolder>(`/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<ListResponse<MailLabel>>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
labels: (mailboxId?: string) => request<ListResponse<MailLabel>>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||||
createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => {
|
createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => {
|
||||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||||
|
|||||||
+187
-13
@@ -67,11 +67,12 @@ type PendingConfirm = { title: string; description?: string; confirmText: string
|
|||||||
type MailNotificationState = { latestId: string; latestReceivedAt: string }
|
type MailNotificationState = { latestId: string; latestReceivedAt: string }
|
||||||
type ComposeSendIntent = { title: string; description: string; confirmText: string; onConfirm: () => void }
|
type ComposeSendIntent = { title: string; description: string; confirmText: string; onConfirm: () => void }
|
||||||
type MessageContextMenuState = { message: MailMessage; x: number; y: number }
|
type MessageContextMenuState = { message: MailMessage; x: number; y: number }
|
||||||
|
type FolderDropTarget = { key: string; edge: "before" | "after" | "end" }
|
||||||
type MailMenuItem =
|
type MailMenuItem =
|
||||||
| { type: "starred"; key: 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 }
|
| { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number; order: number }
|
||||||
| { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number }
|
| { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number; order: number }
|
||||||
| { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number }
|
| { type: "folder"; key: string; folderId: string; folderName: string; label: string; icon: React.ReactNode; count: number; custom: boolean; order: number }
|
||||||
|
|
||||||
const filterLabels: Record<MailFilter, string> = {
|
const filterLabels: Record<MailFilter, string> = {
|
||||||
all: "全部邮件",
|
all: "全部邮件",
|
||||||
@@ -113,6 +114,8 @@ export function MailPage() {
|
|||||||
const [newLabelEditing, setNewLabelEditing] = React.useState(false)
|
const [newLabelEditing, setNewLabelEditing] = React.useState(false)
|
||||||
const [messageContextMenu, setMessageContextMenu] = React.useState<MessageContextMenuState | null>(null)
|
const [messageContextMenu, setMessageContextMenu] = React.useState<MessageContextMenuState | null>(null)
|
||||||
const [folderDialogOpen, setFolderDialogOpen] = React.useState(false)
|
const [folderDialogOpen, setFolderDialogOpen] = React.useState(false)
|
||||||
|
const [draggingFolderId, setDraggingFolderId] = React.useState("")
|
||||||
|
const [folderDropTarget, setFolderDropTarget] = React.useState<FolderDropTarget | null>(null)
|
||||||
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
|
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
|
||||||
const themeMountedRef = React.useRef(false)
|
const themeMountedRef = React.useRef(false)
|
||||||
const mailNotifyStateRef = React.useRef<Record<string, MailNotificationState>>({})
|
const mailNotifyStateRef = React.useRef<Record<string, MailNotificationState>>({})
|
||||||
@@ -296,6 +299,29 @@ export function MailPage() {
|
|||||||
},
|
},
|
||||||
onError: (error) => toast({ title: "创建文件夹失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
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<ListResponse<MailFolder>>(["folders", activeMailboxId])
|
||||||
|
qc.setQueryData<ListResponse<MailFolder>>(["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({
|
const retrySendQueue = useMutation({
|
||||||
mutationFn: (item: SendQueueItem) => api.retrySendQueue(item.id),
|
mutationFn: (item: SendQueueItem) => api.retrySendQueue(item.id),
|
||||||
onMutate: (item) => setSendQueuePendingId(item.id),
|
onMutate: (item) => setSendQueuePendingId(item.id),
|
||||||
@@ -650,6 +676,69 @@ export function MailPage() {
|
|||||||
function closeMessageContextMenu() {
|
function closeMessageContextMenu() {
|
||||||
setMessageContextMenu(null)
|
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] || <Inbox className="h-4 w-4" />,
|
||||||
|
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) {
|
function runMessageContextAction(action: "open" | "reply" | "forward" | "read" | "star" | "archive" | "trash" | "spam" | "delete", message: MailMessage) {
|
||||||
closeMessageContextMenu()
|
closeMessageContextMenu()
|
||||||
if (action === "open") {
|
if (action === "open") {
|
||||||
@@ -778,10 +867,25 @@ export function MailPage() {
|
|||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{mailMenuItems.map((item) => (
|
{mailMenuItems.map((item) => (
|
||||||
<SidebarMenuItem key={item.key}>
|
<SidebarMenuItem
|
||||||
|
key={item.key}
|
||||||
|
draggable={item.type === "folder" && item.custom && canOrganizeMail && !sidebarCollapsed}
|
||||||
|
onDragStart={(event) => handleFolderDragStart(event, item)}
|
||||||
|
onDragOver={(event) => handleFolderDragOver(event, item)}
|
||||||
|
onDragLeave={() => { if (folderDropTarget?.key === item.key) setFolderDropTarget(null) }}
|
||||||
|
onDrop={(event) => handleFolderDrop(event, item)}
|
||||||
|
onDragEnd={clearFolderDragState}
|
||||||
|
>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : item.type === "sendQueue" ? mailView === "sendQueue" : mailView === "folder" && folder === item.folderName}
|
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : item.type === "sendQueue" ? mailView === "sendQueue" : mailView === "folder" && folder === item.folderName}
|
||||||
className={cn(sidebarCollapsed && "justify-center px-0")}
|
className={cn(
|
||||||
|
sidebarCollapsed && "justify-center px-0",
|
||||||
|
item.type === "folder" && item.custom && canOrganizeMail && !sidebarCollapsed && "cursor-grab active:cursor-grabbing",
|
||||||
|
item.type === "folder" && item.custom && draggingFolderId === item.folderId && "opacity-50",
|
||||||
|
folderDropTarget?.key === item.key && "bg-accent/60",
|
||||||
|
folderDropTarget?.key === item.key && folderDropTarget.edge === "before" && "border-t-2 border-t-primary",
|
||||||
|
folderDropTarget?.key === item.key && folderDropTarget.edge === "after" && "border-b-2 border-b-primary"
|
||||||
|
)}
|
||||||
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : item.type === "sendQueue" ? openSendQueue() : openFolder(item.folderName)}
|
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : item.type === "sendQueue" ? openSendQueue() : openFolder(item.folderName)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
@@ -790,6 +894,19 @@ export function MailPage() {
|
|||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
|
{!sidebarCollapsed && canOrganizeMail && (
|
||||||
|
<div
|
||||||
|
className={cn("mx-2 h-4 rounded-sm border border-dashed border-transparent", folderDropTarget?.edge === "end" && "border-primary bg-accent/60")}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (!draggingFolderId) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.dataTransfer.dropEffect = "move"
|
||||||
|
setFolderDropTarget({ key: "__end__", edge: "end" })
|
||||||
|
}}
|
||||||
|
onDragLeave={() => { if (folderDropTarget?.edge === "end") setFolderDropTarget(null) }}
|
||||||
|
onDrop={handleFolderDropEnd}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
{folders.isLoading && <FolderSkeleton />}
|
{folders.isLoading && <FolderSkeleton />}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
@@ -1146,27 +1263,84 @@ export function MailPage() {
|
|||||||
|
|
||||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean): MailMenuItem[] {
|
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 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) {
|
for (const item of folders) {
|
||||||
if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item)
|
if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item)
|
||||||
}
|
}
|
||||||
const folderItems: MailMenuItem[] = normalizedFolders.map((item) => ({
|
const folderItems: MailMenuItem[] = normalizedFolders.map((item) => ({
|
||||||
type: "folder",
|
type: "folder",
|
||||||
key: item.id,
|
key: item.id,
|
||||||
|
folderId: item.id,
|
||||||
folderName: item.name,
|
folderName: item.name,
|
||||||
label: folderLabels[item.name] || item.name,
|
label: folderLabels[item.name] || item.name,
|
||||||
icon: folderIcons[item.role] || <Inbox className="h-4 w-4" />,
|
icon: folderIcons[item.role] || <Inbox className="h-4 w-4" />,
|
||||||
count: item.name === "Drafts" ? item.totalCount : item.unreadCount,
|
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: <Star className="h-4 w-4" />, count: starredCount }
|
const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: <Star className="h-4 w-4" />, count: starredCount, order: 2000 }
|
||||||
const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: <Clock3 className="h-4 w-4" />, count: scheduledCount }
|
const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: <Clock3 className="h-4 w-4" />, count: scheduledCount, order: 3000 }
|
||||||
const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: <History className="h-4 w-4" />, count: sendQueueCount }
|
const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: <History className="h-4 w-4" />, count: sendQueueCount, order: 4000 }
|
||||||
const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox")
|
|
||||||
const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0
|
|
||||||
const specialItems: MailMenuItem[] = [starredItem]
|
const specialItems: MailMenuItem[] = [starredItem]
|
||||||
if (includeScheduled) specialItems.push(scheduledItem)
|
if (includeScheduled) specialItems.push(scheduledItem)
|
||||||
if (includeSendQueue) specialItems.push(sendQueueItem)
|
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<MailFolder, "name" | "id">) {
|
||||||
|
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<MailMenuItem, { type: "folder" }>
|
||||||
|
out.push({ id: folder.folderId, sortOrder: start + step * (j - groupStart + 1) })
|
||||||
|
}
|
||||||
|
i = groupEnd
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustomMenuFolder(item: MailMenuItem): item is Extract<MailMenuItem, { type: "folder" }> {
|
||||||
|
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 <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
|
function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
|
||||||
|
|||||||
Reference in New Issue
Block a user