diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index b3cbff6b..8512dbbc 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -108,6 +108,7 @@ export default function Home() {
selectedKeyword,
selectKeyword,
hasMoreEmails,
+ fetchTagCounts,
} = useEmailStore();
// Keyboard shortcuts handlers
@@ -299,6 +300,9 @@ export default function Home() {
await fetchEmails(client);
}
+ // Fetch tag counts
+ fetchTagCounts(client);
+
// Setup push notifications after successful data load
try {
// Register state change callback
@@ -330,7 +334,7 @@ export default function Home() {
client.closePushNotifications();
}
};
- }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, handleStateChange, setPushConnected]);
+ }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
// Handle mark-as-read with delay based on settings
useEffect(() => {
@@ -587,6 +591,9 @@ export default function Home() {
// Refresh emails list to show color in list
await fetchEmails(client, selectedMailbox);
+
+ // Refresh tag counts
+ fetchTagCounts(client);
} catch (error) {
console.error("Failed to set color tag:", error);
}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 86576200..9c4859fc 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -34,6 +34,7 @@ import { useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
import { useVacationStore } from "@/stores/vacation-store";
import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
+import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
@@ -227,11 +228,15 @@ function TagItem({
isSelected,
isCollapsed,
onTagSelect,
+ totalCount,
+ unreadCount,
}: {
kw: KeywordDefinition;
isSelected: boolean;
isCollapsed: boolean;
onTagSelect?: (keywordId: string | null) => void;
+ totalCount: number;
+ unreadCount: number;
}) {
const t = useTranslations('notifications');
const palette = KEYWORD_PALETTE[kw.color];
@@ -272,7 +277,26 @@ function TagItem({
title={isCollapsed ? kw.label : undefined}
>
- {!isCollapsed && {kw.label}}
+ {!isCollapsed && (
+ <>
+ {kw.label}
+
+ {unreadCount > 0 && (
+
+ {unreadCount}
+
+ )}
+
+ {totalCount}
+
+
+ >
+ )}
);
@@ -321,6 +345,7 @@ export function Sidebar({
} catch { return true; }
});
const emailKeywords = useSettingsStore(s => s.emailKeywords);
+ const tagCounts = useEmailStore(s => s.tagCounts);
const t = useTranslations('sidebar');
useEffect(() => {
@@ -532,6 +557,8 @@ export function Sidebar({
isSelected={isSelected}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
+ totalCount={tagCounts[kw.id]?.total ?? 0}
+ unreadCount={tagCounts[kw.id]?.unread ?? 0}
/>
);
})}
diff --git a/hooks/use-tag-drop.ts b/hooks/use-tag-drop.ts
index 2e80b487..c90c68c1 100644
--- a/hooks/use-tag-drop.ts
+++ b/hooks/use-tag-drop.ts
@@ -5,6 +5,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
+
interface UseTagDropOptions {
tagId: string;
onSuccess?: (count: number, tagLabel: string) => void;
@@ -25,7 +26,7 @@ interface UseTagDropReturn {
export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): UseTagDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
- const { fetchEmails, selectedMailbox } = useEmailStore();
+ const { fetchEmails, fetchTagCounts, selectedMailbox } = useEmailStore();
const { isDragging, endDrag } = useDragDropContext();
const handleDragOver = useCallback((e: DragEvent) => {
@@ -93,6 +94,9 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
// Refresh the email list
await fetchEmails(client, selectedMailbox);
+ // Refresh tag counts
+ fetchTagCounts(client);
+
onSuccess?.(emailIds.length, tagId);
} catch (error) {
console.error("Failed to tag emails:", error);
@@ -100,7 +104,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
} finally {
endDrag();
}
- }, [client, isDragging, tagId, fetchEmails, selectedMailbox, endDrag, onSuccess, onError]);
+ }, [client, isDragging, tagId, fetchEmails, fetchTagCounts, selectedMailbox, endDrag, onSuccess, onError]);
return {
dropHandlers: {
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index ecc79d83..0f5b3726 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -548,6 +548,53 @@ export class JMAPClient {
}
}
+ async getTagCounts(tagIds: string[]): Promise> {
+ if (tagIds.length === 0) return {};
+ try {
+ const methodCalls: JMAPMethodCall[] = [];
+ for (let i = 0; i < tagIds.length; i++) {
+ const keyword = `$label:${tagIds[i]}`;
+ // Total count for this tag
+ methodCalls.push(["Email/query", {
+ accountId: this.accountId,
+ filter: { hasKeyword: keyword },
+ limit: 0,
+ calculateTotal: true,
+ }, `total_${i}`]);
+ // Unread count for this tag
+ methodCalls.push(["Email/query", {
+ accountId: this.accountId,
+ filter: {
+ operator: "AND",
+ conditions: [
+ { hasKeyword: keyword },
+ { notKeyword: "$seen" },
+ ],
+ },
+ limit: 0,
+ calculateTotal: true,
+ }, `unread_${i}`]);
+ }
+
+ const response = await this.request(methodCalls);
+ const result: Record = {};
+
+ for (let i = 0; i < tagIds.length; i++) {
+ const totalResp = response.methodResponses?.[i * 2]?.[1];
+ const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1];
+ result[tagIds[i]] = {
+ total: totalResp?.total ?? 0,
+ unread: unreadResp?.total ?? 0,
+ };
+ }
+
+ return result;
+ } catch (error) {
+ console.error('Failed to get tag counts:', error);
+ return {};
+ }
+ }
+
async getEmail(emailId: string, accountId?: string): Promise {
try {
const targetAccountId = accountId || this.accountId;
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 5fd2eafe..a6c198da 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -31,6 +31,7 @@ interface EmailStore {
// Keyword/tag filter
selectedKeyword: string | null;
+ tagCounts: Record;
// Advanced search state
searchFilters: SearchFilters;
@@ -47,6 +48,7 @@ interface EmailStore {
setSearchQuery: (query: string) => void;
setQuota: (quota: { used: number; total: number } | null) => void;
selectKeyword: (keyword: string | null) => void;
+ fetchTagCounts: (client: JMAPClient) => Promise;
toggleEmailSelection: (emailId: string) => void;
selectRangeEmails: (targetEmailId: string) => void;
lastSelectedEmailId: string | null;
@@ -143,6 +145,7 @@ export const useEmailStore = create((set, get) => ({
// Keyword/tag filter
selectedKeyword: null,
+ tagCounts: {},
// Advanced search state
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
@@ -162,6 +165,20 @@ export const useEmailStore = create((set, get) => ({
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
}),
+ fetchTagCounts: async (client) => {
+ try {
+ const keywords = useSettingsStore.getState().emailKeywords;
+ if (keywords.length === 0) {
+ set({ tagCounts: {} });
+ return;
+ }
+ const tagIds = keywords.map(k => k.id);
+ const counts = await client.getTagCounts(tagIds);
+ set({ tagCounts: counts });
+ } catch (error) {
+ console.error('Failed to fetch tag counts:', error);
+ }
+ },
selectMailbox: (mailboxId) => set({
selectedMailbox: mailboxId,
selectedEmail: null,
@@ -1040,6 +1057,7 @@ export const useEmailStore = create((set, get) => ({
// Handle Email state changes - refresh current mailbox
if (accountChanges.Email) {
await get().refreshCurrentMailbox(client);
+ get().fetchTagCounts(client);
}
// Handle Mailbox state changes - refresh mailbox list