From fd700f412e30b999a2b424ba7f764f1345b8274c Mon Sep 17 00:00:00 2001 From: Max Hao Date: Thu, 11 Jun 2026 19:49:12 -0400 Subject: [PATCH] fix: fix inconsistent behavior with threading email messages in the inbox/folders --- app/(main)/[locale]/page.tsx | 39 ++++- components/email/email-list.tsx | 10 +- components/pro/pro-compose-tab-body.tsx | 20 ++- lib/demo/demo-client.ts | 10 ++ lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 18 ++ lib/thread-utils.ts | 12 +- stores/email-store.ts | 212 +++++++++++++++++++++++- 8 files changed, 309 insertions(+), 13 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 1d081eb9..a575d1e2 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -335,6 +335,7 @@ export default function Home() { viewingAccountId, selectAccountMailbox, setViewingAccount, + refreshCurrentMailbox, } = useEmailStore(); // Pro shell: populate per-account mailbox cache so the sidebar can render @@ -1168,7 +1169,26 @@ export default function Home() { } // Refresh the current mailbox to update the UI - if (!isScheduledView) await fetchEmails(client, selectedMailbox); + if (!isScheduledView) { + await refreshCurrentMailbox(client); + // Re-fetch the replied thread's cross-folder data so the expanded + // view shows the newly sent reply without collapsing. + if (originalEmailId) { + const emailState = useEmailStore.getState(); + const repliedEmail = emailState.emails.find(e => e.id === originalEmailId); + if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) { + const accountId = client.getAccountId(); + const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId); + if (fullEmails.length > 0) { + useEmailStore.setState((state) => { + const c = new Map(state.threadEmailsCache); + c.set(repliedEmail.threadId!, fullEmails); + return { threadEmailsCache: c }; + }); + } + } + } + } } catch (error) { console.error("Failed to send email:", error); } @@ -2201,7 +2221,22 @@ export default function Home() { } // Refresh emails to show the sent reply - await fetchEmails(client, selectedMailbox); + await refreshCurrentMailbox(client); + // Re-fetch the replied thread's cross-folder data so the expanded + // view shows the newly sent reply without collapsing. + const emailState = useEmailStore.getState(); + const repliedEmail = emailState.emails.find(e => e.id === originalEmailId); + if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) { + const accountId = client.getAccountId(); + const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId); + if (fullEmails.length > 0) { + useEmailStore.setState((state) => { + const c = new Map(state.threadEmailsCache); + c.set(repliedEmail.threadId!, fullEmails); + return { threadEmailsCache: c }; + }); + } + } }; // Show loading state while checking auth diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 80753f58..5a781e85 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -100,6 +100,8 @@ export function EmailList({ isLoadingThread, toggleThreadExpansion, fetchThreadEmails, + markThreadAsRead, + threadEmailCounts, searchFilters, setSearchFilters, clearSearchFilters, @@ -110,9 +112,9 @@ export function EmailList({ const disableThreading = useSettingsStore((state) => state.disableThreading); const threadGroups = useMemo(() => { - const groups = groupEmailsByThread(emails, disableThreading || isScheduledView); + const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts); return sortThreadGroups(groups); - }, [emails, disableThreading, isScheduledView]); + }, [emails, disableThreading, isScheduledView, threadEmailCounts]); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); @@ -250,10 +252,12 @@ export function EmailList({ if (!isExpanded && client) { toggleThreadExpansion(threadId); await fetchThreadEmails(client, threadId); + // Mark all unread emails in this thread as read + void markThreadAsRead(client, threadId); } else { toggleThreadExpansion(threadId); } - }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]); + }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]); // Range-based load more: trigger when last visible item is near the end. // Debounce to prevent rapid cascade when thread grouping reduces item diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index ecbca751..49dd46d1 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -26,6 +26,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { const client = useAuthStore((s) => s.client); const sendEmail = useEmailStore((s) => s.sendEmail); const fetchEmails = useEmailStore((s) => s.fetchEmails); + const refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox); const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails); const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata); const selectedMailbox = useEmailStore((s) => s.selectedMailbox); @@ -92,7 +93,24 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { // Refresh the currently-active mail list so the new sent message / // updated keyword status shows up. - await fetchEmails(client, selectedMailbox); + await refreshCurrentMailbox(client); + // Re-fetch the replied thread's cross-folder data so the expanded + // view shows the newly sent reply without collapsing. + if (data.sourceEmailId) { + const emailState = useEmailStore.getState(); + const repliedEmail = emailState.emails.find(e => e.id === data.sourceEmailId); + if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) { + const accountId = client.getAccountId(); + const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId); + if (fullEmails.length > 0) { + useEmailStore.setState((state) => { + const c = new Map(state.threadEmailsCache); + c.set(repliedEmail.threadId!, fullEmails); + return { threadEmailsCache: c }; + }); + } + } + } closeTab(tabIdRef.current); } catch (error) { console.error('Failed to send email:', error); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 2288490d..cc20d52d 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -388,6 +388,16 @@ export class DemoJMAPClient implements IJMAPClient { return { id: threadId, emailIds: emails.map(e => e.id) }; } + async getThreads(threadIds: string[]): Promise { + return threadIds + .map(tid => { + const emails = this.data.emails.filter(e => e.threadId === tid); + if (emails.length === 0) return null; + return { id: tid, emailIds: emails.map(e => e.id) }; + }) + .filter((t): t is Thread => t !== null); + } + async getThreadEmails(threadId: string): Promise { return this.data.emails .filter(e => e.threadId === threadId) diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 6a55a8f5..0624861e 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -117,6 +117,7 @@ export interface IJMAPClient { // ── Threads ─────────────────────────────────────────────────── getThread(threadId: string, accountId?: string): Promise; + getThreads(threadIds: string[], accountId?: string): Promise; getThreadEmails(threadId: string, accountId?: string): Promise; // ── Compose / Send ──────────────────────────────────────────── diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 4bf02286..7440d54c 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1904,6 +1904,24 @@ export class JMAPClient implements IJMAPClient { } } + async getThreads(threadIds: string[], accountId?: string): Promise { + if (threadIds.length === 0) return []; + try { + const targetAccountId = accountId || this.accountId; + const response = await this.request([ + ["Thread/get", { accountId: targetAccountId, ids: threadIds }, "0"], + ]); + + if (response.methodResponses?.[0]?.[0] === "Thread/get") { + return (response.methodResponses[0][1].list || []) as Thread[]; + } + return []; + } catch (error) { + console.error('Failed to get threads:', error); + return []; + } + } + async getThreadEmails(threadId: string, accountId?: string): Promise { try { const targetAccountId = accountId || this.accountId; diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index 65dfbae0..45ced51d 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -5,8 +5,16 @@ import type { Email, ThreadGroup } from "./jmap/types"; * Single-email threads are still returned as ThreadGroups with emailCount=1. * When disableThreading is true, each email is placed into its own group using * its message ID as the key, so the list shows individual messages. + * + * @param threadEmailCounts - Optional map of threadId → total email count across + * all folders (from Thread/get). When provided, emailCount reflects the full + * thread size rather than just the emails in the current folder. */ -export function groupEmailsByThread(emails: Email[], disableThreading = false): ThreadGroup[] { +export function groupEmailsByThread( + emails: Email[], + disableThreading = false, + threadEmailCounts?: Map, +): ThreadGroup[] { if (!emails || emails.length === 0) { return []; } @@ -53,7 +61,7 @@ export function groupEmailsByThread(emails: Email[], disableThreading = false): hasAttachment, hasAnswered, hasForwarded, - emailCount: sortedEmails.length, + emailCount: threadEmailCounts?.get(threadId) ?? sortedEmails.length, }); } diff --git a/stores/email-store.ts b/stores/email-store.ts index 53b25249..498e6173 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -60,6 +60,8 @@ interface EmailStore { expandedThreadIds: Set; threadEmailsCache: Map; isLoadingThread: string | null; + // Full thread email counts (from Thread/get across all folders) + threadEmailCounts: Map; // Keyword/tag filter selectedKeyword: string | null; @@ -199,6 +201,8 @@ interface EmailStore { fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise; collapseAllThreads: () => void; updateThreadCache: (threadId: string, emails: Email[]) => void; + fetchThreadEmailCounts: (client: IJMAPClient) => Promise; + markThreadAsRead: (client: IJMAPClient, threadId: string) => Promise; // Mailbox management createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise; @@ -519,6 +523,7 @@ export const useEmailStore = create((set, get) => ({ expandedThreadIds: new Set(), threadEmailsCache: new Map(), isLoadingThread: null, + threadEmailCounts: new Map(), // Keyword/tag filter selectedKeyword: null, @@ -565,6 +570,7 @@ export const useEmailStore = create((set, get) => ({ selectedKeyword: null, expandedThreadIds: new Set(), threadEmailsCache: new Map(), + threadEmailCounts: new Map(), isLoadingThread: null, }), fetchAccountMailboxes: async (client, accountId) => { @@ -595,6 +601,7 @@ export const useEmailStore = create((set, get) => ({ selectedEmailIds: new Set(), expandedThreadIds: new Set(), threadEmailsCache: new Map(), + threadEmailCounts: new Map(), }), fetchTagCounts: async (client) => { try { @@ -617,6 +624,7 @@ export const useEmailStore = create((set, get) => ({ selectedKeyword: null, expandedThreadIds: new Set(), threadEmailsCache: new Map(), + threadEmailCounts: new Map(), isLoadingThread: null, }), setLoading: (loading) => set({ isLoading: loading }), @@ -785,8 +793,14 @@ export const useEmailStore = create((set, get) => ({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), hasMoreEmails: result.hasMore, totalEmails: result.total, + // Clear thread caches since the email list was fully replaced + threadEmailsCache: new Map(), + expandedThreadIds: new Set(), + isLoadingThread: null, isLoading: false }); + // Fetch full thread counts in the background (non-blocking) + void get().fetchThreadEmailCounts(client); } catch (error) { console.error('Failed to fetch emails:', error); set({ @@ -923,6 +937,10 @@ export const useEmailStore = create((set, get) => ({ totalEmails: result.total, isLoadingMore: false }); + // Fetch full thread counts for newly loaded threads in the background + if (newEmails.length > 0) { + void get().fetchThreadEmailCounts(client); + } } catch (error) { console.error('Failed to load more emails:', error); set({ @@ -1214,7 +1232,23 @@ export const useEmailStore = create((set, get) => ({ ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } } : state.selectedEmail, mailboxes: updatedMailboxes, - processingReadStatus: newProcessingSet + processingReadStatus: newProcessingSet, + // Also update threadEmailsCache so expanded dropdowns reflect the change + threadEmailsCache: (() => { + let updated = false; + const newCache = new Map(state.threadEmailsCache); + for (const [tid, cachedEmails] of newCache) { + const idx = cachedEmails.findIndex(e => e.id === emailId); + if (idx !== -1) { + newCache.set(tid, cachedEmails.map((e, i) => + i === idx ? { ...e, keywords: { ...e.keywords, $seen: read } } : e + )); + updated = true; + break; + } + } + return updated ? newCache : state.threadEmailsCache; + })(), }; }); } catch (error) { @@ -2327,11 +2361,74 @@ export const useEmailStore = create((set, get) => ({ // hasMore should reflect whether there are still more emails beyond // what we have loaded, using the fresh total from the server. const hasMore = merged.length < (result.total || 0); - set({ - emails: merged, - hasMoreEmails: hasMore, - totalEmails: result.total, + + // Invalidate thread email caches for threads whose composition changed + // so expanded threads pick up new/removed emails. + const prevThreadIds = new Set(currentEmails.map(e => e.threadId)); + const nextThreadIds = new Set(merged.map(e => e.threadId)); + const changedThreadIds = new Set(); + for (const tid of prevThreadIds) { + if (!nextThreadIds.has(tid)) changedThreadIds.add(tid); + } + for (const tid of nextThreadIds) { + if (!prevThreadIds.has(tid)) changedThreadIds.add(tid); + } + // Also check threads where the set of email IDs changed + const prevEmailsByThread = new Map>(); + for (const e of currentEmails) { + if (!prevEmailsByThread.has(e.threadId)) prevEmailsByThread.set(e.threadId, new Set()); + prevEmailsByThread.get(e.threadId)!.add(e.id); + } + const nextEmailsByThread = new Map>(); + for (const e of merged) { + if (!nextEmailsByThread.has(e.threadId)) nextEmailsByThread.set(e.threadId, new Set()); + nextEmailsByThread.get(e.threadId)!.add(e.id); + } + for (const [tid, nextIds] of nextEmailsByThread) { + const prevIds = prevEmailsByThread.get(tid); + if (!prevIds || prevIds.size !== nextIds.size) { + changedThreadIds.add(tid); + } else { + for (const id of nextIds) { + if (!prevIds.has(id)) { changedThreadIds.add(tid); break; } + } + } + } + + set((state) => { + const newCache = new Map(state.threadEmailsCache); + for (const tid of changedThreadIds) { + newCache.delete(tid); + } + return { + emails: merged, + hasMoreEmails: hasMore, + totalEmails: result.total, + threadEmailsCache: newCache, + }; }); + + // Re-fetch cross-folder thread data for any currently expanded threads + // so they show the complete conversation (not just current-folder emails). + const expandedNow = get().expandedThreadIds; + if (expandedNow.size > 0) { + const effectiveClient2 = resolveActionClient(client); + const accountId = effectiveClient2.getAccountId(); + for (const tid of expandedNow) { + void effectiveClient2.getThreadEmails(tid, accountId).then((fullEmails) => { + if (fullEmails.length > 0) { + set((state) => { + const c = new Map(state.threadEmailsCache); + c.set(tid, fullEmails); + return { threadEmailsCache: c }; + }); + } + }); + } + } + + // Fetch full thread counts in the background (non-blocking) + void get().fetchThreadEmailCounts(client); } } catch (error) { console.error('Failed to refresh current mailbox:', error); @@ -2401,6 +2498,90 @@ export const useEmailStore = create((set, get) => ({ } }, + markThreadAsRead: async (client, threadId) => { + const state = get(); + const threadEmails = state.threadEmailsCache.get(threadId) ?? []; + const mainEmails = state.emails.filter(e => e.threadId === threadId); + + // Combine unique emails from both sources + const allEmailMap = new Map(); + for (const e of mainEmails) allEmailMap.set(e.id, e); + for (const e of threadEmails) allEmailMap.set(e.id, e); + + const unreadIds = Array.from(allEmailMap.values()) + .filter(e => !e.keywords?.$seen) + .map(e => e.id); + + if (unreadIds.length === 0) return; + + // Group by account for unified view support + const emailsById = new Map(); + for (const e of allEmailMap.values()) emailsById.set(e.id, e); + + // Group unread IDs by account client + const groups = new Map(); + for (const id of unreadIds) { + const email = emailsById.get(id)!; + const { client: actionClient } = resolveEmailActionContext(email, client); + if (!groups.has(actionClient)) groups.set(actionClient, []); + groups.get(actionClient)!.push(id); + } + + // Mark all as read on the server + try { + await Promise.all( + Array.from(groups.entries()).map(([actionClient, emailIds]) => + actionClient.batchMarkAsRead(emailIds, true) + ) + ); + } catch (error) { + console.error('Failed to mark thread as read:', error); + return; + } + + // Update local state + set((state) => { + const unreadSet = new Set(unreadIds); + + const updatedEmails = state.emails.map(e => + unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e + ); + + // Update threadEmailsCache + const newCache = new Map(state.threadEmailsCache); + const cached = newCache.get(threadId); + if (cached) { + newCache.set(threadId, cached.map(e => + unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e + )); + } + + // Update mailbox unread counters + const affectedEmails = state.emails.filter(e => unreadSet.has(e.id)); + const updatedMailboxes = state.mailboxes.map(mailbox => { + let delta = 0; + for (const email of affectedEmails) { + if (email.mailboxIds?.[mailbox.id]) delta -= 1; + } + if (delta === 0) return mailbox; + return { + ...mailbox, + unreadEmails: Math.max(0, mailbox.unreadEmails + delta), + unreadThreads: Math.max(0, mailbox.unreadThreads + delta), + }; + }); + + return { + emails: updatedEmails, + threadEmailsCache: newCache, + mailboxes: updatedMailboxes, + selectedEmail: state.selectedEmail && unreadSet.has(state.selectedEmail.id) + ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } } + : state.selectedEmail, + }; + }); + }, + collapseAllThreads: () => { set({ expandedThreadIds: new Set(), @@ -2414,6 +2595,27 @@ export const useEmailStore = create((set, get) => ({ set({ threadEmailsCache: newCache }); }, + fetchThreadEmailCounts: async (client) => { + const { emails } = get(); + if (emails.length === 0) return; + + const uniqueThreadIds = [...new Set(emails.map(e => e.threadId).filter(Boolean))]; + if (uniqueThreadIds.length === 0) return; + + try { + const effectiveClient = resolveActionClient(client); + const threads = await effectiveClient.getThreads(uniqueThreadIds); + + const newCounts = new Map(get().threadEmailCounts); + for (const thread of threads) { + newCounts.set(thread.id, thread.emailIds?.length ?? 0); + } + set({ threadEmailCounts: newCounts }); + } catch { + // Non-critical — fall back to inbox-only counts + } + }, + // Mailbox management createMailbox: async (client, name, parentId) => { try {