diff --git a/src/lib/__tests__/search-utils.test.ts b/src/lib/__tests__/search-utils.test.ts index e174f18f..93c1dd4c 100644 --- a/src/lib/__tests__/search-utils.test.ts +++ b/src/lib/__tests__/search-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { splitSearchText, textConditions, textFilter } from '../search-utils'; +import { sizeFilterBytes, sizePresetOf, splitSearchText, textConditions, textFilter } from '../search-utils'; import { textFilterMatches, type StalwartTextVersion } from './stalwart-text-model'; type Filter = Record; @@ -125,3 +125,29 @@ describe('text search results (P1-7)', () => { expect(hits('0.16', textFilter('project budg*'))).toEqual([]); }); }); + +// The size filter (webmail 720e14622): KB bounds as typed, sent in bytes. +describe('sizeFilterBytes', () => { + it('turns a KB value into bytes', () => { + expect(sizeFilterBytes('1024')).toBe(1024 * 1024); + expect(sizeFilterBytes('1.5')).toBe(1536); + }); + + it('reads an empty, zero, negative or non-numeric value as unset', () => { + for (const value of [undefined, '', '0', '-5', 'abc', 'Infinity']) { + expect(sizeFilterBytes(value)).toBeNull(); + } + }); +}); + +describe('sizePresetOf', () => { + it('names the panel choice for the bounds', () => { + expect(sizePresetOf(undefined, undefined)).toBe('any'); + expect(sizePresetOf('', '')).toBe('any'); + expect(sizePresetOf('1024', undefined)).toBe('mb1'); + expect(sizePresetOf('25600', '')).toBe('mb25'); + expect(sizePresetOf('2048', undefined)).toBe('custom'); + expect(sizePresetOf('1024', '4096')).toBe('custom'); + expect(sizePresetOf(undefined, '100')).toBe('custom'); + }); +}); diff --git a/src/lib/search-utils.ts b/src/lib/search-utils.ts index bb7aac18..a8960622 100644 --- a/src/lib/search-utils.ts +++ b/src/lib/search-utils.ts @@ -1,6 +1,33 @@ // Free-text search conditions for Email/query, ported from the webmail's // lib/jmap/search-utils.ts (5967a1c4) so both clients send the same filters. +/** + * A KB size field (the filter panel's custom size) as a positive byte count + * for Email/query's minSize / maxSize (RFC 8621 §4.4.1), or null when unset + * or not a positive number (webmail lib/jmap/search-utils.ts, 720e14622). + */ +export function sizeFilterBytes(value: string | undefined): number | null { + const kb = Number(value); + if (!value || !Number.isFinite(kb) || kb <= 0) return null; + return Math.round(kb * 1024); +} + +/** The size presets of the filter panel, in MB: "Larger than 1 MB" and so on. */ +export const SIZE_PRESETS_MB = [1, 5, 10, 25] as const; + +/** + * Which size choice the filter panel shows for the KB bounds: none set, a + * "Larger than N MB" preset, or a custom range. + */ +export function sizePresetOf(minSizeKb: string | undefined, maxSizeKb: string | undefined): string { + if (!minSizeKb && !maxSizeKb) return 'any'; + if (!maxSizeKb) { + const mb = SIZE_PRESETS_MB.find((m) => String(m * 1024) === minSizeKb); + if (mb) return `mb${mb}`; + } + return 'custom'; +} + /** Filter properties whose value the server reads as free text. */ export type SearchTextProperty = 'text' | 'from' | 'to' | 'subject' | 'body'; diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index db4d9c1c..0c7e4a1c 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -66,6 +66,7 @@ import { confirmMailRemoval } from '../lib/mail-removal-confirm'; import { moveSheetCurrentId } from '../lib/move-targets'; import { listViewKey } from '../lib/list-view-key'; import { searchScopeFolders } from '../lib/search-scope-folders'; +import { SIZE_PRESETS_MB, sizeFilterBytes, sizePresetOf } from '../lib/search-utils'; import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context'; import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email'; import type { RootStackParamList } from '../navigation/types'; @@ -1091,6 +1092,8 @@ export default function EmailListScreen({ (filters.hasAttachment !== undefined ? 1 : 0) + (filters.isStarred !== undefined ? 1 : 0) + (filters.isUnread !== undefined ? 1 : 0) + + (sizeFilterBytes(filters.minSizeKb) !== null ? 1 : 0) + + (sizeFilterBytes(filters.maxSizeKb) !== null ? 1 : 0) + (filters.keyword ? 1 : 0) + (filters.folder && filters.folder !== 'current' ? 1 : 0); const hasActiveSearchOrFilter = Boolean(storeSearchQuery) || activeFilterCount > 0; @@ -1160,6 +1163,20 @@ export default function EmailListScreen({ }; const [datePickerField, setDatePickerField] = React.useState<'dateAfter' | 'dateBefore' | null>(null); + // "Custom size" with nothing typed yet has no value to tell it from "Any + // size" (webmail search-filter-panel). + const [customSize, setCustomSize] = React.useState(false); + const sizePreset = customSize ? 'custom' : sizePresetOf(filters.minSizeKb, filters.maxSizeKb); + const pickSizePreset = (key: string) => { + setCustomSize(key === 'custom'); + if (key === 'custom') return; + const mb = SIZE_PRESETS_MB.find((m) => `mb${m}` === key); + const updated: EmailFilters = { ...filters }; + delete updated.maxSizeKb; + if (mb) updated.minSizeKb = String(mb * 1024); + else delete updated.minSizeKb; + setFilters(updated); + }; const headerTitle = React.useMemo(() => { if (!currentMailbox) return t('sidebar.mailboxes.inbox', 'Inbox'); @@ -1610,9 +1627,22 @@ export default function EmailListScreen({ onRemove={() => cycleTriStateTo('hasAttachment', undefined)} /> )} + {sizeFilterBytes(filters.minSizeKb) !== null ? ( + setFilterField('minSizeKb', undefined)} + /> + ) : null} + {sizeFilterBytes(filters.maxSizeKb) !== null ? ( + setFilterField('maxSizeKb', undefined)} + /> + ) : null} { setSearchInput(''); + setCustomSize(false); clearSearchAndFilters(); }} style={styles.clearAllButton} @@ -1774,7 +1804,7 @@ export default function EmailListScreen({ {t('email_list.filter_title', 'Filter emails')} setFilters({})} + onPress={() => { setCustomSize(false); setFilters({}); }} style={styles.filterMenuHeaderBtn} hitSlop={6} accessibilityRole="button" @@ -1926,6 +1956,60 @@ export default function EmailListScreen({ + {/* Size (webmail 1.19 filter panel): the usual "larger than" + steps, or a custom range in KB. */} + + {t('advanced_search.size', 'Size')} + + pickSizePreset('any')} + /> + {SIZE_PRESETS_MB.map((mb) => ( + pickSizePreset(`mb${mb}`)} + /> + ))} + pickSizePreset('custom')} + /> + + {sizePreset === 'custom' && ( + + + setFilterField('minSizeKb', v.trim())} + placeholder={t('advanced_search.size_min', 'Larger than (KB)')} + accessibilityLabel={t('advanced_search.size_min', 'Larger than (KB)')} + placeholderTextColor={c.textMuted} + keyboardType="number-pad" + style={styles.filterFieldInput} + testID="advanced-search-min-size" + /> + + + setFilterField('maxSizeKb', v.trim())} + placeholder={t('advanced_search.size_max', 'Smaller than (KB)')} + accessibilityLabel={t('advanced_search.size_max', 'Smaller than (KB)')} + placeholderTextColor={c.textMuted} + keyboardType="number-pad" + style={styles.filterFieldInput} + testID="advanced-search-max-size" + /> + + + )} + + } @@ -2396,6 +2480,7 @@ function makeStyles(c: ThemePalette) { gap: spacing.sm, }, filterFieldHalf: { flex: 1 }, + filterSizeRow: { marginTop: spacing.sm }, filterFieldLabel: { ...typography.caption, color: c.textMuted, diff --git a/src/stores/__tests__/email-store-size-filter.test.ts b/src/stores/__tests__/email-store-size-filter.test.ts new file mode 100644 index 00000000..1aa83ed2 --- /dev/null +++ b/src/stores/__tests__/email-store-size-filter.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createFakeJmap, type FakeEmail } from './fake-jmap-server'; + +// The filter panel's size choice (webmail 1.19, 720e14622): "Larger than +// N MB" or a custom range in KB, sent as Email/query minSize (inclusive) and +// maxSize (exclusive) in bytes. A value that is not a positive number is no +// filter at all, so the folder stays the plain folder view. + +const server = vi.hoisted(() => ({ current: null as null | ReturnType })); + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + isConnected: true, + accountId: 'c', + connectedAccountId: 'c', + username: 'me@example.com', + serverUrl: 'https://mail.example.com', + currentSession: { + apiUrl: 'https://mail.example.com/jmap/', + accounts: { + c: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { emailQuerySortOptions: ['receivedAt'] } } }, + team: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { emailQuerySortOptions: ['receivedAt'] } } }, + }, + }, + getMaxObjectsInGet: () => 500, + getMaxObjectsInSet: () => 500, + getMaxCallsInRequest: () => 16, + getSharedMailAccounts: () => [{ id: 'team', name: 'Team' }], + request: vi.fn((calls) => server.current!.request(calls)), + }, +})); + +vi.mock('../locale-store', () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + useLocaleStore: { getState: () => ({ locale: 'en', t: (_k: string, f?: string) => f ?? _k }) }, +})); + +vi.mock('../outbox-store', () => ({ + applyOrQueueBatch: async (_ops: unknown[], onlineRun?: () => Promise) => { if (onlineRun) await onlineRun(); return { queued: false }; }, + applyOrQueue: async (_op: unknown, onlineRun?: () => Promise) => { if (onlineRun) await onlineRun(); return { queued: false }; }, + useOutboxStore: { getState: () => ({ setAccount: vi.fn(async () => undefined), flush: vi.fn(async () => undefined) }) }, +})); + +vi.mock('../settings-store', () => { + const settings: Record = {}; + return { + useSettingsStore: { + getState: () => ({ + ...settings, + updateSetting: (key: string, value: unknown) => { settings[key] = value; }, + }), + }, + __settings: settings, + }; +}); + +vi.mock('../offline-cache-store', () => ({ + useOfflineCacheStore: { + getState: () => ({ + hydrated: true, + hydrate: vi.fn(), + setAccount: vi.fn(async () => undefined), + totalCount: () => 0, + getEmailsInMailbox: vi.fn(async () => []), + has: () => false, + get: vi.fn(async () => null), + put: vi.fn(async () => undefined), + patch: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + }), + }, +})); + +import { generateAccountId } from '../../lib/account-utils'; +import * as settingsModule from '../settings-store'; +import { useEmailStore } from '../email-store'; +import type { Email, Mailbox } from '../../api/types'; + +const settings = (settingsModule as unknown as { __settings: Record }).__settings; + +const MAILBOXES: Mailbox[] = [{ + id: 'inbox', name: 'Inbox', role: 'inbox', accountId: 'c', isShared: false, + totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: {} as Mailbox['myRights'], +}]; + +const KB = 1024; + +function mail(id: string, day: number, size: number): Email { + return { + id, size, hasAttachment: false, threadId: `t-${id}`, + mailboxIds: { inbox: true }, keywords: {}, + receivedAt: `2026-09-${String(day).padStart(2, '0')}T10:00:00Z`, + subject: `mail ${id}`, + }; +} + +const ids = () => useEmailStore.getState().emails.map((e) => e.id); +const lastQueryFilter = () => server.current!.callsOf('Email/query').at(-1)?.[1].filter; + +async function filterBy(filters: Record) { + useEmailStore.getState().setFilters({ folder: 'current', ...filters }); + await useEmailStore.getState().refreshEmails(); +} + +beforeEach(() => { + server.current = createFakeJmap({ + c: [ + mail('small', 20, 2 * KB), + // Exactly 1 MB: minSize is inclusive, maxSize exclusive. + mail('edge', 19, 1024 * KB), + mail('large', 18, 6 * 1024 * KB), + ], + }); + for (const key of Object.keys(settings)) delete settings[key]; + Object.assign(settings, { + emailsPerPage: 25, mailSortAscending: false, disableThreading: true, + messageListOrder: [], messageListOrderScope: 'inbox', + }); + useEmailStore.getState().reset(); + useEmailStore.setState({ + activeAccountId: generateAccountId('me@example.com', 'https://mail.example.com'), + mailboxes: MAILBOXES, + currentMailboxId: 'inbox', + pendingUndo: null, + }); +}); + +describe('size filter', () => { + it('"Larger than 1 MB" sends minSize in bytes and lists only the large mail', async () => { + await filterBy({ minSizeKb: '1024' }); + expect(lastQueryFilter()).toEqual({ inMailbox: 'inbox', minSize: 1024 * KB }); + expect(ids()).toEqual(['edge', 'large']); + }); + + it('a custom range sends both bounds', async () => { + await filterBy({ minSizeKb: '1', maxSizeKb: '1024' }); + expect(lastQueryFilter()).toEqual({ + operator: 'AND', + conditions: [{ inMailbox: 'inbox' }, { operator: 'AND', conditions: [{ minSize: KB }, { maxSize: 1024 * KB }] }], + }); + expect(ids()).toEqual(['small']); + }); + + it('goes along with a search', async () => { + useEmailStore.setState({ searchQuery: 'mail' }); + await filterBy({ maxSizeKb: '10' }); + expect(lastQueryFilter()).toEqual({ + operator: 'AND', + conditions: [{ inMailbox: 'inbox' }, { operator: 'AND', conditions: [{ text: 'mail' }, { maxSize: 10 * KB }] }], + }); + expect(ids()).toEqual(['small']); + }); + + it('reads a size still being typed as no filter', async () => { + await filterBy({ minSizeKb: '0', maxSizeKb: '-' }); + expect(lastQueryFilter()).toEqual({ inMailbox: 'inbox' }); + expect(ids()).toEqual(['small', 'edge', 'large']); + // Still the folder's own view: its list is kept as the folder's snapshot. + expect(useEmailStore.getState().mailboxSnapshots.inbox?.emails.map((e) => e.id)).toEqual(['small', 'edge', 'large']); + }); +}); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 5f0e5dba..fccf662f 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -43,7 +43,7 @@ import { isTransientNetworkError } from '../lib/network-error'; import { mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox, findArchiveMailbox, findTrashMailbox, ownMailboxes, } from '../lib/mailbox-tree'; -import { textConditions } from '../lib/search-utils'; +import { sizeFilterBytes, textConditions } from '../lib/search-utils'; import { filterHasSnippetTerms, withSearchSnippets, type SnippetBatch } from '../lib/search-snippet'; import { collapseThreads, rowKeyOf } from '../lib/thread-utils'; import { compareEmails, levelKeyword, orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order'; @@ -256,6 +256,12 @@ export interface EmailFilters { hasAttachment?: boolean; // undefined = unset, true = with, false = without isStarred?: boolean; isUnread?: boolean; + /** + * Message size bounds in KB, as typed (RFC 8621 §4.4.1 minSize / maxSize); + * a value that is not a positive number counts as unset. + */ + minSizeKb?: string; + maxSizeKb?: string; /** * Folder scope. Unset means "all folders" while a text query is active * (#788) and "the open folder" otherwise; explicit values come from the @@ -551,6 +557,12 @@ function buildJmapFilter( if (filters.isStarred === true) conditions.push({ hasKeyword: '$flagged' }); else if (filters.isStarred === false) conditions.push({ notKeyword: '$flagged' }); + // minSize is inclusive, maxSize exclusive (RFC 8621 §4.4.1). + const minSize = sizeFilterBytes(filters.minSizeKb); + if (minSize !== null) conditions.push({ minSize }); + const maxSize = sizeFilterBytes(filters.maxSizeKb); + if (maxSize !== null) conditions.push({ maxSize }); + if (conditions.length === 0) return undefined; if (conditions.length === 1) return conditions[0]; return { operator: 'AND', conditions }; @@ -573,6 +585,8 @@ function activeFilterKeys(filters: EmailFilters): string[] { const v = (filters as Record)[k]; if (v === undefined || v === '') return false; if (k === 'folder' && v === 'current') return false; + // A size still being typed ("0", "-") narrows nothing (webmail isFilterEmpty). + if ((k === 'minSizeKb' || k === 'maxSizeKb') && sizeFilterBytes(v as string) === null) return false; return true; }); }