From d671c606a158f3e6e8d8ef1c8377eb79c4f7c373 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Tue, 23 Jun 2026 17:40:27 +0300 Subject: [PATCH 1/5] feat(mail): deleting the open message returns to the list, not the next email Deleting from inside an open message advanced to the next email. Gmail (and most clients) return you to the message list instead. In the viewer's onDelete, deselect first (handleMobileBack) so the store's remove-and-advance sees no selection and won't auto-open the next message, then delete the captured email. Returning to the list immediately also avoids a flash of the next email. Scoped to the single-message viewer; list and keyboard deletes (which keep auto-advance) are unchanged. Consistent with the mark-unread-returns-to-list behaviour. --- app/(main)/[locale]/page.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 1edd0997..f7896154 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -3099,7 +3099,15 @@ export default function Home() { onReply={handleReply} onReplyAll={handleReplyAll} onForward={handleForward} - onDelete={() => handleDelete()} + onDelete={() => { + // Deleting the open message returns to the list (Gmail-style), + // not the next email. Deselect first so the store's + // remove-and-advance sees no selection and doesn't auto-open + // the next message; then delete the captured email. + const target = selectedEmail; + handleMobileBack(); + handleDelete(target); + }} onArchive={() => handleArchive()} onToggleStar={handleToggleStar} onSetColorTag={handleSetColorTag} From acc61db6f2d8554009094913548fa82e700a9186 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Tue, 23 Jun 2026 21:39:21 +0300 Subject: [PATCH 2/5] feat(settings): make return-to-list-after-action configurable (default on) Per review: gate the return-to-list behaviour behind a setting, returnToListAfterAction, defaulting to true (the Gmail/Yahoo default). When off, deleting the open message keeps the previous auto-advance-to-next behaviour. Adds the setting to the store (persisted), a toggle under Reading settings, and i18n keys across all locales (English; non-English need translation). The same setting will govern mark-as-unread (#468). --- app/(main)/[locale]/page.tsx | 10 ++++++---- components/settings/reading-settings.tsx | 8 ++++++++ locales/cs/common.json | 4 ++++ locales/da/common.json | 4 ++++ locales/de/common.json | 4 ++++ locales/en/common.json | 4 ++++ locales/es/common.json | 4 ++++ locales/fr/common.json | 4 ++++ locales/hu/common.json | 4 ++++ locales/it/common.json | 4 ++++ locales/ja/common.json | 4 ++++ locales/ko/common.json | 4 ++++ locales/lv/common.json | 4 ++++ locales/nl/common.json | 4 ++++ locales/pl/common.json | 4 ++++ locales/pt/common.json | 4 ++++ locales/ro/common.json | 4 ++++ locales/ru/common.json | 4 ++++ locales/tr/common.json | 4 ++++ locales/uk/common.json | 4 ++++ locales/zh/common.json | 4 ++++ stores/settings-store.ts | 3 +++ 22 files changed, 93 insertions(+), 4 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index f7896154..c07514fe 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -3101,11 +3101,13 @@ export default function Home() { onForward={handleForward} onDelete={() => { // Deleting the open message returns to the list (Gmail-style), - // not the next email. Deselect first so the store's - // remove-and-advance sees no selection and doesn't auto-open - // the next message; then delete the captured email. + // not the next email — unless the user turned the setting off. + // Deselect first so the store's remove-and-advance sees no + // selection and doesn't auto-open the next message. const target = selectedEmail; - handleMobileBack(); + if (useSettingsStore.getState().returnToListAfterAction) { + handleMobileBack(); + } handleDelete(target); }} onArchive={() => handleArchive()} diff --git a/components/settings/reading-settings.tsx b/components/settings/reading-settings.tsx index f74aa045..1a54f8f1 100644 --- a/components/settings/reading-settings.tsx +++ b/components/settings/reading-settings.tsx @@ -22,6 +22,7 @@ export function ReadingSettings() { markAsReadDelay, deleteAction, permanentlyDeleteJunk, + returnToListAfterAction, showPreview, mailLayout, disableThreading, @@ -176,6 +177,13 @@ export function ReadingSettings() { /> + + updateSetting('returnToListAfterAction', checked)} + /> + + {!isSettingHidden('showPreview') && ( ()( firstDayOfWeek: state.firstDayOfWeek, markAsReadDelay: state.markAsReadDelay, deleteAction: state.deleteAction, + returnToListAfterAction: state.returnToListAfterAction, showPreview: state.showPreview, mailLayout: state.mailLayout, emailsPerPage: state.emailsPerPage, From 5c2f206c74b7f8d5d890d0e680d8f996efe2640a Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Wed, 24 Jun 2026 05:48:29 +0300 Subject: [PATCH 3/5] feat(mail): return to the list after marking an open message unread Gmail-style: marking the currently-open message unread returns to the message list instead of staying in the reading pane (where the viewer's auto-mark-read would just flip it back to read). Gated on the returnToListAfterAction setting added in #477 (default on); when off, you stay in the viewer. Only the single-message viewer, and only on mark-unread (read === false). --- app/(main)/[locale]/page.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index c07514fe..2f64a114 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -3118,6 +3118,12 @@ export default function Home() { onMarkAsRead={async (emailId, read) => { if (client) { await markAsRead(client, emailId, read); + // Marking the open message unread returns to the list + // (Gmail-style, gated on returnToListAfterAction). Staying + // in the reading pane would just re-mark it read on view. + if (!read && useSettingsStore.getState().returnToListAfterAction) { + handleMobileBack(); + } } }} onDownloadAttachment={handleDownloadAttachment} From 751f3c16859290187b8c1db28bc44d530a5c9860 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:09:19 +0200 Subject: [PATCH 4/5] feat: per-account All Mail folder selection Replaces the global allMailFolderIds (string[] | null) with a per-account Record, so each account chooses which of its own folders the "All Mail" view merges. A missing entry = "not configured" (defaults to every no-role folder); an explicit [] = "no folders". - settings-store: type/default -> Record (default {}); persist version 4 -> 5, migration drops the legacy global list (the active account isn't known at migrate time); onRehydrate + importSettings coerce/ignore any non-record (legacy global string[] | null) shape. isPlainRecord() guard. - email-store.resolveAllMailJmapIds: reads the entry for the account the view is scoped to (viewingAccountId ?? activeAccountId); undefined -> all no-role, [] -> none. - layout-settings: read/write the active account's entry; when more than one account is logged in, an italic hint names the account the selection applies to (settings.appearance.all_mail.account_hint, 19 locales; de/ro translated). - Test: stores/__tests__/settings-store-all-mail.test.ts (per-account independence, explicit-empty vs not-configured, importSettings legacy guard). --- components/settings/layout-settings.tsx | 26 +++++++-- locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/fr/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh/common.json | 1 + .../__tests__/settings-store-all-mail.test.ts | 55 +++++++++++++++++++ stores/email-store.ts | 12 ++-- stores/settings-store.ts | 33 ++++++++++- 23 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 stores/__tests__/settings-store-all-mail.test.ts diff --git a/components/settings/layout-settings.tsx b/components/settings/layout-settings.tsx index b069a0af..44ba417c 100644 --- a/components/settings/layout-settings.tsx +++ b/components/settings/layout-settings.tsx @@ -121,24 +121,37 @@ export function LayoutSettings() { const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore(); const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const accounts = useAccountStore(s => s.accounts); + const activeAccountId = useAccountStore(s => s.activeAccountId); const mailboxes = useEmailStore(s => s.mailboxes); const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]); const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled'); - // Own (non-shared) folders and the current All Mail selection. `null` = - // never configured, which defaults to all non-special (no-role) folders. + // Own (non-shared) folders and the active account's All Mail selection. The + // selection is per account: a missing entry = never configured, which + // defaults to all no-role folders; an explicit [] = no folders. const ownMailboxes = useMemo(() => mailboxes.filter(m => !m.isShared), [mailboxes]); + const currentAllMailEntry = activeAccountId ? allMailFolderIds[activeAccountId] : undefined; const allMailSelected = new Set( - allMailFolderIds === null + currentAllMailEntry === undefined ? ownMailboxes.filter(m => !m.role).map(m => m.id) - : allMailFolderIds + : currentAllMailEntry ); const toggleAllMailFolder = (id: string) => { + if (!activeAccountId) return; const next = new Set(allMailSelected); if (next.has(id)) next.delete(id); else next.add(id); - updateSetting('allMailFolderIds', ownMailboxes.filter(m => next.has(m.id)).map(m => m.id)); + updateSetting('allMailFolderIds', { + ...allMailFolderIds, + [activeAccountId]: ownMailboxes.filter(m => next.has(m.id)).map(m => m.id), + }); }; + // Name the account the selection applies to, but only when more than one is + // logged in (otherwise it's unambiguous). + const activeAccount = accounts.find(a => a.id === activeAccountId); + const allMailAccountHint = accounts.length > 1 && activeAccount + ? t('all_mail.account_hint', { account: activeAccount.displayName || activeAccount.email }) + : null; return ( @@ -244,6 +257,9 @@ export function LayoutSettings() {
{t('all_mail.folders_label')}
{t('all_mail.folders_description')}
+ {allMailAccountHint && ( +
{allMailAccountHint}
+ )}
{ownMailboxes.length === 0 ? (

{t('all_mail.no_folders')}

diff --git a/locales/cs/common.json b/locales/cs/common.json index 1772e60c..60158ecd 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/da/common.json b/locales/da/common.json index d6987861..c1f05ead 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/de/common.json b/locales/de/common.json index 3e8e8d57..5b25d5ee 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Gilt für {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/en/common.json b/locales/en/common.json index c2cace8c..01e3748e 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/es/common.json b/locales/es/common.json index 9c136e5c..07212004 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/fr/common.json b/locales/fr/common.json index aaa889cb..483f108e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 380a03d4..b2912dac 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -922,6 +922,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/it/common.json b/locales/it/common.json index 295a2df2..d20e4175 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 14a786d6..33d992b8 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/ko/common.json b/locales/ko/common.json index f99baaaf..ac169ef3 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/lv/common.json b/locales/lv/common.json index b28caccc..37dee663 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/nl/common.json b/locales/nl/common.json index 4e486450..54ffa80f 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 64107616..e7fab6f8 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 9a358039..7ea40a34 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 60c20915..eba273d7 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -922,6 +922,7 @@ "description": "Afișați o intrare „Toate mesajele” deasupra folderelor, care reunește mesajele din toate folderele acestui cont într-o singură listă.", "folders_label": "Dosare în „Toate mesajele”", "folders_description": "Alegeți ce dosare să fie incluse în vizualizarea „Toate mesajele”.", + "account_hint": "Se aplică pentru {account}.", "no_folders": "Nu sunt disponibile foldere." }, "colorful_sidebar_icons": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 474cedc0..2336c3ed 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 71bac54c..3f600c5c 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 8cb6c033..d624d191 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/locales/zh/common.json b/locales/zh/common.json index a06642ec..12b67a51 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -919,6 +919,7 @@ "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", + "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, "colorful_sidebar_icons": { diff --git a/stores/__tests__/settings-store-all-mail.test.ts b/stores/__tests__/settings-store-all-mail.test.ts new file mode 100644 index 00000000..616a8b35 --- /dev/null +++ b/stores/__tests__/settings-store-all-mail.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSettingsStore } from '../settings-store'; + +describe('settings-store per-account allMailFolderIds', () => { + beforeEach(() => { + useSettingsStore.setState({ allMailFolderIds: {} }); + }); + + it('defaults to an empty record (every account "not configured")', () => { + expect(useSettingsStore.getState().allMailFolderIds).toEqual({}); + }); + + it('keeps each account selection independent', () => { + useSettingsStore.setState({ + allMailFolderIds: { 'acct-1': ['inbox', 'archive'], 'acct-2': ['sent'] }, + }); + const map = useSettingsStore.getState().allMailFolderIds; + expect(map['acct-1']).toEqual(['inbox', 'archive']); + expect(map['acct-2']).toEqual(['sent']); + // A third account remains unconfigured (no entry). + expect(map['acct-3']).toBeUndefined(); + }); + + it('distinguishes explicit-empty ([] = no folders) from not-configured (undefined)', () => { + useSettingsStore.setState({ allMailFolderIds: { 'acct-1': [] } }); + const map = useSettingsStore.getState().allMailFolderIds; + expect(map['acct-1']).toEqual([]); // explicit "no folders" + expect(map['acct-2']).toBeUndefined(); // never configured + }); + + describe('importSettings legacy-shape guard', () => { + it('ignores a legacy global array shape', () => { + useSettingsStore.setState({ allMailFolderIds: { 'acct-1': ['inbox'] } }); + const ok = useSettingsStore.getState().importSettings( + JSON.stringify({ allMailFolderIds: ['inbox', 'sent'] }), + ); + expect(ok).toBe(true); + // unchanged - the array shape was rejected + expect(useSettingsStore.getState().allMailFolderIds).toEqual({ 'acct-1': ['inbox'] }); + }); + + it('ignores a null legacy value', () => { + useSettingsStore.setState({ allMailFolderIds: { 'acct-1': ['inbox'] } }); + useSettingsStore.getState().importSettings(JSON.stringify({ allMailFolderIds: null })); + expect(useSettingsStore.getState().allMailFolderIds).toEqual({ 'acct-1': ['inbox'] }); + }); + + it('accepts a proper per-account record', () => { + useSettingsStore.getState().importSettings( + JSON.stringify({ allMailFolderIds: { 'acct-9': ['inbox', 'spam'] } }), + ); + expect(useSettingsStore.getState().allMailFolderIds).toEqual({ 'acct-9': ['inbox', 'spam'] }); + }); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 498e6173..4a9688c3 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -321,15 +321,19 @@ function resolveActionMailboxes(): Mailbox[] { /** * Resolves the JMAP mailbox ids that make up the gated "All Mail" view for the - * active/viewing account. Honors the per-user `allMailFolderIds` setting; when - * unset (null) it defaults to every non-special (no-role) folder. Shared + * active/viewing account. Honors that account's `allMailFolderIds` entry; when + * not configured it defaults to every non-special (no-role) folder. Shared * folders are excluded - All Mail is scoped to a single account. Returns * JMAP-side ids (originalId for namespaced mailboxes). */ function resolveAllMailJmapIds(): string[] { const mailboxes = resolveActionMailboxes().filter((mb) => !mb.isShared); - const configured = useSettingsStore.getState().allMailFolderIds; - const selected = configured === null + // Per-account selection: read the entry for the account the view is scoped to + // (the Pro viewing override, else the global active account). A missing entry + // = "not configured" -> all no-role folders; an explicit [] = no folders. + const accountId = useEmailStore.getState().viewingAccountId ?? useAuthStore.getState().activeAccountId; + const configured = accountId ? useSettingsStore.getState().allMailFolderIds[accountId] : undefined; + const selected = configured === undefined ? mailboxes.filter((mb) => !mb.role) : mailboxes.filter((mb) => configured.includes(mb.id)); return selected.map((mb) => mb.originalId || mb.id); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 7ec486d6..c024fc15 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -15,6 +15,11 @@ const syncLog = (...args: unknown[]) => console.log('[SETTINGS_SYNC]', ...args); const syncWarn = (...args: unknown[]) => console.warn('[SETTINGS_SYNC]', ...args); const syncError = (...args: unknown[]) => console.error('[SETTINGS_SYNC]', ...args); +/** True for a non-null, non-array plain object (the allMailFolderIds map shape). */ +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + // Settings sync state (module-level, not persisted) let syncEnabled = false; let syncUsername: string | null = null; @@ -221,7 +226,10 @@ interface SettingsState { // configured, in which case the view defaults to all non-special (no-role) // folders of the active account. enableAllMailView: boolean; - allMailFolderIds: string[] | null; + // Per-account "All Mail" folder selection, keyed by AccountEntry.id. A + // missing entry = "not configured" -> defaults to every no-role folder; an + // explicit [] = "no folders". (Replaced the legacy global string[] | null.) + allMailFolderIds: Record; // Email Display disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation @@ -409,7 +417,7 @@ const DEFAULT_SETTINGS = { // All Mail view (gated) enableAllMailView: false, - allMailFolderIds: null as string[] | null, + allMailFolderIds: {} as Record, // Email Display disableThreading: false, @@ -633,6 +641,11 @@ export const useSettingsStore = create()( set({ sendDelaySeconds: 0 }); return; } + // Ignore a legacy global allMailFolderIds (string[] | null) or any + // non-record value - this build keys it per account. + if (key === 'allMailFolderIds' && !isPlainRecord(settings[key])) { + return; + } if (DEVICE_LOCAL_SETTING_KEYS.has(key)) { return; } @@ -823,7 +836,7 @@ export const useSettingsStore = create()( }), { name: 'settings-storage', - version: 4, + version: 5, migrate: (persisted, version) => { const state = persisted as Record; if (version < 2 && state.listDensity) { @@ -843,11 +856,25 @@ export const useSettingsStore = create()( if (version < 4) { state.dateFormat = 'smart'; } + // v5: allMailFolderIds went from a global `string[] | null` to a + // per-account `Record`. The legacy global list + // can't be attributed to a specific account here (the active account + // isn't known at migrate time), so it's dropped - each account starts + // "not configured" (defaults to all no-role folders). + if (version < 5 || !isPlainRecord(state.allMailFolderIds)) { + state.allMailFolderIds = {}; + } return state as unknown as SettingsState; }, onRehydrateStorage: () => { return (state) => { if (state) { + // Defensive: a legacy global array or any non-record value (e.g. + // synced from an older client) is coerced to an empty map so + // per-account consumers never see a non-record. + if (!isPlainRecord(state.allMailFolderIds)) { + state.allMailFolderIds = {}; + } applyFontSize(state.fontSize); applyDensity(state.density); applyAnimations(state.animationsEnabled); From 8af6694152524e7e0b1f3de7e2ffd33b57b59ebe Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:39:33 +0200 Subject: [PATCH 5/5] fix: strip reply/forward prefixes followed by a full-width colon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prefix-stripping regex only matched an ASCII ":", so a localized prefix from a CJK mail client (e.g. "回复:foo", using the full-width colon U+FF1A) was left in place. On reply this caused the user's own prefix to be stacked on top, growing the subject chain. Accept both ":" and ":" after the prefix token. Adds tests. --- lib/__tests__/subject-prefix.test.ts | 33 ++++++++++------------------ lib/subject-prefix.ts | 4 +++- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/lib/__tests__/subject-prefix.test.ts b/lib/__tests__/subject-prefix.test.ts index 3fa4b4fe..bb514754 100644 --- a/lib/__tests__/subject-prefix.test.ts +++ b/lib/__tests__/subject-prefix.test.ts @@ -10,10 +10,9 @@ describe('stripSubjectPrefixes', () => { expect(stripSubjectPrefixes('Re: AW: WG: foo')).toBe('foo'); }); - it('strips the Outlook [N] counter and Eudora *N counter', () => { + it('strips the Outlook [N] and Eudora *N counters', () => { expect(stripSubjectPrefixes('Re[2]: foo')).toBe('foo'); expect(stripSubjectPrefixes('Re*3: foo')).toBe('foo'); - expect(stripSubjectPrefixes('Re*: foo')).toBe('foo'); }); it('is case-insensitive and idempotent', () => { @@ -21,47 +20,37 @@ describe('stripSubjectPrefixes', () => { expect(stripSubjectPrefixes(stripSubjectPrefixes('RE: Re: foo'))).toBe('foo'); }); - it('strips a Cyrillic reply token', () => { + it('strips a Cyrillic token and an ASCII-colon Chinese token', () => { expect(stripSubjectPrefixes('Ответ: foo')).toBe('foo'); - }); - - it('strips a Chinese token followed by an ASCII colon', () => { expect(stripSubjectPrefixes('回复: foo')).toBe('foo'); }); - it('CHARACTERISATION: does NOT strip a token followed by a full-width colon', () => { - // The colon in the regex is ASCII ":"; a full-width ":" (U+FF1A), as some - // CJK mail clients emit, is left untouched. Likely a bug — see follow-ups. - expect(stripSubjectPrefixes('回复:foo')).toBe('回复:foo'); + it('strips a token followed by a full-width colon (CJK clients)', () => { + expect(stripSubjectPrefixes('回复:foo')).toBe('foo'); + expect(stripSubjectPrefixes('回覆:foo')).toBe('foo'); + expect(stripSubjectPrefixes('Re:foo')).toBe('foo'); }); - it('does NOT strip a bare single-letter "R:" (would eat real subjects)', () => { + it('still does not strip a bare single-letter "R:"', () => { expect(stripSubjectPrefixes('R: budget 2024')).toBe('R: budget 2024'); }); - it('returns "" for empty / null / undefined', () => { + it('returns "" for empty / null / undefined and leaves clean subjects alone', () => { expect(stripSubjectPrefixes('')).toBe(''); expect(stripSubjectPrefixes(null)).toBe(''); expect(stripSubjectPrefixes(undefined)).toBe(''); - }); - - it('leaves a prefix-free subject untouched', () => { expect(stripSubjectPrefixes('foo')).toBe('foo'); }); }); describe('buildReplySubject / buildForwardSubject', () => { - it('replaces an existing prefix chain with the given prefix', () => { - expect(buildReplySubject('AW: WG: foo', 'Re:')).toBe('Re: foo'); + it('replaces a prefix chain (incl. a full-width colon) with the given prefix', () => { + expect(buildReplySubject('回复:foo', 'Re:')).toBe('Re: foo'); expect(buildForwardSubject('Re: foo', 'Fwd:')).toBe('Fwd: foo'); }); - it('prepends the prefix to a prefix-free subject', () => { + it('prepends to a clean subject and returns the bare prefix for empty input', () => { expect(buildReplySubject('foo', 'AW:')).toBe('AW: foo'); - }); - - it('returns just the bare prefix for an empty subject', () => { expect(buildReplySubject('', 'AW:')).toBe('AW:'); - expect(buildForwardSubject(null, 'Fwd:')).toBe('Fwd:'); }); }); diff --git a/lib/subject-prefix.ts b/lib/subject-prefix.ts index 5ca0d5aa..766155a7 100644 --- a/lib/subject-prefix.ts +++ b/lib/subject-prefix.ts @@ -64,8 +64,10 @@ function buildPrefixRegex(tokens: string[]): RegExp { // Sort by length DESC so longer tokens (e.g. "Пересл") win over their // shorter prefixes (e.g. "Пер") during alternation matching. escaped.sort((a, b) => b.length - a.length); + // Accept both the ASCII colon and the full-width colon ":" (U+FF1A) that CJK + // mail clients emit after a localized prefix (e.g. "回复:foo"). return new RegExp( - `^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*:\\s*`, + `^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*[:\\uFF1A]\\s*`, "i", ); }