From bbc3c2123e87be5d078728185cf617ef7c97002b Mon Sep 17 00:00:00 2001
From: Peterka35 <43301641+Peterka35@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:01:34 +0200
Subject: [PATCH] fix: follow markAsReadDelay when expanding a conversation or
opening a Pro folder row
Expanding a conversation in the message list (row click, expand chevron,
or the `x` shortcut) and clicking a row in a Pro folder tab marked the
messages read straight away. They ignored "Mark as read: Never" and any
delay, which the reading pane and the conversation view follow.
Route them through one helper, scheduleAutoMarkAsRead: -1 never marks,
0 marks at once, any other value marks after that many milliseconds and
returns a cancel function.
- New store action autoMarkExpandedThreadAsRead keeps one pending timer
per thread key. Collapsing the thread, or collapsing all threads, drops
it. Both expansion paths (EmailList and the `x` handler in MailApp) use
it.
- MailApp's handleEmailSelect drops the pending mark of every expanded
thread other than the one the newly opened message belongs to, through
cancelAutoMarkThreadAsRead. Moving on with `j`/`k` or a click no longer
lets the delayed mark land on a conversation the user has left. The
thread stays expanded.
- ProFolderTabBody schedules the row mark through the helper and drops a
pending one when another row is opened or the tab unmounts.
---
...mail-list-thread-expand-mark-read.test.tsx | 114 +++++++++++++
components/email/email-list.tsx | 10 +-
.../mail-app-thread-expand-mark-read.test.tsx | 160 ++++++++++++++++++
components/mail/mail-app.tsx | 18 +-
.../pro-folder-tab-mark-read.test.tsx | 112 ++++++++++++
components/pro/pro-folder-tab-body.tsx | 13 +-
lib/__tests__/mark-as-read-delay.test.ts | 43 +++++
lib/mark-as-read-delay.ts | 24 +++
...mail-store-thread-expand-mark-read.test.ts | 142 ++++++++++++++++
stores/email-store.ts | 44 ++++-
10 files changed, 671 insertions(+), 9 deletions(-)
create mode 100644 components/email/__tests__/email-list-thread-expand-mark-read.test.tsx
create mode 100644 components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx
create mode 100644 components/pro/__tests__/pro-folder-tab-mark-read.test.tsx
create mode 100644 lib/__tests__/mark-as-read-delay.test.ts
create mode 100644 lib/mark-as-read-delay.ts
create mode 100644 stores/__tests__/email-store-thread-expand-mark-read.test.ts
diff --git a/components/email/__tests__/email-list-thread-expand-mark-read.test.tsx b/components/email/__tests__/email-list-thread-expand-mark-read.test.tsx
new file mode 100644
index 000000000..c605a2e03
--- /dev/null
+++ b/components/email/__tests__/email-list-thread-expand-mark-read.test.tsx
@@ -0,0 +1,114 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { EmailList } from '../email-list';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
+import type { Email } from '@/lib/jmap/types';
+
+/**
+ * Expanding a conversation from the list must go through markAsReadDelay:
+ * with "Never" nothing is marked read. Guards the list's wiring, not only the
+ * store action.
+ */
+
+const client = vi.hoisted(() => ({
+ getAccountId: () => 'acct-1',
+ getThreadEmails: vi.fn(),
+ batchMarkAsRead: vi.fn(async () => {}),
+}));
+
+vi.mock('@/hooks/use-email-drag', () => ({
+ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
+}));
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+// jsdom has no layout, so the real virtualizer draws no rows. Draw them all.
+vi.mock('@tanstack/react-virtual', () => ({
+ useVirtualizer: ({ count }: { count: number }) => ({
+ getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, key: index, start: index * 80, size: 80 })),
+ getTotalSize: () => count * 80,
+ measureElement: () => {},
+ measure: () => {},
+ scrollToOffset: () => {},
+ scrollToIndex: () => {},
+ measurementsCache: [],
+ scrollOffset: 0,
+ scrollRect: null,
+ }),
+}));
+vi.mock('@/stores/auth-store', async () => {
+ const { create } = await import('zustand');
+ return {
+ useAuthStore: create(() => ({
+ client, identities: [], username: null, activeAccountId: 'acct-1',
+ getClientForAccount: () => client,
+ })),
+ };
+});
+vi.mock('@/stores/account-store', () => {
+ const state = { getAccountById: () => undefined };
+ return {
+ useAccountStore: Object.assign(
+ (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
+ { getState: () => state },
+ ),
+ };
+});
+
+const mail = (id: string, time: string, seen: boolean): Email => ({
+ id,
+ threadId: 'thread-t',
+ mailboxIds: { inbox: true },
+ keywords: seen ? { $seen: true } : {},
+ size: 1000,
+ receivedAt: `2026-09-30T${time}:00Z`,
+ from: [{ name: 'Alice', email: 'alice@example.com' }],
+ subject: 'Conversation',
+ hasAttachment: false,
+});
+
+const THREAD = [mail('t2', '16:00', false), mail('t1', '15:00', true)];
+
+const initialEmailState = useEmailStore.getState();
+const initialTabsState = useMessageListTabsStore.getState();
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ client.batchMarkAsRead.mockClear();
+ client.getThreadEmails.mockResolvedValue(THREAD);
+ useEmailStore.setState(initialEmailState, true);
+ useEmailStore.setState({ selectedMailbox: 'inbox', emails: THREAD });
+ useMessageListTabsStore.setState(initialTabsState, true);
+});
+
+afterEach(() => {
+ useEmailStore.getState().collapseAllThreads();
+ vi.useRealTimers();
+});
+
+async function expandFromList() {
+ render();
+ fireEvent.click(screen.getAllByText('Conversation')[0]);
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+ expect(useEmailStore.getState().expandedThreadIds.has('thread-t')).toBe(true);
+}
+
+describe('email list conversation expansion follows markAsReadDelay', () => {
+ it('marks nothing read with "Never" (-1)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: -1 });
+ await expandFromList();
+ await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ });
+
+ it('marks the unread message read after the delay', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ await expandFromList();
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+
+ await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
+ expect(client.batchMarkAsRead).toHaveBeenCalledWith(['t2'], true, undefined);
+ });
+});
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index ea9e32b55..e81379bd3 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -172,7 +172,7 @@ export function EmailList({
isLoadingThread,
toggleThreadExpansion,
fetchThreadEmails,
- markThreadAsRead,
+ autoMarkExpandedThreadAsRead,
collapseAllThreads,
threadEmailCounts,
searchFilters,
@@ -204,7 +204,7 @@ export function EmailList({
isLoadingThread: state.isLoadingThread,
toggleThreadExpansion: state.toggleThreadExpansion,
fetchThreadEmails: state.fetchThreadEmails,
- markThreadAsRead: state.markThreadAsRead,
+ autoMarkExpandedThreadAsRead: state.autoMarkExpandedThreadAsRead,
collapseAllThreads: state.collapseAllThreads,
threadEmailCounts: state.threadEmailCounts,
searchFilters: state.searchFilters,
@@ -607,13 +607,13 @@ export function EmailList({
toggleThreadExpansion(threadKey);
await fetchThreadEmails(client, threadKey);
emitThreadExpand(threadKey, threadMembers(threadKey), true);
- // Mark all unread emails in this thread as read
- void markThreadAsRead(client, threadKey);
+ // Mark the thread's unread emails read, following markAsReadDelay.
+ autoMarkExpandedThreadAsRead(client, threadKey);
} else {
toggleThreadExpansion(threadKey);
emitThreadExpand(threadKey, threadMembers(threadKey), !isExpanded);
}
- }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead, threadMembers]);
+ }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, autoMarkExpandedThreadAsRead, threadMembers]);
const collapseAllThreadsAndNotify = useCallback(() => {
const wereExpanded = [...useEmailStore.getState().expandedThreadIds];
diff --git a/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx b/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx
new file mode 100644
index 000000000..8f636c660
--- /dev/null
+++ b/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx
@@ -0,0 +1,160 @@
+import React from 'react';
+import { act, fireEvent, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePluginStore } from '@/stores/plugin-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+
+// The `x` shortcut expands the selected conversation and marks it read. It has
+// to follow markAsReadDelay like the reading pane, and a delayed mark must not
+// land once the user has moved on (`j`/`k`) to a message outside it.
+
+vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
+vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => null }));
+vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
+vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
+vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
+vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
+vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
+vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+ useIsDesktop: () => true,
+ useIsMobile: () => false,
+}));
+vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
+vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
}));
+
+const { MailApp } = await import('../mail-app');
+
+const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
+const mail = (id: string, threadId: string, seen: boolean, time: string): Email => ({
+ id, threadId, mailboxIds: { inbox: true }, keywords: seen ? { $seen: true } : {}, size: 1,
+ receivedAt: `2026-10-01T${time}:00Z`, from: [{ email: 'sender@example.com' }], subject: id,
+ preview: '', hasAttachment: false,
+}) as Email;
+
+// Newest first, as the list shows it: the conversation, then a single message.
+const conversation = [mail('c2', 'conv', false, '12:00'), mail('c1', 'conv', true, '11:00')];
+const single = mail('s1', 'single', true, '10:00');
+
+let client: {
+ getAccountId: () => string; getUsername: () => string; getSieveAccountId: () => null;
+ hasDelayedSend: () => boolean; getSession: () => null;
+ getEmail: ReturnType; getThreadEmails: ReturnType;
+ batchMarkAsRead: ReturnType; markAsRead: ReturnType;
+};
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ client = {
+ getAccountId: () => 'acc', getUsername: () => 'me@example.com',
+ getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
+ getEmail: vi.fn(async (id: string) => [...conversation, single].find((e) => e.id === id) ?? null),
+ getThreadEmails: vi.fn(async () => conversation),
+ batchMarkAsRead: vi.fn(async () => {}),
+ markAsRead: vi.fn(async () => {}),
+ };
+ useAuthStore.setState({
+ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [],
+ getClientForAccount: () => client as never,
+ getAllConnectedClients: () => new Map([['login', client]]) as never,
+ });
+ useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', interfaceLayout: 'default' } as never);
+ useEmailStore.setState({
+ // The conversation's newest message is open (selected with `j`).
+ emails: [...conversation, single], selectedEmail: conversation[0], selectedMailbox: inbox.id, mailboxes: [inbox],
+ viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
+ isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null,
+ selectedEmailKeys: new Set(), expandedThreadIds: new Set(), threadEmailsCache: new Map(),
+ processingReadStatus: new Set(),
+ fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
+ fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
+ refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+ } as never);
+});
+afterEach(() => {
+ useEmailStore.getState().collapseAllThreads();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+const press = async (code: string) => {
+ fireEvent.keyDown(window, { key: code.slice(3).toLowerCase(), code });
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+};
+
+describe('`x` expansion follows markAsReadDelay', () => {
+ it('marks nothing read with "Never" (-1)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: -1 });
+ vi.useFakeTimers();
+ render();
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+
+ await press('KeyX');
+ expect(useEmailStore.getState().expandedThreadIds.has('conv')).toBe(true);
+ await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ expect(client.markAsRead).not.toHaveBeenCalled();
+ });
+
+ it('drops the delayed conversation mark once `j` opens a message outside it', async () => {
+ // The reading pane already shows c2 marked read here: only the thread
+ // expansion is under test.
+ useEmailStore.setState({ selectedEmail: { ...conversation[0], keywords: { $seen: true } } });
+ useSettingsStore.setState({ markAsReadDelay: 5000 });
+ vi.useFakeTimers();
+ render();
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+
+ await press('KeyX');
+ expect(useEmailStore.getState().expandedThreadIds.has('conv')).toBe(true);
+ await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
+
+ // Move past the conversation to the single message.
+ await press('KeyJ');
+ await press('KeyJ');
+ expect(useEmailStore.getState().selectedEmail?.id).toBe('s1');
+ await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ // Moving on does not collapse the conversation.
+ expect(useEmailStore.getState().expandedThreadIds.has('conv')).toBe(true);
+ });
+
+ it('still marks the conversation read after the delay while the user stays in it', async () => {
+ useEmailStore.setState({ selectedEmail: { ...conversation[0], keywords: { $seen: true } } });
+ useSettingsStore.setState({ markAsReadDelay: 5000 });
+ vi.useFakeTimers();
+ render();
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+
+ await press('KeyX');
+ await act(async () => { await vi.advanceTimersByTimeAsync(4999); });
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ await act(async () => { await vi.advanceTimersByTimeAsync(1); });
+
+ expect(client.batchMarkAsRead).toHaveBeenCalledWith(['c2'], true, undefined);
+ });
+});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 1b100d4e1..5fa720acf 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -1061,7 +1061,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
// `x` in the help modal: expand/collapse the selected email's thread. Same
// steps as EmailList.handleToggleThreadExpansion - expanding pulls the
- // thread's messages and marks them read, collapsing only toggles. (#683)
+ // thread's messages and marks them read (following markAsReadDelay),
+ // collapsing only toggles. (#683)
onToggleThreadExpansion: () => {
if (isScheduledView || !client) return;
if (!selectedEmail?.threadId) return;
@@ -1074,7 +1075,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!wasExpanded) {
void store.fetchThreadEmails(client, threadKey).then(() => {
emitThreadExpand(threadKey, members(), true);
- void useEmailStore.getState().markThreadAsRead(client, threadKey);
+ useEmailStore.getState().autoMarkExpandedThreadAsRead(client, threadKey);
});
} else {
emitThreadExpand(threadKey, members(), false);
@@ -4093,6 +4094,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
&& (email.sourceAccountId === undefined || e.sourceAccountId === email.sourceAccountId))
?? (email.sourceClientAccountId ? email : undefined);
+ // Expanding a conversation may have scheduled a delayed "mark read" for
+ // it (markAsReadDelay). Moving on to a message outside that conversation
+ // (j/k, a click) drops it, as the reading pane drops its own pending
+ // mark; the conversation stays expanded.
+ const openRef = (listEmail ?? email) as Partial;
+ if (openRef.threadId) {
+ const openThreadKey = threadKeyFor(openRef as Email);
+ const emailStore = useEmailStore.getState();
+ for (const key of emailStore.expandedThreadIds) {
+ if (key !== openThreadKey) emailStore.cancelAutoMarkThreadAsRead(key);
+ }
+ }
+
// Only the newest open may touch the viewer: a slow fetch for a message
// the user has already clicked away from must neither replace the one
// they are looking at nor end its loading state.
diff --git a/components/pro/__tests__/pro-folder-tab-mark-read.test.tsx b/components/pro/__tests__/pro-folder-tab-mark-read.test.tsx
new file mode 100644
index 000000000..486b47a10
--- /dev/null
+++ b/components/pro/__tests__/pro-folder-tab-mark-read.test.tsx
@@ -0,0 +1,112 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import type { Email } from '@/lib/jmap/types';
+import { useSettingsStore } from '@/stores/settings-store';
+
+/**
+ * A Pro folder tab marked a message read the moment its row was clicked,
+ * ignoring "Mark as read: Never" / a delay. It must follow markAsReadDelay
+ * like the reading pane does.
+ */
+
+vi.mock('@/components/pro/pro-email-tab-body', () => ({
+ ProEmailView: ({ emailId }: { emailId: string }) => React.createElement('div', { 'data-testid': 'reader' }, emailId),
+}));
+vi.mock('@/components/email/email-viewer', () => ({
+ EmailViewer: () => null,
+}));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+}));
+
+const client = vi.hoisted(() => ({
+ getEmails: vi.fn(),
+ markAsRead: vi.fn(async () => {}),
+}));
+
+vi.mock('@/stores/auth-store', () => {
+ const state = { client, getClientForAccount: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state);
+ hook.getState = () => state;
+ return { useAuthStore: hook };
+});
+
+const { ProFolderTabBody } = await import('../pro-folder-tab-body');
+
+const unread = (id: string, subject: string): Email =>
+ ({
+ id,
+ threadId: id,
+ mailboxIds: { inbox: true },
+ keywords: {},
+ from: [{ name: 'Sender', email: 'sender@example.com' }],
+ subject,
+ receivedAt: '2026-10-01T10:00:00Z',
+ preview: '',
+ hasAttachment: false,
+ size: 1,
+ }) as unknown as Email;
+
+async function renderTab() {
+ render(
+ ,
+ );
+ await act(async () => {});
+}
+
+describe('Pro folder tab row click follows markAsReadDelay', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ client.markAsRead.mockClear();
+ client.getEmails.mockResolvedValue({
+ emails: [unread('e1', 'First'), unread('e2', 'Second')],
+ total: 2,
+ hasMore: false,
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('does not mark the message read with "Never" (-1)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: -1 });
+ await renderTab();
+
+ fireEvent.click(screen.getByText('First'));
+ await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
+
+ expect(screen.getByTestId('reader')).toHaveTextContent('e1');
+ expect(client.markAsRead).not.toHaveBeenCalled();
+ });
+
+ it('marks the message read at once with "Instantly" (0)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 0 });
+ await renderTab();
+
+ fireEvent.click(screen.getByText('First'));
+ await act(async () => {});
+
+ expect(client.markAsRead).toHaveBeenCalledWith('e1', true, undefined);
+ });
+
+ it('marks the message read after the delay, and only the row still open', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ await renderTab();
+
+ fireEvent.click(screen.getByText('First'));
+ await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
+ // Moving on to another row before the delay ran out drops the first mark.
+ fireEvent.click(screen.getByText('Second'));
+ await act(async () => { await vi.advanceTimersByTimeAsync(2999); });
+ expect(client.markAsRead).not.toHaveBeenCalled();
+
+ await act(async () => { await vi.advanceTimersByTimeAsync(1); });
+ expect(client.markAsRead).toHaveBeenCalledTimes(1);
+ expect(client.markAsRead).toHaveBeenCalledWith('e2', true, undefined);
+ });
+});
diff --git a/components/pro/pro-folder-tab-body.tsx b/components/pro/pro-folder-tab-body.tsx
index 4479ebbd9..0746b47c6 100644
--- a/components/pro/pro-folder-tab-body.tsx
+++ b/components/pro/pro-folder-tab-body.tsx
@@ -16,6 +16,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePaneId } from "@/hooks/use-pane-context";
import type { Email, Mailbox } from "@/lib/jmap/types";
import { emailHooks } from "@/lib/plugin-hooks";
+import { scheduleAutoMarkAsRead } from "@/lib/mark-as-read-delay";
interface ProFolderTabBodyProps {
tabId: string;
@@ -152,8 +153,18 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
);
}, [data.mailboxId, paneId, t]);
+ // Opening a row marks it read on the user's markAsReadDelay, like the
+ // reading pane does; a pending delayed mark is dropped when another row is
+ // opened or the tab goes away.
+ const cancelRowReadRef = useRef<(() => void) | null>(null);
+ useEffect(() => () => cancelRowReadRef.current?.(), []);
+
const handleRowClick = useCallback((email: Email) => {
- void markRowAsRead(email);
+ cancelRowReadRef.current?.();
+ cancelRowReadRef.current = scheduleAutoMarkAsRead(
+ useSettingsStore.getState().markAsReadDelay,
+ () => void markRowAsRead(email),
+ );
if (isMobile) {
// No room for a reading pane - promote to a dedicated email tab.
openAsEmailTab(email, true);
diff --git a/lib/__tests__/mark-as-read-delay.test.ts b/lib/__tests__/mark-as-read-delay.test.ts
new file mode 100644
index 000000000..fff1fab58
--- /dev/null
+++ b/lib/__tests__/mark-as-read-delay.test.ts
@@ -0,0 +1,43 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { scheduleAutoMarkAsRead } from '../mark-as-read-delay';
+
+describe('scheduleAutoMarkAsRead', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('never marks with "Never" (-1)', () => {
+ const mark = vi.fn();
+ scheduleAutoMarkAsRead(-1, mark);
+ vi.advanceTimersByTime(60_000);
+ expect(mark).not.toHaveBeenCalled();
+ });
+
+ it('marks synchronously with "Instantly" (0)', () => {
+ const mark = vi.fn();
+ scheduleAutoMarkAsRead(0, mark);
+ expect(mark).toHaveBeenCalledTimes(1);
+ });
+
+ it('marks once the delay has passed', () => {
+ const mark = vi.fn();
+ scheduleAutoMarkAsRead(3000, mark);
+ vi.advanceTimersByTime(2999);
+ expect(mark).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(1);
+ expect(mark).toHaveBeenCalledTimes(1);
+ });
+
+ it('drops a pending delayed mark when cancelled', () => {
+ const mark = vi.fn();
+ const cancel = scheduleAutoMarkAsRead(3000, mark);
+ vi.advanceTimersByTime(1000);
+ cancel();
+ vi.advanceTimersByTime(10_000);
+ expect(mark).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/mark-as-read-delay.ts b/lib/mark-as-read-delay.ts
new file mode 100644
index 000000000..f1a10a359
--- /dev/null
+++ b/lib/mark-as-read-delay.ts
@@ -0,0 +1,24 @@
+// Automatic "mark as read" timing for surfaces that mark a message read
+// because it was shown. Used by thread expansion and Pro folder rows; same
+// rules as the reading pane (`mail-app.tsx`) and `useMarkReadWhenShown`.
+//
+// `markAsReadDelay` is the user's setting in milliseconds: -1 = never,
+// 0 = instantly, anything else = after that many milliseconds. Surfaces that
+// skipped the setting marked mail read even with "Never" selected.
+
+const noop = () => {};
+
+/**
+ * Runs `mark` according to `markAsReadDelay` and returns a function that
+ * cancels a still-pending delayed mark. "Never" (any negative value) does
+ * nothing; 0 runs `mark` synchronously.
+ */
+export function scheduleAutoMarkAsRead(markAsReadDelay: number, mark: () => void): () => void {
+ if (markAsReadDelay < 0) return noop;
+ if (markAsReadDelay === 0) {
+ mark();
+ return noop;
+ }
+ const timeout = setTimeout(mark, markAsReadDelay);
+ return () => clearTimeout(timeout);
+}
diff --git a/stores/__tests__/email-store-thread-expand-mark-read.test.ts b/stores/__tests__/email-store-thread-expand-mark-read.test.ts
new file mode 100644
index 000000000..f75d68498
--- /dev/null
+++ b/stores/__tests__/email-store-thread-expand-mark-read.test.ts
@@ -0,0 +1,142 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useEmailStore } from '../email-store';
+import { useAuthStore } from '../auth-store';
+import { useSettingsStore } from '../settings-store';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+/**
+ * Expanding a conversation in the message list (row click, expand chevron or
+ * the `x` shortcut) marked its unread messages read straight away, even with
+ * "Mark as read: Never". It must follow markAsReadDelay like the reading pane.
+ */
+
+const makeMailbox = (overrides: Partial = {}): Mailbox => ({
+ id: 'inbox',
+ name: 'Inbox',
+ role: 'inbox',
+ sortOrder: 0,
+ totalEmails: 2,
+ unreadEmails: 1,
+ totalThreads: 1,
+ unreadThreads: 1,
+ myRights: {
+ mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
+ maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true,
+ },
+ isSubscribed: true,
+ isShared: false,
+ ...overrides,
+});
+
+const makeEmail = (id: string, seen: boolean): Email =>
+ ({
+ id,
+ threadId: 't1',
+ mailboxIds: { inbox: true },
+ keywords: seen ? { $seen: true } : {},
+ size: 1,
+ receivedAt: '2026-10-01T10:00:00Z',
+ subject: id,
+ preview: '',
+ hasAttachment: false,
+ }) as unknown as Email;
+
+function makeClient() {
+ return {
+ getAccountId: () => 'acct-a',
+ batchMarkAsRead: vi.fn(async () => {}),
+ } as unknown as IJMAPClient & { batchMarkAsRead: ReturnType };
+}
+
+describe('autoMarkExpandedThreadAsRead follows markAsReadDelay', () => {
+ let client: ReturnType;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ client = makeClient();
+ useAuthStore.setState({
+ activeAccountId: 'login-a',
+ getClientForAccount: () => client as never,
+ } as never);
+ useEmailStore.setState({
+ isUnifiedView: false,
+ unifiedRole: null,
+ viewingAccountId: null,
+ selectedMailbox: 'inbox',
+ mailboxes: [makeMailbox()],
+ accountMailboxes: {},
+ emails: [makeEmail('m1', true), makeEmail('m2', false)],
+ expandedThreadIds: new Set(),
+ threadEmailsCache: new Map(),
+ processingReadStatus: new Set(),
+ });
+ });
+
+ afterEach(() => {
+ useEmailStore.getState().collapseAllThreads();
+ vi.useRealTimers();
+ });
+
+ const expand = () => {
+ useEmailStore.getState().toggleThreadExpansion('t1');
+ useEmailStore.getState().autoMarkExpandedThreadAsRead(client, 't1');
+ };
+
+ it('leaves the conversation unread with "Never" (-1)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: -1 });
+ expand();
+ await vi.advanceTimersByTimeAsync(60_000);
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ expect(useEmailStore.getState().emails.find((e) => e.id === 'm2')?.keywords?.$seen).toBeFalsy();
+ });
+
+ it('marks the unread messages read at once with "Instantly" (0)', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 0 });
+ expand();
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(client.batchMarkAsRead).toHaveBeenCalledWith(['m2'], true, undefined);
+ });
+
+ it('marks the unread messages read after the configured delay', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ expand();
+ await vi.advanceTimersByTimeAsync(2999);
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(1);
+ expect(client.batchMarkAsRead).toHaveBeenCalledWith(['m2'], true, undefined);
+ });
+
+ it('drops the delayed mark when the conversation is collapsed first', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ expand();
+ await vi.advanceTimersByTimeAsync(1000);
+ useEmailStore.getState().toggleThreadExpansion('t1');
+ await vi.advanceTimersByTimeAsync(10_000);
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ });
+
+ it('drops the delayed mark when cancelled, and leaves the thread expanded', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ expand();
+ await vi.advanceTimersByTimeAsync(1000);
+ useEmailStore.getState().cancelAutoMarkThreadAsRead('t1');
+ await vi.advanceTimersByTimeAsync(10_000);
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ expect(useEmailStore.getState().expandedThreadIds.has('t1')).toBe(true);
+ });
+
+ it('drops the delayed mark when every thread is collapsed', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 3000 });
+ expand();
+ useEmailStore.getState().collapseAllThreads();
+ await vi.advanceTimersByTimeAsync(10_000);
+
+ expect(client.batchMarkAsRead).not.toHaveBeenCalled();
+ });
+});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index f568bb9b7..16139b1b0 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -11,6 +11,7 @@ import { useCalendarStore } from "@/stores/calendar-store";
import { orderKeywords, placeHeldRows, sanitizeSortLevels, type HeldKeywords, 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 { scheduleAutoMarkAsRead } from "@/lib/mark-as-read-delay";
import { allowEmailMove, createMailboxWithHooks, emitEmailMove, idsFromSelectionKeys, keywordChangeLists, mailboxToView, quotaToView } from "@/lib/plugin-mail-hooks";
import { emailSummary, emailToReadView } from "@/lib/plugin-projection";
import { resolveThreadRoute } from "@/lib/thread-routing";
@@ -453,6 +454,17 @@ interface EmailStore {
updateThreadCache: (threadId: string, emails: Email[]) => void;
fetchThreadEmailCounts: (client: IJMAPClient) => Promise;
markThreadAsRead: (client: IJMAPClient, threadKey: string) => Promise;
+ /**
+ * Marks an expanded thread read the way the reading pane marks a message:
+ * following `markAsReadDelay` (never / instantly / after a delay). A delayed
+ * mark is dropped when the thread is collapsed first.
+ */
+ autoMarkExpandedThreadAsRead: (client: IJMAPClient, threadKey: string) => void;
+ /**
+ * Drops a still-pending delayed mark for the thread, without collapsing it.
+ * Used when the user moves on to a message outside that thread.
+ */
+ cancelAutoMarkThreadAsRead: (threadKey: string) => void;
// Mailbox management
createMailbox: (client: IJMAPClient, name: string, parentId?: string, accountId?: string) => Promise;
@@ -1082,6 +1094,18 @@ function folderListState(s: EmailStore): FolderList {
/** The list row beginOpenEmail put in the viewer, until its message arrives. */
let openingRow: Email | null = null;
+// Pending delayed "mark thread read" timers, by thread key. Collapsing the
+// thread, or opening a message outside it, cancels its timer.
+const pendingThreadReads = new Map void>();
+
+function cancelPendingThreadRead(threadKey?: string): void {
+ const keys = threadKey === undefined ? [...pendingThreadReads.keys()] : [threadKey];
+ for (const key of keys) {
+ pendingThreadReads.get(key)?.();
+ pendingThreadReads.delete(key);
+ }
+}
+
/**
* The per-thread message counts to store once a page arrived: the counts that
* page brought along (Thread/get chained onto its query) under the keys the
@@ -5646,9 +5670,10 @@ export const useEmailStore = create((set, get) => ({
if (newExpandedThreadIds.has(threadKey)) {
newExpandedThreadIds.delete(threadKey);
+ cancelPendingThreadRead(threadKey);
} else {
newExpandedThreadIds.add(threadKey);
- // Expanding marks the thread read; hold it before that lands.
+ // Expanding may mark the thread read; hold it before that lands.
get().holdListRow(threadKey);
}
@@ -5718,6 +5743,22 @@ export const useEmailStore = create((set, get) => ({
}
},
+ autoMarkExpandedThreadAsRead: (client, threadKey) => {
+ cancelPendingThreadRead(threadKey);
+ const { markAsReadDelay } = useSettingsStore.getState();
+ const cancel = scheduleAutoMarkAsRead(markAsReadDelay, () => {
+ pendingThreadReads.delete(threadKey);
+ // Collapsed (or the list was reset) before the delay ran out.
+ if (!get().expandedThreadIds.has(threadKey)) return;
+ void get().markThreadAsRead(client, threadKey);
+ });
+ if (markAsReadDelay > 0) pendingThreadReads.set(threadKey, cancel);
+ },
+
+ cancelAutoMarkThreadAsRead: (threadKey) => {
+ cancelPendingThreadRead(threadKey);
+ },
+
markThreadAsRead: async (client, threadKey) => {
const state = get();
const threadEmails = state.threadEmailsCache.get(threadKey) ?? [];
@@ -5815,6 +5856,7 @@ export const useEmailStore = create((set, get) => ({
},
collapseAllThreads: () => {
+ cancelPendingThreadRead();
set({
expandedThreadIds: new Set(),
isLoadingThread: null