feat: filter search results by message size

This commit is contained in:
Linus Rath
2026-10-31 05:26:03 +01:00
parent 1342e2a0f8
commit aac8a40367
5 changed files with 317 additions and 3 deletions
+27 -1
View File
@@ -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<string, unknown>;
@@ -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');
});
});
+27
View File
@@ -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';
+86 -1
View File
@@ -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 ? (
<FilterChip
label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.size_min', 'Larger than (KB)'), value: `${filters.minSizeKb} KB` })}
onRemove={() => setFilterField('minSizeKb', undefined)}
/>
) : null}
{sizeFilterBytes(filters.maxSizeKb) !== null ? (
<FilterChip
label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.size_max', 'Smaller than (KB)'), value: `${filters.maxSizeKb} KB` })}
onRemove={() => setFilterField('maxSizeKb', undefined)}
/>
) : null}
<Pressable
onPress={() => {
setSearchInput('');
setCustomSize(false);
clearSearchAndFilters();
}}
style={styles.clearAllButton}
@@ -1774,7 +1804,7 @@ export default function EmailListScreen({
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
<View style={styles.filterMenuHeaderActions}>
<Pressable
onPress={() => setFilters({})}
onPress={() => { setCustomSize(false); setFilters({}); }}
style={styles.filterMenuHeaderBtn}
hitSlop={6}
accessibilityRole="button"
@@ -1926,6 +1956,60 @@ export default function EmailListScreen({
</View>
</View>
{/* Size (webmail 1.19 filter panel): the usual "larger than"
steps, or a custom range in KB. */}
<View>
<Text style={styles.filterFieldLabel}>{t('advanced_search.size', 'Size')}</Text>
<View style={styles.filterToggleGroup}>
<ScopeChip
label={t('advanced_search.any_size', 'Any size')}
active={sizePreset === 'any'}
onPress={() => pickSizePreset('any')}
/>
{SIZE_PRESETS_MB.map((mb) => (
<ScopeChip
key={mb}
label={t('advanced_search.larger_than_mb', 'Larger than {size} MB', { size: mb })}
active={sizePreset === `mb${mb}`}
onPress={() => pickSizePreset(`mb${mb}`)}
/>
))}
<ScopeChip
label={t('advanced_search.custom_size', 'Custom size')}
active={sizePreset === 'custom'}
onPress={() => pickSizePreset('custom')}
/>
</View>
{sizePreset === 'custom' && (
<View style={[styles.filterFieldRow, styles.filterSizeRow]}>
<View style={styles.filterFieldHalf}>
<DebouncedFilterInput
value={filters.minSizeKb ?? ''}
onCommit={(v) => 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"
/>
</View>
<View style={styles.filterFieldHalf}>
<DebouncedFilterInput
value={filters.maxSizeKb ?? ''}
onCommit={(v) => 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"
/>
</View>
</View>
)}
</View>
<View style={styles.filterToggleGroup}>
<TriToggle
icon={<Paperclip size={14} color={c.textSecondary} />}
@@ -2396,6 +2480,7 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm,
},
filterFieldHalf: { flex: 1 },
filterSizeRow: { marginTop: spacing.sm },
filterFieldLabel: {
...typography.caption,
color: c.textMuted,
@@ -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<typeof import('./fake-jmap-server').createFakeJmap> }));
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<void>) => { if (onlineRun) await onlineRun(); return { queued: false }; },
applyOrQueue: async (_op: unknown, onlineRun?: () => Promise<void>) => { 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<string, unknown> = {};
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<string, unknown> }).__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<string, unknown>) {
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']);
});
});
+15 -1
View File
@@ -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<string, unknown>)[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;
});
}