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 && } ))} @@ -1242,8 +1262,8 @@ export function MailPage() { onClick={() => activateSidebarItem(item)} > {item.icon} - {!sidebarCollapsed && {item.label}} - {!sidebarCollapsed && item.count > 0 && {item.count}} + {!sidebarCollapsed && {item.label}} + {!sidebarCollapsed && } ))} @@ -1489,12 +1509,12 @@ export function MailPage() { {selectedCountOnPage > 0 && canOrganizeMail && (
已选 {selectedCountOnPage} 封 - +
)} {(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && } - {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} canOrganize={canOrganizeMail} />)} + {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} onArchive={() => move.mutate({ id: m.id, folder: m.folder === "Archive" ? "Inbox" : "Archive" })} onTrash={() => move.mutate({ id: m.id, folder: "Trash" })} onToggleRead={() => markRead.mutate({ id: m.id, read: !m.isRead })} canOrganize={canOrganizeMail} />)} {!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && visibleMessages.length === 0 &&
{emptyMessage}
} {!(mailView === "external" ? externalMessages.isLoading : messages.isLoading) && hasMoreMessages && (
@@ -2254,39 +2274,41 @@ function datetimeLocalToISO(value: string) { return Number.isNaN(date.getTime()) ? "" : date.toISOString() } -type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete" +type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "inbox" | "trash" | "spam" | "delete" -function BulkActionToolbar({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) { +function BulkActionToolbar({ pending, currentFolder, folders = [], readAction = "read", onAction, onMoveToFolder }: { pending: boolean; currentFolder?: string; folders?: MailFolder[]; readAction?: "read" | "unread"; onAction: (action: BulkAction) => void; onMoveToFolder?: (folderName: string) => void }) { const buttonClass = "h-7 w-7 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" + const archiveAction: BulkAction = currentFolder === "Archive" ? "inbox" : "archive" + const archiveLabel = currentFolder === "Archive" ? "移回收件箱" : "归档" + const movableFolders = folders.filter((folder) => folder.name !== currentFolder && folder.name !== "Drafts") return (
- - - - - + + + {movableFolders.map((folder) => ( + onMoveToFolder(folder.name)}> + {folderIcons[folder.role] || } + {folderLabels[folder.name] || folder.name} + + ))} + + + )} + - - - - - - onAction("unstar")}>取消星标 - onAction("spam")}>移入垃圾邮件 - onAction("delete")} className="text-destructive">彻底删除 - -
) } @@ -3060,11 +3082,23 @@ function AccountHeader({ collapsed, name, email, darkMode, language, onToggleThe ) } +function UnreadBadge({ count, tone = "danger" }: { count?: number; tone?: "danger" | "muted" }) { + if (!count || count <= 0) return null + return ( + + {count > 99 ? "99+" : count} + + ) +} + function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMailbox, fallbackAddress, unreadCount, onSelect }: { collapsed: boolean; mailboxes: Mailbox[]; selectedMailboxId: string; selectedMailbox?: Mailbox; fallbackAddress?: string; unreadCount: number; onSelect: (mailboxId: string) => void }) { const [mailboxQuery, setMailboxQuery] = React.useState("") const isAllSelected = selectedMailboxId === "all" const displayAddress = isAllSelected ? "全部邮箱" : selectedMailbox?.address || fallbackAddress || "选择邮箱" - const displayUnreadCount = Math.min(unreadCount, 99) + const selectedUnreadCount = isAllSelected ? unreadCount : selectedMailbox?.unreadCount || 0 const normalizedQuery = mailboxQuery.trim().toLowerCase() const showAllMailboxOption = !normalizedQuery || "全部邮箱".includes(normalizedQuery) || "all".includes(normalizedQuery) const filteredMailboxes = React.useMemo(() => { @@ -3082,17 +3116,13 @@ function MailboxSwitcher({ collapsed, mailboxes, selectedMailboxId, selectedMail {!collapsed && ( <> {displayAddress} - {isAllSelected && unreadCount > 0 && ( - - {unreadCount > 99 ? "99+" : displayUnreadCount} - - )} + )} - + {mailboxes.length > 0 && (
onSelect("all")} className={cn("h-8 gap-2 rounded-sm px-2 text-[13px] font-normal", isAllSelected && "bg-accent text-accent-foreground")}> 全部邮箱 - {unreadCount > 0 && ( - - {unreadCount > 99 ? "99+" : displayUnreadCount} - - )} + )} {filteredMailboxes.map((mailbox) => ( onSelect(mailbox.id)} className={cn("h-8 min-w-0 gap-2 rounded-sm px-2 text-[13px] font-normal", !isAllSelected && selectedMailbox?.id === mailbox.id && "bg-accent text-accent-foreground")}> {mailbox.address} + ))} {mailboxes.length > 0 && !showAllMailboxOption && filteredMailboxes.length === 0 && ( @@ -3334,6 +3361,9 @@ function MessageRow({ onClick, onContextMenu, onStar, + onArchive, + onTrash, + onToggleRead, canOrganize, }: { message: MailMessage @@ -3344,12 +3374,18 @@ function MessageRow({ onClick: () => void onContextMenu: (event: React.MouseEvent) => void onStar: () => void + onArchive: () => void + onTrash: () => void + onToggleRead: () => void canOrganize: boolean }) { const visibleLabels = (message.labels || []).slice(0, 2) const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0) const senderName = senderDisplayName(message) - return
+ const quickActionsVisible = checked || active + const quickButtonClass = "h-6 w-6 text-muted-foreground hover:bg-accent hover:text-foreground" + const archiveLabel = message.folder === "Archive" ? "移回收件箱" : "归档" + return
{senderName}
-
+
+ {canOrganize && ( +
+ + + +
+ )} {canOrganize && } -
{formatDate(message.receivedAt)}
+
{formatDate(message.receivedAt)}