This commit is contained in:
Linus Rath
2026-09-21 22:57:24 +02:00
9 changed files with 354 additions and 74 deletions
+13 -13
View File
@@ -13,7 +13,7 @@ import { useEmailStore, ArchiveMailboxNotFoundError } from "@/stores/email-store
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { groupEmailsByThread, sortThreadGroups, threadKeyFor } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
@@ -208,7 +208,7 @@ export function EmailList({
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 5,
getItemKey: (index) => threadGroups[index]?.threadId ?? String(index),
getItemKey: (index) => threadGroups[index]?.threadKey ?? String(index),
});
const LoadingSkeleton = () => (
@@ -321,16 +321,16 @@ export function EmailList({
}
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
const isExpanded = expandedThreadIds.has(threadId);
const handleToggleThreadExpansion = useCallback(async (threadKey: string) => {
const isExpanded = expandedThreadIds.has(threadKey);
if (!isExpanded && client) {
toggleThreadExpansion(threadId);
await fetchThreadEmails(client, threadId);
toggleThreadExpansion(threadKey);
await fetchThreadEmails(client, threadKey);
// Mark all unread emails in this thread as read
void markThreadAsRead(client, threadId);
void markThreadAsRead(client, threadKey);
} else {
toggleThreadExpansion(threadId);
toggleThreadExpansion(threadKey);
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
@@ -565,18 +565,18 @@ export function EmailList({
>
<ThreadListItem
thread={thread}
isExpanded={expandedThreadIds.has(thread.threadId)}
isExpanded={expandedThreadIds.has(thread.threadKey)}
selectedEmailId={selectedEmailId}
isLoading={isLoadingThread === thread.threadId}
expandedEmails={threadEmailsCache.get(thread.threadId)}
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
isLoading={isLoadingThread === thread.threadKey}
expandedEmails={threadEmailsCache.get(thread.threadKey)}
onToggleExpand={() => handleToggleThreadExpansion(thread.threadKey)}
onCollapseAllThreads={collapseAllThreads}
onEmailSelect={(email) => {
// Collapse expanded threads when selecting an email outside the expanded thread
const currentExpanded = useEmailStore.getState().expandedThreadIds;
if (currentExpanded.size > 0) {
// Check if the selected email belongs to any expanded thread via its threadId
if (!email.threadId || !currentExpanded.has(email.threadId)) {
if (!email.threadId || !currentExpanded.has(threadKeyFor(email))) {
collapseAllThreads();
}
}
+12 -12
View File
@@ -54,7 +54,7 @@ import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
import { cn, getMailboxPath } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, groupEmailsByThread } from "@/lib/thread-utils";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, groupEmailsByThread, threadKeyFor } from "@/lib/thread-utils";
import { resolveThreadRoute } from "@/lib/thread-routing";
import {
ErrorBoundary,
@@ -833,14 +833,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// thread's messages and marks them read, collapsing only toggles. (#683)
onToggleThreadExpansion: () => {
if (isScheduledView || !client) return;
const threadId = selectedEmail?.threadId;
if (!threadId) return;
if (!selectedEmail?.threadId) return;
const threadKey = threadKeyFor(selectedEmail);
const store = useEmailStore.getState();
const wasExpanded = store.expandedThreadIds.has(threadId);
store.toggleThreadExpansion(threadId);
const wasExpanded = store.expandedThreadIds.has(threadKey);
store.toggleThreadExpansion(threadKey);
if (!wasExpanded) {
void store.fetchThreadEmails(client, threadId).then(() => {
void useEmailStore.getState().markThreadAsRead(client, threadId);
void store.fetchThreadEmails(client, threadKey).then(() => {
void useEmailStore.getState().markThreadAsRead(client, threadKey);
});
}
},
@@ -1750,7 +1750,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (originalEmailId) {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) {
// Route to the email's own account so shared/group threads refresh
// from the right server, not the active one. (#281, #814)
const route = resolveThreadRoute({
@@ -1769,7 +1769,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
c.set(threadKeyFor(repliedEmail), fullEmails);
return { threadEmailsCache: c };
});
}
@@ -2285,7 +2285,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}
if (conversationThread?.threadId === emailToArchive.threadId) {
if (conversationThread && conversationThread.threadKey === threadKeyFor(emailToArchive)) {
setConversationThread(null);
setConversationEmails([]);
}
@@ -3225,7 +3225,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// 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)) {
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) {
// Route to the email's own account so shared/group threads refresh from
// the right server, not the active one. (#281, #814)
const route = resolveThreadRoute({
@@ -3244,7 +3244,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
c.set(threadKeyFor(repliedEmail), fullEmails);
return { threadEmailsCache: c };
});
}
+3 -2
View File
@@ -11,6 +11,7 @@ import { resolveComposeAccountEmail } from "@/lib/reply-identity";
import { toast } from "@/stores/toast-store";
import { useProTabStore, registerProTabCloseInterceptor, type ProComposeTabData } from "@/stores/pro-tab-store";
import { debug } from "@/lib/debug";
import { threadKeyFor } from "@/lib/thread-utils";
interface ProComposeTabBodyProps {
tabId: string;
@@ -118,13 +119,13 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
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)) {
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) {
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);
c.set(threadKeyFor(repliedEmail), fullEmails);
return { threadEmailsCache: c };
});
}
+59
View File
@@ -8,6 +8,8 @@ import {
getEmailTagIds,
getThreadTagId,
getThreadTagIds,
threadKeyFor,
threadIdFromKey,
} from '../thread-utils';
import type { Email, ThreadGroup } from '../jmap/types';
@@ -120,6 +122,7 @@ describe('groupEmailsByThread', () => {
describe('sortThreadGroups', () => {
const makeGroup = (threadId: string, receivedAt: string, hasPinned = false): ThreadGroup => ({
threadId,
threadKey: threadId,
emails: [makeEmail({ receivedAt })],
latestEmail: makeEmail({ receivedAt }),
participantNames: ['A'],
@@ -222,6 +225,7 @@ describe('mergeThreadEmails', () => {
it('merges new emails without duplicating existing ones', () => {
const existing: ThreadGroup = {
threadId: 'thread-1',
threadKey: 'thread-1',
emails: [
makeEmail({ id: 'e1', receivedAt: '2024-01-10T00:00:00Z' }),
makeEmail({ id: 'e2', receivedAt: '2024-01-09T00:00:00Z' }),
@@ -248,6 +252,7 @@ describe('mergeThreadEmails', () => {
it('updates thread metadata after merge', () => {
const existing: ThreadGroup = {
threadId: 'thread-1',
threadKey: 'thread-1',
emails: [makeEmail({ id: 'e1', keywords: { $seen: true }, hasAttachment: false })],
latestEmail: makeEmail({ id: 'e1' }),
participantNames: ['Alice'],
@@ -403,3 +408,57 @@ describe('getThreadTagIds', () => {
expect(getThreadTagIds([])).toEqual([]);
});
});
describe('cross-account thread identity (#1012)', () => {
// JMAP thread ids are per-account: Stalwart hands out counters like "b", so
// two accounts routinely have a thread "b" that has nothing in common.
const inAccount = (login: string, overrides: Partial<Email> = {}): Email =>
makeEmail({ sourceClientAccountId: `login-${login}`, sourceAccountId: `acct-${login}`, ...overrides });
it('keeps the bare thread id as key for unstamped emails', () => {
expect(threadKeyFor(makeEmail({ threadId: 'b' }))).toBe('b');
});
it('scopes the key by the source stamps', () => {
expect(threadKeyFor(inAccount('a', { threadId: 'b' }))).toBe('login-a/acct-a:b');
});
it('recovers the JMAP id from a key', () => {
expect(threadIdFromKey('login-a/acct-a:b')).toBe('b');
expect(threadIdFromKey('b')).toBe('b');
});
it('does not merge same-id threads that belong to different accounts', () => {
const groups = groupEmailsByThread([
inAccount('a', { id: 'a1', threadId: 'b' }),
inAccount('b', { id: 'b1', threadId: 'b' }),
]);
expect(groups).toHaveLength(2);
// The raw id is preserved for Thread/get on each account …
expect(groups.map((g) => g.threadId)).toEqual(['b', 'b']);
// … while the client-side identity tells them apart.
expect(new Set(groups.map((g) => g.threadKey)).size).toBe(2);
expect(groups.map((g) => g.emails.map((e) => e.id))).toEqual([['a1'], ['b1']]);
});
it('still merges the same thread within one account', () => {
const groups = groupEmailsByThread([
inAccount('a', { id: 'a1', threadId: 'b' }),
inAccount('a', { id: 'a2', threadId: 'b' }),
]);
expect(groups).toHaveLength(1);
expect(groups[0].emailCount).toBe(2);
});
it('looks thread counts up by key', () => {
const counts = new Map([['login-a/acct-a:b', 7]]);
const [group] = groupEmailsByThread([inAccount('a', { threadId: 'b' })], false, counts);
expect(group.emailCount).toBe(7);
});
it('uses the message id as both id and key when threading is disabled', () => {
const [group] = groupEmailsByThread([inAccount('a', { id: 'm1', threadId: 'b' })], true);
expect(group.threadKey).toBe('m1');
expect(group.threadId).toBe('m1');
});
});
+2 -1
View File
@@ -243,7 +243,8 @@ export interface Thread {
// Thread grouping for UI display
export interface ThreadGroup {
threadId: string;
threadId: string; // JMAP thread id - unique only within its account; use for Thread/get
threadKey: string; // Client-side identity, unique across accounts (see threadKeyFor)
emails: Email[]; // Emails in this thread (sorted by receivedAt desc)
latestEmail: Email; // Most recent email
participantNames: string[];// Unique participant names
+41 -9
View File
@@ -1,13 +1,41 @@
import type { Email, ThreadGroup } from "./jmap/types";
import { compareEmails, type SortLevel } from "./message-list-order";
/**
* Client-side identity of a thread.
*
* JMAP thread ids are only unique within their account (Stalwart hands out
* per-account counters such as "b", "c", …), so in views that merge several
* accounts two unrelated conversations can share a `threadId`. Emails fetched
* through the aggregate paths carry their source stamps; scoping the key by
* them keeps those threads apart. Unstamped emails (single-account views) keep
* the bare id, so nothing changes there. `ThreadGroup.threadId` stays the raw
* id for JMAP calls - `threadIdFromKey` is the reverse mapping.
*/
export function threadKeyFor(
email: Pick<Email, "threadId" | "sourceClientAccountId" | "sourceAccountId">,
): string {
const scope = [email.sourceClientAccountId, email.sourceAccountId].filter(Boolean).join("/");
return scope ? `${scope}:${email.threadId}` : email.threadId;
}
/**
* The JMAP thread id behind a `threadKeyFor` key. JMAP ids never contain ":"
* (RFC 8620 §1.2 limits them to the URL-safe base64 alphabet), so the last
* separator always splits the scope from the id.
*/
export function threadIdFromKey(threadKey: string): string {
const at = threadKey.lastIndexOf(":");
return at === -1 ? threadKey : threadKey.slice(at + 1);
}
/**
* Groups emails by their threadId and creates ThreadGroup objects for UI display.
* 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
* @param threadEmailCounts - Optional map of threadKey → 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.
*/
@@ -20,21 +48,22 @@ export function groupEmailsByThread(
return [];
}
// Group emails by threadId (or by message ID when threading is disabled)
// Group by the account-scoped thread key (or by message id when threading
// is disabled): bare thread ids collide across accounts in aggregate views.
const threadMap = new Map<string, Email[]>();
for (const email of emails) {
const threadId = disableThreading ? email.id : email.threadId;
if (!threadMap.has(threadId)) {
threadMap.set(threadId, []);
const key = disableThreading ? email.id : threadKeyFor(email);
if (!threadMap.has(key)) {
threadMap.set(key, []);
}
threadMap.get(threadId)!.push(email);
threadMap.get(key)!.push(email);
}
// Convert to ThreadGroup array
const threadGroups: ThreadGroup[] = [];
for (const [threadId, threadEmails] of threadMap) {
for (const [threadKey, threadEmails] of threadMap) {
// Sort emails by receivedAt descending (newest first)
const sortedEmails = [...threadEmails].sort(
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
@@ -54,7 +83,9 @@ export function groupEmailsByThread(
const hasForwarded = sortedEmails.some(e => e.keywords?.$forwarded);
threadGroups.push({
threadId,
// With threading off the "thread" is the single message, keyed by its id.
threadId: disableThreading ? latestEmail.id : latestEmail.threadId,
threadKey,
emails: sortedEmails,
latestEmail,
participantNames,
@@ -64,7 +95,7 @@ export function groupEmailsByThread(
hasAttachment,
hasAnswered,
hasForwarded,
emailCount: threadEmailCounts?.get(threadId) ?? sortedEmails.length,
emailCount: threadEmailCounts?.get(threadKey) ?? sortedEmails.length,
});
}
@@ -161,6 +192,7 @@ export function mergeThreadEmails(
return {
threadId: existingGroup.threadId,
threadKey: existingGroup.threadKey,
emails: mergedEmails,
latestEmail,
participantNames,
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { useAuthStore } from '../auth-store';
import { threadKeyFor } from '@/lib/thread-utils';
import { CROSS_ALL } from '@/lib/jmap/types';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
/**
* #1012: JMAP thread ids are only unique within their account - Stalwart hands
* out per-account counters such as "b" - so in the cross-account views two
* accounts routinely list a thread "b" that has nothing in common. Thread state
* has to be keyed by the account-scoped thread key, and thread fetches routed
* to the account that owns the thread.
*/
const makeMailbox = (overrides: Partial<Mailbox> = {}): Mailbox => ({
id: 'inbox',
name: 'Inbox',
role: 'inbox',
sortOrder: 0,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true,
},
isSubscribed: true,
isShared: false,
...overrides,
});
type Login = 'a' | 'b';
// Both accounts have a thread "b"; only the source stamps tell them apart.
const inAccount = (login: Login, id: string, overrides: Partial<Email> = {}): Email =>
({
id,
threadId: 'b',
mailboxIds: { inbox: true },
keywords: {},
size: 1,
receivedAt: '2026-09-10T10:00:00Z',
from: [{ email: `${login}@example.com` }],
subject: id,
preview: '',
hasAttachment: false,
accountId: `login-${login}`,
accountLabel: `${login}@example.com`,
sourceClientAccountId: `login-${login}`,
sourceAccountId: `acct-${login}`,
...overrides,
}) as unknown as Email;
function makeClient(login: Login, threadSize: number) {
return {
getAccountId: () => `acct-${login}`,
getThreads: vi.fn(async (ids: string[]) =>
ids.map((id) => ({ id, emailIds: Array.from({ length: threadSize }, (_, i) => `${login}-${id}-${i}`) })),
),
batchMarkAsRead: vi.fn(async () => {}),
getThreadEmails: vi.fn(async (threadId: string) => [
inAccount(login, `${login}-full-1`, { threadId, sourceClientAccountId: undefined, sourceAccountId: undefined }),
inAccount(login, `${login}-full-2`, { threadId, sourceClientAccountId: undefined, sourceAccountId: undefined }),
]),
} as unknown as IJMAPClient & {
getThreads: ReturnType<typeof vi.fn>;
getThreadEmails: ReturnType<typeof vi.fn>;
batchMarkAsRead: ReturnType<typeof vi.fn>;
};
}
describe('thread identity across accounts (#1012)', () => {
let clientA: ReturnType<typeof makeClient>;
let clientB: ReturnType<typeof makeClient>;
const a1 = inAccount('a', 'a1');
const b1 = inAccount('b', 'b1');
beforeEach(() => {
clientA = makeClient('a', 3);
clientB = makeClient('b', 1);
useAuthStore.setState({
activeAccountId: 'login-a',
getClientForAccount: (id: string) =>
(id === 'login-a' ? clientA : id === 'login-b' ? clientB : undefined) as never,
} as never);
useEmailStore.setState({
isUnifiedView: true,
unifiedRole: null,
crossView: 'all',
selectedMailbox: CROSS_ALL,
viewingAccountId: null,
mailboxes: [makeMailbox()],
accountMailboxes: { 'acct-a': [makeMailbox()], 'acct-b': [makeMailbox()] },
emails: [a1, b1],
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
threadEmailCounts: new Map(),
isLoadingThread: null,
processingReadStatus: new Set(),
});
});
it('asks each account for its own threads and files the counts under the scoped key', async () => {
await useEmailStore.getState().fetchThreadEmailCounts(clientA);
expect(clientA.getThreads).toHaveBeenCalledWith(['b'], 'acct-a');
expect(clientB.getThreads).toHaveBeenCalledWith(['b'], 'acct-b');
const counts = useEmailStore.getState().threadEmailCounts;
expect(counts.get(threadKeyFor(a1))).toBe(3);
expect(counts.get(threadKeyFor(b1))).toBe(1);
// The bare id must not be a key any more: it could only ever hold one
// account's answer.
expect(counts.has('b')).toBe(false);
});
it('fetches a thread from the account that owns it and caches it under the scoped key', async () => {
const emails = await useEmailStore.getState().fetchThreadEmails(clientA, threadKeyFor(b1));
expect(clientB.getThreadEmails).toHaveBeenCalledWith('b', 'acct-b');
expect(clientA.getThreadEmails).not.toHaveBeenCalled();
expect(emails.map((e) => e.id)).toEqual(['b-full-1', 'b-full-2']);
// Re-stamped with the owning account so later actions route correctly.
expect(emails.every((e) => e.sourceAccountId === 'acct-b' && e.sourceClientAccountId === 'login-b')).toBe(true);
const cache = useEmailStore.getState().threadEmailsCache;
expect(cache.has(threadKeyFor(b1))).toBe(true);
expect(cache.has(threadKeyFor(a1))).toBe(false);
expect(cache.has('b')).toBe(false);
});
it('marks only the owning account\'s messages read when a thread is expanded', async () => {
await useEmailStore.getState().markThreadAsRead(clientA, threadKeyFor(a1));
expect(clientA.batchMarkAsRead).toHaveBeenCalledWith(['a1'], true);
expect(clientB.batchMarkAsRead).not.toHaveBeenCalled();
const byId = new Map(useEmailStore.getState().emails.map((e) => [e.id, e]));
expect(byId.get('a1')?.keywords?.$seen).toBe(true);
expect(byId.get('b1')?.keywords?.$seen).toBeUndefined();
});
it('keeps expansion state apart for same-id threads of different accounts', () => {
useEmailStore.getState().toggleThreadExpansion(threadKeyFor(a1));
const expanded = useEmailStore.getState().expandedThreadIds;
expect(expanded.has(threadKeyFor(a1))).toBe(true);
expect(expanded.has(threadKeyFor(b1))).toBe(false);
});
});
+60 -35
View File
@@ -8,6 +8,7 @@ import type { SortLevel } from "@/lib/message-list-order";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import { resolveThreadRoute } from "@/lib/thread-routing";
import { threadKeyFor, threadIdFromKey } from "@/lib/thread-utils";
import type { ExternalSearchResult } from "@/lib/plugin-types";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
@@ -91,6 +92,9 @@ interface EmailStore {
newEmailNotification: Email | null; // New email notification for toast
// Thread expansion state
// Thread state is keyed by the account-scoped thread key (threadKeyFor), not
// the bare JMAP id: in aggregate views the same id names unrelated threads
// in different accounts. Same for threadEmailCounts below.
expandedThreadIds: Set<string>;
threadEmailsCache: Map<string, Email[]>;
isLoadingThread: string | null;
@@ -289,12 +293,12 @@ interface EmailStore {
clearNewEmailNotification: () => void;
// Thread expansion actions
toggleThreadExpansion: (threadId: string) => void;
fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise<Email[]>;
toggleThreadExpansion: (threadKey: string) => void;
fetchThreadEmails: (client: IJMAPClient, threadKey: string) => Promise<Email[]>;
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
fetchThreadEmailCounts: (client: IJMAPClient) => Promise<void>;
markThreadAsRead: (client: IJMAPClient, threadId: string) => Promise<void>;
markThreadAsRead: (client: IJMAPClient, threadKey: string) => Promise<void>;
// Mailbox management
createMailbox: (client: IJMAPClient, name: string, parentId?: string, accountId?: string) => Promise<void>;
@@ -540,9 +544,9 @@ async function applyEmailDeltaNow(client: IJMAPClient, newState: string, get: St
.map((e) => replacementById.get(e.id) ?? e);
const changedThreadIds = new Set<string>();
for (const e of current) {
if (removed.has(e.id) || replacementById.has(e.id)) changedThreadIds.add(e.threadId);
if (removed.has(e.id) || replacementById.has(e.id)) changedThreadIds.add(threadKeyFor(e));
}
for (const e of replacementById.values()) changedThreadIds.add(e.threadId);
for (const e of replacementById.values()) changedThreadIds.add(threadKeyFor(e));
set((state) => {
const threadEmailsCache = new Map(state.threadEmailsCache);
@@ -2673,9 +2677,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
Array.from(currentState.selectedEmailIds).filter(id => !removedEmailIds.has(id))
);
const nextExpandedThreadIds = new Set(currentState.expandedThreadIds);
nextExpandedThreadIds.delete(email.threadId);
nextExpandedThreadIds.delete(threadKeyFor(email));
const nextThreadEmailsCache = new Map(currentState.threadEmailsCache);
nextThreadEmailsCache.delete(email.threadId);
nextThreadEmailsCache.delete(threadKeyFor(email));
return {
emails: currentState.emails.filter(currentEmail => !removedEmailIds.has(currentEmail.id)),
@@ -3966,8 +3970,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// 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 prevThreadIds = new Set(currentEmails.map(e => threadKeyFor(e)));
const nextThreadIds = new Set(merged.map(e => threadKeyFor(e)));
const changedThreadIds = new Set<string>();
for (const tid of prevThreadIds) {
if (!nextThreadIds.has(tid)) changedThreadIds.add(tid);
@@ -3978,13 +3982,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// 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 key = threadKeyFor(e);
if (!prevEmailsByThread.has(key)) prevEmailsByThread.set(key, new Set());
prevEmailsByThread.get(key)!.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);
const key = threadKeyFor(e);
if (!nextEmailsByThread.has(key)) nextEmailsByThread.set(key, new Set());
nextEmailsByThread.get(key)!.add(e.id);
}
for (const [tid, nextIds] of nextEmailsByThread) {
const prevIds = prevEmailsByThread.get(tid);
@@ -4050,38 +4056,40 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
// Thread expansion actions
toggleThreadExpansion: (threadId) => {
toggleThreadExpansion: (threadKey) => {
const { expandedThreadIds } = get();
const newExpandedThreadIds = new Set(expandedThreadIds);
if (newExpandedThreadIds.has(threadId)) {
newExpandedThreadIds.delete(threadId);
if (newExpandedThreadIds.has(threadKey)) {
newExpandedThreadIds.delete(threadKey);
} else {
newExpandedThreadIds.add(threadId);
newExpandedThreadIds.add(threadKey);
}
set({ expandedThreadIds: newExpandedThreadIds });
},
fetchThreadEmails: async (client, threadId) => {
fetchThreadEmails: async (client, threadKey) => {
const { threadEmailsCache, selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
// Check if we already have this thread cached
const cachedEmails = threadEmailsCache.get(threadId);
const cachedEmails = threadEmailsCache.get(threadKey);
if (cachedEmails && cachedEmails.length > 0) {
return cachedEmails;
}
// Set loading state
set({ isLoadingThread: threadId });
set({ isLoadingThread: threadKey });
try {
// Route to the thread's own account. In aggregate views `selectedMailbox`
// is virtual, so derive the client + accountId from a list email of this
// thread (handles shared/group accounts); otherwise fall back to the
// selected-mailbox shared-folder logic. (#281)
const threadEmail = get().emails.find(e => e.threadId === threadId);
// selected-mailbox shared-folder logic. (#281) The key is account-scoped,
// so this can't pick up another account's thread with the same id.
const threadEmail = get().emails.find(e => threadKeyFor(e) === threadKey);
const threadId = threadEmail?.threadId ?? threadIdFromKey(threadKey);
const route = resolveThreadRoute({
isUnifiedView: isAggregateListView(),
ref: threadEmail,
@@ -4109,7 +4117,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Update cache
const newCache = new Map(get().threadEmailsCache);
newCache.set(threadId, emails);
newCache.set(threadKey, emails);
set({
threadEmailsCache: newCache,
@@ -4124,10 +4132,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
markThreadAsRead: async (client, threadId) => {
markThreadAsRead: async (client, threadKey) => {
const state = get();
const threadEmails = state.threadEmailsCache.get(threadId) ?? [];
const mainEmails = state.emails.filter(e => e.threadId === threadId);
const threadEmails = state.threadEmailsCache.get(threadKey) ?? [];
const mainEmails = state.emails.filter(e => threadKeyFor(e) === threadKey);
// Combine unique emails from both sources
const allEmailMap = new Map<string, Email>();
@@ -4175,9 +4183,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Update threadEmailsCache
const newCache = new Map(state.threadEmailsCache);
const cached = newCache.get(threadId);
const cached = newCache.get(threadKey);
if (cached) {
newCache.set(threadId, cached.map(e =>
newCache.set(threadKey, cached.map(e =>
unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e
));
}
@@ -4224,16 +4232,33 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const { emails } = get();
if (emails.length === 0) return;
const uniqueThreadIds = [...new Set(emails.map(e => e.threadId).filter(Boolean))];
if (uniqueThreadIds.length === 0) return;
// Thread ids are per-account, so ask each source account for its own
// threads and file the counts under the account-scoped key. One client
// answering for every id would return the ACTIVE account's unrelated
// thread "b" for every other account's thread "b" in an aggregate view.
const bySource = new Map<string, { ref: Email; threadIds: Set<string> }>();
for (const e of emails) {
if (!e.threadId) continue;
const source = `${e.sourceClientAccountId ?? ''}/${e.sourceAccountId ?? ''}`;
const entry = bySource.get(source) ?? { ref: e, threadIds: new Set<string>() };
entry.threadIds.add(e.threadId);
bySource.set(source, entry);
}
if (bySource.size === 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);
for (const { ref, threadIds } of bySource.values()) {
const { client: sourceClient, accountId } = resolveEmailActionContext(ref, client);
const threads = await sourceClient.getThreads([...threadIds], accountId);
for (const thread of threads) {
const key = threadKeyFor({
threadId: thread.id,
sourceClientAccountId: ref.sourceClientAccountId,
sourceAccountId: ref.sourceAccountId,
});
newCounts.set(key, thread.emailIds?.length ?? 0);
}
}
set({ threadEmailCounts: newCounts });
} catch {
+13 -2
View File
@@ -8,6 +8,7 @@ import { pluginStorage } from '@/lib/plugin-storage';
import { extractPlugin } from '@/lib/plugin-validator';
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader';
import { useLocaleStore } from '@/stores/locale-store';
import { getEffectiveLocale } from '@/i18n/detect-locale';
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
import { requestConsent } from '@/lib/plugin-sandbox/consent';
import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity';
@@ -272,10 +273,20 @@ export const usePluginStore = create<PluginStoreState>()(
// in the dead activateAllPlugins() path, so the sandbox locale stayed
// 'en' forever and plugin i18n never localized). Subscribe once for
// later language switches; those affect plugins/slots loaded after.
setSandboxLocale(useLocaleStore.getState().locale);
//
// Use getEffectiveLocale(), NOT the raw store value: the stored
// choice is '' by default and stays '' under "Automatic" (see
// language-switcher.tsx / settings/language-settings.tsx) - the
// app's own UI resolves that via document.documentElement.lang /
// detectBrowserLocale (see i18n/detect-locale.ts), so a plugin
// reading the raw store value would see an empty string (falling
// back to English) even when the app itself is fully localized to
// the visitor's browser language. Found via a plugin dev report:
// native UI in Hungarian, plugin's own i18n stuck on English.
setSandboxLocale(getEffectiveLocale());
if (!localeSubscribed) {
localeSubscribed = true;
useLocaleStore.subscribe((s) => setSandboxLocale(s.locale));
useLocaleStore.subscribe(() => setSandboxLocale(getEffectiveLocale()));
}
// Sync server-managed plugins before loading