mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +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 { 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';
|
import { textFilterMatches, type StalwartTextVersion } from './stalwart-text-model';
|
||||||
|
|
||||||
type Filter = Record<string, unknown>;
|
type Filter = Record<string, unknown>;
|
||||||
@@ -125,3 +125,29 @@ describe('text search results (P1-7)', () => {
|
|||||||
expect(hits('0.16', textFilter('project budg*'))).toEqual([]);
|
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
|
// 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.
|
// 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. */
|
/** Filter properties whose value the server reads as free text. */
|
||||||
export type SearchTextProperty = 'text' | 'from' | 'to' | 'subject' | 'body';
|
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 { moveSheetCurrentId } from '../lib/move-targets';
|
||||||
import { listViewKey } from '../lib/list-view-key';
|
import { listViewKey } from '../lib/list-view-key';
|
||||||
import { searchScopeFolders } from '../lib/search-scope-folders';
|
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 { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
|
||||||
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
@@ -1091,6 +1092,8 @@ export default function EmailListScreen({
|
|||||||
(filters.hasAttachment !== undefined ? 1 : 0) +
|
(filters.hasAttachment !== undefined ? 1 : 0) +
|
||||||
(filters.isStarred !== undefined ? 1 : 0) +
|
(filters.isStarred !== undefined ? 1 : 0) +
|
||||||
(filters.isUnread !== 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.keyword ? 1 : 0) +
|
||||||
(filters.folder && filters.folder !== 'current' ? 1 : 0);
|
(filters.folder && filters.folder !== 'current' ? 1 : 0);
|
||||||
const hasActiveSearchOrFilter = Boolean(storeSearchQuery) || activeFilterCount > 0;
|
const hasActiveSearchOrFilter = Boolean(storeSearchQuery) || activeFilterCount > 0;
|
||||||
@@ -1160,6 +1163,20 @@ export default function EmailListScreen({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [datePickerField, setDatePickerField] = React.useState<'dateAfter' | 'dateBefore' | null>(null);
|
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(() => {
|
const headerTitle = React.useMemo(() => {
|
||||||
if (!currentMailbox) return t('sidebar.mailboxes.inbox', 'Inbox');
|
if (!currentMailbox) return t('sidebar.mailboxes.inbox', 'Inbox');
|
||||||
@@ -1610,9 +1627,22 @@ export default function EmailListScreen({
|
|||||||
onRemove={() => cycleTriStateTo('hasAttachment', undefined)}
|
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
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setSearchInput('');
|
setSearchInput('');
|
||||||
|
setCustomSize(false);
|
||||||
clearSearchAndFilters();
|
clearSearchAndFilters();
|
||||||
}}
|
}}
|
||||||
style={styles.clearAllButton}
|
style={styles.clearAllButton}
|
||||||
@@ -1774,7 +1804,7 @@ export default function EmailListScreen({
|
|||||||
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
|
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
|
||||||
<View style={styles.filterMenuHeaderActions}>
|
<View style={styles.filterMenuHeaderActions}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setFilters({})}
|
onPress={() => { setCustomSize(false); setFilters({}); }}
|
||||||
style={styles.filterMenuHeaderBtn}
|
style={styles.filterMenuHeaderBtn}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -1926,6 +1956,60 @@ export default function EmailListScreen({
|
|||||||
</View>
|
</View>
|
||||||
</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}>
|
<View style={styles.filterToggleGroup}>
|
||||||
<TriToggle
|
<TriToggle
|
||||||
icon={<Paperclip size={14} color={c.textSecondary} />}
|
icon={<Paperclip size={14} color={c.textSecondary} />}
|
||||||
@@ -2396,6 +2480,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
filterFieldHalf: { flex: 1 },
|
filterFieldHalf: { flex: 1 },
|
||||||
|
filterSizeRow: { marginTop: spacing.sm },
|
||||||
filterFieldLabel: {
|
filterFieldLabel: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: c.textMuted,
|
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 {
|
import {
|
||||||
mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox, findArchiveMailbox, findTrashMailbox, ownMailboxes,
|
mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox, findArchiveMailbox, findTrashMailbox, ownMailboxes,
|
||||||
} from '../lib/mailbox-tree';
|
} 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 { filterHasSnippetTerms, withSearchSnippets, type SnippetBatch } from '../lib/search-snippet';
|
||||||
import { collapseThreads, rowKeyOf } from '../lib/thread-utils';
|
import { collapseThreads, rowKeyOf } from '../lib/thread-utils';
|
||||||
import { compareEmails, levelKeyword, orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order';
|
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
|
hasAttachment?: boolean; // undefined = unset, true = with, false = without
|
||||||
isStarred?: boolean;
|
isStarred?: boolean;
|
||||||
isUnread?: 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
|
* Folder scope. Unset means "all folders" while a text query is active
|
||||||
* (#788) and "the open folder" otherwise; explicit values come from the
|
* (#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' });
|
if (filters.isStarred === true) conditions.push({ hasKeyword: '$flagged' });
|
||||||
else if (filters.isStarred === false) conditions.push({ notKeyword: '$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 === 0) return undefined;
|
||||||
if (conditions.length === 1) return conditions[0];
|
if (conditions.length === 1) return conditions[0];
|
||||||
return { operator: 'AND', conditions };
|
return { operator: 'AND', conditions };
|
||||||
@@ -573,6 +585,8 @@ function activeFilterKeys(filters: EmailFilters): string[] {
|
|||||||
const v = (filters as Record<string, unknown>)[k];
|
const v = (filters as Record<string, unknown>)[k];
|
||||||
if (v === undefined || v === '') return false;
|
if (v === undefined || v === '') return false;
|
||||||
if (k === 'folder' && v === 'current') 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;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user