fix: fix inconsistent behavior with threading email messages in the inbox/folders

This commit is contained in:
Max Hao
2026-06-15 23:00:30 +02:00
committed by Linus Rath
parent f7d4f9d53c
commit fd700f412e
8 changed files with 309 additions and 13 deletions
+37 -2
View File
@@ -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
+7 -3
View File
@@ -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<Email>();
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
+19 -1
View File
@@ -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);
+10
View File
@@ -388,6 +388,16 @@ export class DemoJMAPClient implements IJMAPClient {
return { id: threadId, emailIds: emails.map(e => e.id) };
}
async getThreads(threadIds: string[]): Promise<Thread[]> {
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<Email[]> {
return this.data.emails
.filter(e => e.threadId === threadId)
+1
View File
@@ -117,6 +117,7 @@ export interface IJMAPClient {
// ── Threads ───────────────────────────────────────────────────
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
getThreads(threadIds: string[], accountId?: string): Promise<Thread[]>;
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
// ── Compose / Send ────────────────────────────────────────────
+18
View File
@@ -1904,6 +1904,24 @@ export class JMAPClient implements IJMAPClient {
}
}
async getThreads(threadIds: string[], accountId?: string): Promise<Thread[]> {
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<Email[]> {
try {
const targetAccountId = accountId || this.accountId;
+10 -2
View File
@@ -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<string, number>,
): 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,
});
}
+207 -5
View File
@@ -60,6 +60,8 @@ interface EmailStore {
expandedThreadIds: Set<string>;
threadEmailsCache: Map<string, Email[]>;
isLoadingThread: string | null;
// Full thread email counts (from Thread/get across all folders)
threadEmailCounts: Map<string, number>;
// Keyword/tag filter
selectedKeyword: string | null;
@@ -199,6 +201,8 @@ interface EmailStore {
fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise<Email[]>;
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
fetchThreadEmailCounts: (client: IJMAPClient) => Promise<void>;
markThreadAsRead: (client: IJMAPClient, threadId: string) => Promise<void>;
// Mailbox management
createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise<void>;
@@ -519,6 +523,7 @@ export const useEmailStore = create<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<string>();
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<string, Set<string>>();
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<string, Set<string>>();
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<EmailStore>((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<string, Email>();
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<string, Email>();
for (const e of allEmailMap.values()) emailsById.set(e.id, e);
// Group unread IDs by account client
const groups = new Map<IJMAPClient, string[]>();
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<EmailStore>((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 {