From 50d7c091bfa221bf262e74f7fe7f5cc00b95f7f6 Mon Sep 17 00:00:00 2001
From: zxyszx <299979470+zxyszx@users.noreply.github.com>
Date: Sun, 2 Aug 2026 11:23:44 +0800
Subject: [PATCH] fix: align mailbox unread and bulk actions
---
apps/api/internal/app/app_test.go | 35 +++++++
apps/api/internal/app/mail_handlers.go | 8 +-
apps/api/internal/app/types.go | 1 +
apps/web/src/lib/api-types.ts | 2 +-
apps/web/src/pages/mail.tsx | 139 +++++++++++++++++--------
5 files changed, 137 insertions(+), 48 deletions(-)
diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go
index 78c875e..67e03ec 100644
--- a/apps/api/internal/app/app_test.go
+++ b/apps/api/internal/app/app_test.go
@@ -1379,6 +1379,34 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
t.Fatalf("mailboxes were not bound to one user: primary=%s secondary=%s", primary.UserID, secondary.UserID)
}
+ ctx := context.Background()
+ now := a.now().UTC().Format(time.RFC3339Nano)
+ primaryInboxID, err := a.ensureFolder(ctx, primary.ID, "Inbox")
+ if err != nil {
+ t.Fatal(err)
+ }
+ primaryArchiveID, err := a.ensureFolder(ctx, primary.ID, "Archive")
+ if err != nil {
+ t.Fatal(err)
+ }
+ secondaryInboxID, err := a.ensureFolder(ctx, secondary.ID, "Inbox")
+ if err != nil {
+ t.Fatal(err)
+ }
+ insertMessage := func(id, mailboxID, folderID, subject string, read int) {
+ t.Helper()
+ if _, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,created_at,updated_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
+ id, mailboxID, folderID, "", id+"-uid", "<"+id+"@example.test>", subject, "sender@example.test", "", jsonEncode([]string{"recipient@example.test"}), "[]", "[]", now, now, subject, "", "", read, 0, 0, 0, now, now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ insertMessage("msg_multi_primary_unread_1", primary.ID, primaryInboxID, "primary unread one", 0)
+ insertMessage("msg_multi_primary_unread_2", primary.ID, primaryInboxID, "primary unread two", 0)
+ insertMessage("msg_multi_primary_read", primary.ID, primaryInboxID, "primary read", 1)
+ insertMessage("msg_multi_primary_archived", primary.ID, primaryArchiveID, "primary archived unread", 0)
+ insertMessage("msg_multi_secondary_unread", secondary.ID, secondaryInboxID, "secondary unread", 0)
+
userClient := &testClient{t: t, server: ts}
if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("user login=%d", code)
@@ -1389,6 +1417,13 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
if code := userClient.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 2 {
t.Fatalf("my mailboxes code=%d items=%d", code, len(mine.Items))
}
+ unreadByAddress := map[string]int{}
+ for _, item := range mine.Items {
+ unreadByAddress[item.Address] = item.UnreadCount
+ }
+ if unreadByAddress[primary.Address] != 2 || unreadByAddress[secondary.Address] != 1 {
+ t.Fatalf("mailbox unread counts=%+v, want %s=2 %s=1", unreadByAddress, primary.Address, secondary.Address)
+ }
if code := userClient.do("GET", "/api/mail/folders?mailboxId="+secondary.ID, nil, nil); code != http.StatusOK {
t.Fatalf("folders for selected mailbox code=%d", code)
}
diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go
index ba9d4e1..e9730d7 100644
--- a/apps/api/internal/app/mail_handlers.go
+++ b/apps/api/internal/app/mail_handlers.go
@@ -59,10 +59,14 @@ type storedMessage struct {
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
- rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
+ rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at,
+ COALESCE(SUM(CASE WHEN lower(f.name)='inbox' AND m.is_read=0 THEN 1 ELSE 0 END),0) AS unread_count
FROM mailboxes mb
JOIN domains d ON d.id=mb.domain_id
+ LEFT JOIN folders f ON f.mailbox_id=mb.id
+ LEFT JOIN messages m ON m.folder_id=f.id
WHERE mb.user_id=? AND mb.status='active' AND d.status='active'
+ GROUP BY mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
ORDER BY mb.address`, user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailboxes")
@@ -73,7 +77,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
for rows.Next() {
var m Mailbox
var created string
- if err := rows.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
+ if err := rows.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created, &m.UnreadCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
return
}
diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go
index a0cf9fb..9d85883 100644
--- a/apps/api/internal/app/types.go
+++ b/apps/api/internal/app/types.go
@@ -69,6 +69,7 @@ type Mailbox struct {
DisplayName string `json:"displayName"`
QuotaMB int `json:"quotaMb"`
Status string `json:"status"`
+ UnreadCount int `json:"unreadCount"`
CreatedAt time.Time `json:"createdAt"`
}
diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts
index 95a265c..192bc5d 100644
--- a/apps/web/src/lib/api-types.ts
+++ b/apps/web/src/lib/api-types.ts
@@ -55,7 +55,7 @@ export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresA
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
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; unreadCount?: number; 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; 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 }
diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx
index 92c2549..819ad6b 100644
--- a/apps/web/src/pages/mail.tsx
+++ b/apps/web/src/pages/mail.tsx
@@ -649,6 +649,8 @@ export function MailPage() {
const emptyMessage = getEmptyMessage(mailView, mailView === "external" ? externalFolder : folder, allMessages.length)
const visibleMessageIds = visibleMessages.map((message) => message.id)
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
+ const selectedMessagesOnPage = visibleMessages.filter((message) => compactSelectedIds.includes(message.id))
+ const bulkReadAction: BulkAction = selectedMessagesOnPage.some((message) => !message.isRead) ? "read" : "unread"
const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length
const compactSomeSelected = selectedCountOnPage > 0 && !compactAllSelected
const hasMoreMessages = mailView === "external" ? !!externalMessages.hasNextPage : !!messages.hasNextPage
@@ -699,7 +701,7 @@ export function MailPage() {
} else if (action === "delete") {
await Promise.all(ids.map((id) => api.delete(id)))
} else {
- const target = action === "archive" ? "Archive" : action === "trash" ? "Trash" : "Spam"
+ const target = action === "archive" ? "Archive" : action === "inbox" ? "Inbox" : action === "trash" ? "Trash" : "Spam"
await Promise.all(ids.map((id) => api.move(id, target)))
}
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
@@ -713,6 +715,23 @@ export function MailPage() {
setBulkPending(false)
}
}
+ async function runBulkMoveToFolder(folderName: string) {
+ if (!canOrganizeMail) return
+ const ids = compactSelectedIds.filter((id) => visibleMessageIds.includes(id))
+ if (ids.length === 0) return
+ setBulkPending(true)
+ try {
+ await Promise.all(ids.map((id) => api.move(id, folderName)))
+ if (selectedId && ids.includes(selectedId)) setSelectedId(null)
+ setCompactSelectedIds([])
+ await refreshMailData()
+ toast({ title: folderName === "Inbox" ? `已将 ${ids.length} 封邮件移回收件箱` : `已移动 ${ids.length} 封邮件` })
+ } catch (error) {
+ toast({ title: "批量移动失败", description: error instanceof Error ? error.message : "请稍后重试" })
+ } finally {
+ setBulkPending(false)
+ }
+ }
function confirmDeleteMessage(message: MailMessage) {
setPendingConfirm({
title: "删除这封邮件?",
@@ -1136,7 +1155,8 @@ export function MailPage() {
onClick={() => activateSidebarItem(item)}
>
{item.icon}
- {!sidebarCollapsed && {item.label}}
+ {!sidebarCollapsed && {item.label}}
+ {!sidebarCollapsed &&