mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: filter search results by message size
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user