feat: flip $junk/$notjunk on spam, search all folders by default, keep the search across folders and coalesce refreshes in the mail store (#850, #788, #553, #175)

This commit is contained in:
Linus Rath
2026-08-29 18:22:14 +02:00
parent 8206893860
commit f07e039ef0
9 changed files with 1575 additions and 352 deletions
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import {
collapseThreads,
expandThreadSelection,
getEmailTagIds,
getThreadTagIds,
tagIdFromKeyword,
} from '../thread-utils';
import type { Email } from '../../api/types';
function email(id: string, threadId: string, receivedAt: string, keywords: Record<string, boolean> = {}): Email {
return {
id, threadId, receivedAt, keywords, mailboxIds: {}, size: 0, hasAttachment: false,
};
}
describe('tag ids', () => {
it('reads both the $label: and legacy $color: prefixes once', () => {
expect(getEmailTagIds({ '$label:work': true, '$color:work': true, '$label:todo': true, $seen: true, '$label:off': false }))
.toEqual(['work', 'todo']);
expect(getEmailTagIds(undefined)).toEqual([]);
});
it('unions the tags of a whole thread', () => {
expect(getThreadTagIds([
email('a', 't', '2026-01-01T00:00:00Z', { '$label:work': true }),
email('b', 't', '2026-01-02T00:00:00Z', { '$label:todo': true, '$label:work': true }),
])).toEqual(['work', 'todo']);
});
it('extracts the tag id from a keyword', () => {
expect(tagIdFromKeyword('$label:work')).toBe('work');
expect(tagIdFromKeyword('$color:work')).toBe('work');
expect(tagIdFromKeyword('$seen')).toBeNull();
expect(tagIdFromKeyword('$label:')).toBeNull();
});
});
describe('collapseThreads', () => {
const oldest = email('a', 't1', '2026-01-01T00:00:00Z');
const newest = email('b', 't1', '2026-01-03T00:00:00Z');
const other = email('c', 't2', '2026-01-02T00:00:00Z');
it('represents a thread by its newest message even in an ascending list', () => {
const rows = collapseThreads([oldest, other, newest], false);
expect(rows.map((e) => e.id)).toEqual(['b', 'c']);
});
it('keeps the position of the first message the list showed', () => {
const rows = collapseThreads([other, oldest, newest], false);
expect(rows.map((e) => e.id)).toEqual(['c', 'b']);
});
it('leaves every message as its own row when threading is off', () => {
expect(collapseThreads([oldest, other, newest], true).map((e) => e.id)).toEqual(['a', 'c', 'b']);
});
it('puts threads with a pinned message first', () => {
const pinned = email('d', 't3', '2025-12-01T00:00:00Z', { $pinned: true });
expect(collapseThreads([other, oldest, pinned], false).map((e) => e.id)).toEqual(['d', 'c', 'a']);
expect(collapseThreads([other, oldest, pinned], false, { pinnedFirst: false }).map((e) => e.id)).toEqual(['c', 'a', 'd']);
});
});
describe('expandThreadSelection', () => {
const emails = [
email('a', 't1', '2026-01-01T00:00:00Z'),
email('b', 't1', '2026-01-03T00:00:00Z'),
email('c', 't2', '2026-01-02T00:00:00Z'),
];
it('expands a representative to every loaded message of its thread', () => {
expect(expandThreadSelection(['b'], emails, false)).toEqual(['a', 'b']);
expect(expandThreadSelection(['b', 'c'], emails, false)).toEqual(['a', 'b', 'c']);
});
it('passes ids through when threading is off or the id is unknown', () => {
expect(expandThreadSelection(['b'], emails, true)).toEqual(['b']);
expect(expandThreadSelection(['zzz'], emails, false)).toEqual(['zzz']);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { Alert } from 'react-native';
import type { DeleteAction } from '../stores/settings-store';
/**
* Whether a delete would destroy the message outright rather than move it to
* Trash. Mirrors the rule in `email-store.deleteEmail`/`deleteEmailsBatch`:
* already in Trash, the global "permanent" delete action, or junk with the
* "permanently delete junk" option.
*/
export function isPermanentDelete(opts: {
inTrash: boolean;
inJunk: boolean;
deleteAction: DeleteAction;
permanentlyDeleteJunk: boolean;
}): boolean {
return opts.inTrash || opts.deleteAction === 'permanent' || (opts.permanentlyDeleteJunk && opts.inJunk);
}
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
/**
* Confirm dialog shown before any permanent destroy (webmail parity:
* `mail-app.tsx` / `email-list.tsx` confirm before a destroy). Resolves true
* when the user picked Delete.
*/
export function confirmPermanentDelete(count: number, t: Translate): Promise<boolean> {
return new Promise((resolve) => {
const message = count === 1
? t('email_list.permanent_delete_confirm_message', 'This email will be permanently deleted. This action cannot be undone.')
: t('email_list.permanent_delete_confirm_batch_message', `These ${count} emails will be permanently deleted. This action cannot be undone.`, { count });
Alert.alert(
t('email_list.permanent_delete_confirm_title', 'Delete permanently?'),
message,
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => resolve(false) },
{ text: t('common.delete', 'Delete'), style: 'destructive', onPress: () => resolve(true) },
],
{ cancelable: true, onDismiss: () => resolve(false) },
);
});
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Localizes the display name of special-use mailboxes (port of the webmail's
* `lib/mailbox-label.ts`, #404).
*
* Mail servers (e.g. Stalwart) always create system folders with English
* names — "Inbox", "Sent", "Junk" — regardless of the account locale. JMAP
* exposes the special-use semantics via the mailbox `role`, so we map that
* role onto a translated label. Folders without a recognized role — i.e.
* user-created folders — keep their server name untouched.
*
* The keys live under the `sidebar.mailboxes` namespace; `junk`, `flagged`
* and `all` reuse the existing `spam`/`starred`/`all_mail` keys so every
* locale already has a translation.
*/
const ROLE_TRANSLATION_KEY: Record<string, string> = {
inbox: 'inbox',
sent: 'sent',
drafts: 'drafts',
trash: 'trash',
archive: 'archive',
junk: 'spam',
spam: 'spam',
important: 'important',
flagged: 'starred',
all: 'all_mail',
};
type Translate = (key: string, fallback?: string) => string;
/**
* The localized name for a mailbox, or its raw server name when the role is
* unknown/absent. `t` is the root-scoped translate function.
*/
export function localizeMailboxName(
role: string | undefined | null,
name: string,
t: Translate,
): string {
if (!role) return name;
const key = ROLE_TRANSLATION_KEY[role];
return key ? t(`sidebar.mailboxes.${key}`, name) : name;
}
+312
View File
@@ -0,0 +1,312 @@
import type { Email } from '../api/types';
/**
* Configurable message-list ordering (#718). Verbatim port of the webmail's
* lib/message-list-order.ts.
*
* An ordering is a prioritised list of up to MAX_SORT_LEVELS sort levels. Each
* level is a {criterion, direction} pair; the list maps 1:1 onto the JMAP
* Email/query `sort` array (RFC 8621 §4.4.2), so the server orders the whole
* mailbox and pagination stays consistent. This module owns every piece of
* that mapping so the UI only ever deals in semantic choices ("unread first",
* "newest first") and the isAscending polarity is decided in exactly one place.
*/
export type SortCriterion =
| 'unread'
| 'starred'
| 'tag'
| 'receivedAt'
| 'sentAt'
| 'from'
| 'subject'
| 'size';
/**
* Direction of the criterion's *value*: `desc` puts the higher / true value
* first. For the boolean criteria the value is "is unread" / "is starred" /
* "has the tag", so `desc` means "unread first", "starred first", "tagged
* first". For dates `desc` is newest first; for size largest first; for
* sender / subject `asc` is A→Z.
*/
export type SortDirection = 'asc' | 'desc';
export interface SortLevel {
criterion: SortCriterion;
direction: SortDirection;
/** Only for criterion 'tag': the KeywordDefinition id (JMAP keyword $label:<id>). */
tagId?: string;
}
/** Which folders the configured order applies to. */
export type MessageListOrderScope = 'inbox' | 'all';
/**
* How the server interprets `isAscending` on the keyword comparators.
*
* RFC 8621 §4.4.2: hasKeyword is a boolean value (false < true), so
* `isAscending: false` puts messages that HAVE the keyword first - the RFC's
* own `$flagged` example "would sort Emails in flagged Threads first" with
* isAscending false. Stalwart (verified on 0.16.8, reported upstream) does the
* opposite: `isAscending: true` puts has-keyword first. The client probes the
* server once per account (see JMAPClient.resolveKeywordSortPolarity) and
* feeds the result in here, so a fixed server flips back automatically.
*/
export type KeywordSortPolarity = 'rfc' | 'inverted';
export const MAX_SORT_LEVELS = 3;
export const SORT_CRITERIA: readonly SortCriterion[] = [
'unread',
'starred',
'tag',
'receivedAt',
'sentAt',
'from',
'subject',
'size',
];
const KEYWORD_CRITERIA: ReadonlySet<SortCriterion> = new Set(['unread', 'starred', 'tag']);
export function isKeywordCriterion(criterion: SortCriterion): boolean {
return KEYWORD_CRITERIA.has(criterion);
}
/** The direction a freshly added level gets: the one people mean by default. */
export function defaultDirection(criterion: SortCriterion): SortDirection {
return criterion === 'from' || criterion === 'subject' ? 'asc' : 'desc';
}
export interface JMAPEmailComparator {
property: string;
isAscending: boolean;
keyword?: string;
}
export const DEFAULT_COMPARATOR: JMAPEmailComparator = { property: 'receivedAt', isAscending: false };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Coerces an untrusted value (persisted state, a synced settings blob, a file
* import) into a valid level list: unknown criteria and directions are dropped,
* a tag level without a tag id is dropped, duplicate criteria are dropped (a
* second "unread" level can never change the order), and the list is capped.
*/
export function sanitizeSortLevels(value: unknown): SortLevel[] {
if (!Array.isArray(value)) return [];
const out: SortLevel[] = [];
const seen = new Set<string>();
for (const raw of value) {
if (!isRecord(raw)) continue;
const criterion = raw.criterion;
if (typeof criterion !== 'string' || !SORT_CRITERIA.includes(criterion as SortCriterion)) continue;
const direction: SortDirection = raw.direction === 'asc' || raw.direction === 'desc'
? raw.direction
: defaultDirection(criterion as SortCriterion);
const level: SortLevel = { criterion: criterion as SortCriterion, direction };
if (criterion === 'tag') {
if (typeof raw.tagId !== 'string' || raw.tagId.length === 0) continue;
level.tagId = raw.tagId;
}
const key = criterion === 'tag' ? `tag:${level.tagId}` : criterion;
if (seen.has(key)) continue;
seen.add(key);
out.push(level);
if (out.length >= MAX_SORT_LEVELS) break;
}
return out;
}
/** The JMAP keyword a boolean level sorts on, or null for value criteria. */
export function levelKeyword(level: SortLevel): string | null {
switch (level.criterion) {
case 'unread': return '$seen';
case 'starred': return '$flagged';
case 'tag': return level.tagId ? `$label:${level.tagId}` : null;
default: return null;
}
}
/**
* Whether messages that HAVE the level's keyword should come first. "Unread
* first" is the absence of $seen first, so it inverts relative to the others.
*/
export function keywordFirst(level: SortLevel): boolean {
const wantsTrueFirst = level.direction === 'desc';
return level.criterion === 'unread' ? !wantsTrueFirst : wantsTrueFirst;
}
/** True when the order (or pinned-first) needs a keyword comparator server-side. */
export function hasKeywordLevels(levels: SortLevel[], pinnedFirst = false): boolean {
return pinnedFirst || levels.some(l => levelKeyword(l) !== null);
}
/** A "chronological" order: nothing configured, or only newest-first. */
export function isDefaultOrder(levels: SortLevel[]): boolean {
return levels.length === 0
|| (levels.length === 1 && levels[0].criterion === 'receivedAt' && levels[0].direction === 'desc');
}
/**
* Builds the Email/query `sort` array for a level list.
*
* - `pinnedFirst` prepends the existing $pinned-on-top comparator.
* - `polarity` is the server's keyword comparator polarity (see
* KeywordSortPolarity); defaults to the RFC reading.
* - `keywordSortSupported: false` drops every keyword comparator (the server
* advertises no hasKeyword support or refused it with unsupportedSort); the
* remaining value comparators still apply.
* - A trailing receivedAt-desc tie-breaker is always present so the order is
* total and stable across pages.
*/
export function buildEmailSort(
levels: SortLevel[],
opts: { pinnedFirst?: boolean; polarity?: KeywordSortPolarity; keywordSortSupported?: boolean } = {},
): JMAPEmailComparator[] {
const polarity = opts.polarity ?? 'rfc';
const keywordsOk = opts.keywordSortSupported !== false;
const keywordComparator = (keyword: string, first: boolean): JMAPEmailComparator => ({
property: 'hasKeyword',
keyword,
// RFC: false < true, so has-keyword first is descending. Inverted servers
// read it the other way round.
isAscending: polarity === 'rfc' ? !first : first,
});
const sort: JMAPEmailComparator[] = [];
if (opts.pinnedFirst && keywordsOk) {
sort.push(keywordComparator('$pinned', true));
}
let hasDateLevel = false;
for (const level of levels) {
const keyword = levelKeyword(level);
if (keyword) {
if (keywordsOk) sort.push(keywordComparator(keyword, keywordFirst(level)));
continue;
}
if (level.criterion === 'receivedAt') hasDateLevel = true;
sort.push({ property: level.criterion, isAscending: level.direction === 'asc' });
}
if (!hasDateLevel) sort.push({ ...DEFAULT_COMPARATOR });
return sort;
}
/** RFC 8621 "from"/"to" sort value: the first address's name, else its email. */
function addressSortValue(email: Email): string {
const first = email.from?.[0];
if (!first) return '';
return (first.name || first.email || '').toLowerCase();
}
/** RFC 5256 §2.1 base subject, approximated: strip reply/forward prefixes. */
function baseSubject(subject: string | undefined): string {
return (subject ?? '')
.replace(/^\s*(?:(?:re|fwd?|aw|wg|sv|vs|tr)\s*(?:\[\d+\])?\s*:\s*)+/i, '')
.trim()
.toLowerCase();
}
function time(value: string | undefined): number {
if (!value) return 0;
const t = new Date(value).getTime();
return Number.isNaN(t) ? 0 : t;
}
function levelValue(level: SortLevel, email: Email): number | string {
switch (level.criterion) {
case 'unread': return email.keywords?.$seen ? 0 : 1;
case 'starred': return email.keywords?.$flagged ? 1 : 0;
case 'tag': return level.tagId && email.keywords?.[`$label:${level.tagId}`] ? 1 : 0;
case 'receivedAt': return time(email.receivedAt);
case 'sentAt': return time(email.sentAt || email.receivedAt);
case 'size': return email.size ?? 0;
case 'from': return addressSortValue(email);
case 'subject': return baseSubject(email.subject);
}
}
function compareValues(a: number | string, b: number | string): number {
if (typeof a === 'string' || typeof b === 'string') {
return String(a).localeCompare(String(b));
}
return a - b;
}
/**
* Client-side mirror of `buildEmailSort` for the same level list. Used as the
* within-page safety net (some servers ignore the sort on unfiltered queries),
* for merging per-account pages in the unified view, for the demo client, and
* to keep thread grouping from undoing the server order.
*/
export function compareEmails(
levels: SortLevel[],
opts: { pinnedFirst?: boolean } = {},
): (a: Email, b: Email) => number {
const hasDateLevel = levels.some(l => l.criterion === 'receivedAt');
return (a, b) => {
if (opts.pinnedFirst) {
const pa = a.keywords?.['$pinned'] ? 1 : 0;
const pb = b.keywords?.['$pinned'] ? 1 : 0;
if (pa !== pb) return pb - pa;
}
for (const level of levels) {
const diff = compareValues(levelValue(level, a), levelValue(level, b));
if (diff !== 0) return level.direction === 'asc' ? diff : -diff;
}
if (!hasDateLevel) {
const diff = time(b.receivedAt) - time(a.receivedAt);
if (diff !== 0) return diff;
}
return 0;
};
}
/**
* The order to use for a given folder: the configured levels when they apply
* to every folder, or to the Inbox when scoped to it; chronological otherwise.
*/
export function orderForMailbox(
levels: SortLevel[],
scope: MessageListOrderScope,
role: string | null | undefined,
): SortLevel[] {
if (levels.length === 0) return [];
if (scope === 'all' || role === 'inbox') return levels;
return [];
}
export type OrderPreset = 'chronological' | 'unread_first' | 'starred_first' | 'tagged_first' | 'custom';
export const ORDER_PRESETS: readonly OrderPreset[] = ['chronological', 'unread_first', 'starred_first', 'tagged_first'];
/** Levels for a simple preset; `tagId` is required for `tagged_first`. */
export function presetLevels(preset: OrderPreset, tagId?: string): SortLevel[] {
switch (preset) {
case 'unread_first': return [{ criterion: 'unread', direction: 'desc' }];
case 'starred_first': return [{ criterion: 'starred', direction: 'desc' }];
case 'tagged_first': return tagId ? [{ criterion: 'tag', direction: 'desc', tagId }] : [];
default: return [];
}
}
/** The preset a level list corresponds to, or 'custom' for anything else. */
export function detectPreset(levels: SortLevel[]): OrderPreset {
// A trailing newest-first level is implicit and doesn't make an order custom.
const core = levels.length > 1
&& levels[levels.length - 1].criterion === 'receivedAt'
&& levels[levels.length - 1].direction === 'desc'
? levels.slice(0, -1)
: levels;
if (isDefaultOrder(core)) return 'chronological';
if (core.length !== 1 || core[0].direction !== 'desc') return 'custom';
switch (core[0].criterion) {
case 'unread': return 'unread_first';
case 'starred': return 'starred_first';
case 'tag': return 'tagged_first';
default: return 'custom';
}
}
+142
View File
@@ -0,0 +1,142 @@
import type { Email } from '../api/types';
// Port of the webmail's `lib/thread-utils.ts` (tag id extraction, thread
// grouping helpers) trimmed to what the native list needs.
/** Active prefix for new keyword tags written to JMAP. */
export const KEYWORD_PREFIX = '$label:';
/** Legacy prefix still recognised when reading. */
export const KEYWORD_PREFIX_LEGACY = '$color:';
/**
* Every tag id set on a message. Reads both the current `$label:` prefix and
* the legacy `$color:` prefix; a tag written under both spellings is one tag.
*/
export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
if (!keywords) return [];
const tags = new Set<string>();
for (const key of Object.keys(keywords)) {
if (keywords[key] !== true) continue;
if (key.startsWith(KEYWORD_PREFIX)) tags.add(key.slice(KEYWORD_PREFIX.length));
else if (key.startsWith(KEYWORD_PREFIX_LEGACY)) tags.add(key.slice(KEYWORD_PREFIX_LEGACY.length));
}
return [...tags];
}
/**
* Every tag anywhere in a thread, deduplicated. A collapsed thread row stands
* in for all its messages, so it has to account for all their tags.
*/
export function getThreadTagIds(emails: Email[]): string[] {
const tags = new Set<string>();
for (const email of emails) {
for (const tag of getEmailTagIds(email.keywords)) tags.add(tag);
}
return [...tags];
}
/** The tag id inside a keyword, or null when the keyword is not a tag. */
export function tagIdFromKeyword(keyword: string): string | null {
for (const prefix of [KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY]) {
if (keyword.startsWith(prefix)) {
const id = keyword.slice(prefix.length);
return id.length > 0 ? id : null;
}
}
return null;
}
/** Key a message groups under: its thread, or itself when threading is off. */
export function threadKeyOf(email: Email, disableThreading: boolean): string {
return disableThreading ? email.id : email.threadId || email.id;
}
function time(value: string | undefined): number {
if (!value) return 0;
const t = new Date(value).getTime();
return Number.isNaN(t) ? 0 : t;
}
export function isPinned(email: Email): boolean {
return !!email.keywords?.$pinned;
}
/**
* Collapses same-thread messages to one row each. Each thread's row is its
* *newest* message (by receivedAt) regardless of the list's sort direction,
* and takes the position of whichever of its messages the list showed first —
* RFC 8621 §4.4.3 thread-collapsing semantics, the same rule the webmail's
* `sortThreadGroups` applies. Rows whose thread carries a `$pinned` message
* stay on top, mirroring the pinned-first server sort.
*/
export function collapseThreads(
emails: Email[],
disableThreading: boolean,
opts: { pinnedFirst?: boolean } = {},
): Email[] {
const pinnedFirst = opts.pinnedFirst !== false;
const groups = new Map<string, Email[]>();
for (const e of emails) {
const key = threadKeyOf(e, disableThreading);
const list = groups.get(key);
if (list) list.push(e);
else groups.set(key, [e]);
}
const rows: Array<{ email: Email; pinned: boolean; order: number }> = [];
let order = 0;
for (const list of groups.values()) {
let newest = list[0];
for (const e of list) {
if (time(e.receivedAt) > time(newest.receivedAt)) newest = e;
}
rows.push({ email: newest, pinned: pinnedFirst && list.some(isPinned), order: order++ });
}
if (pinnedFirst) {
rows.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || a.order - b.order);
}
return rows.map((r) => r.email);
}
/** Every loaded message of each thread, keyed by thread key. */
export function groupByThread(emails: Email[], disableThreading: boolean): Map<string, Email[]> {
const groups = new Map<string, Email[]>();
for (const e of emails) {
const key = threadKeyOf(e, disableThreading);
const list = groups.get(key);
if (list) list.push(e);
else groups.set(key, [e]);
}
return groups;
}
/**
* Expands selected representative ids to every loaded message of their
* threads, so batch actions on "3 selected" conversations touch all their
* messages rather than one each. Ids that are not in `emails` pass through.
*/
export function expandThreadSelection(
ids: Iterable<string>,
emails: Email[],
disableThreading: boolean,
): string[] {
const wanted = new Set(ids);
if (disableThreading) return [...wanted];
const byId = new Map(emails.map((e) => [e.id, e]));
const keys = new Set<string>();
for (const id of wanted) {
const e = byId.get(id);
if (e) keys.add(threadKeyOf(e, false));
}
const out: string[] = [];
const seen = new Set<string>();
for (const e of emails) {
if (keys.has(threadKeyOf(e, false)) && !seen.has(e.id)) {
seen.add(e.id);
out.push(e.id);
}
}
for (const id of wanted) {
if (!seen.has(id)) { seen.add(id); out.push(id); }
}
return out;
}
+214 -5
View File
@@ -25,10 +25,19 @@ vi.mock('../../api/email', () => ({
deleteEmail: vi.fn(), deleteEmail: vi.fn(),
deleteEmails: vi.fn(), deleteEmails: vi.fn(),
searchEmails: vi.fn(), searchEmails: vi.fn(),
markAsSpam: vi.fn(),
undoSpam: vi.fn(),
unprefixMailboxId: (id: string, accountId?: string) => unprefixMailboxId: (id: string, accountId?: string) =>
(accountId && id.startsWith(`${accountId}:`) ? id.slice(accountId.length + 1) : id), (accountId && id.startsWith(`${accountId}:`) ? id.slice(accountId.length + 1) : id),
})); }));
// locale-store pulls in expo-localization / react-native I18nManager; the
// store only needs t() for toast labels.
vi.mock('../locale-store', () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
useLocaleStore: { getState: () => ({ locale: 'en', t: (_k: string, f?: string) => f ?? _k }) },
}));
// The mutations now route through the offline outbox. In tests we want the // The mutations now route through the offline outbox. In tests we want the
// "online, nothing queued" fast path: run the supplied online runner (or the // "online, nothing queued" fast path: run the supplied online runner (or the
// op's primitive) immediately so the existing api-call assertions still hold, // op's primitive) immediately so the existing api-call assertions still hold,
@@ -359,10 +368,9 @@ describe('email-store', () => {
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled(); expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
const [mailboxId, opts] = mockQueryEmails.mock.calls[0]; const [mailboxId, opts] = mockQueryEmails.mock.calls[0];
expect(mailboxId).toBe('mb-1'); expect(mailboxId).toBe('mb-1');
expect(opts.filter).toEqual({ // The folder scope travels as the first argument (queryEmails adds the
operator: 'AND', // inMailbox condition); the user filter is passed on its own.
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }], expect(opts.filter).toEqual({ notKeyword: '$seen' });
});
// Filter results must not leak into the base-view snapshot. // Filter results must not leak into the base-view snapshot.
expect(useEmailStore.getState().mailboxSnapshots['mb-1'].emails).toEqual([{ id: 'e9' }]); expect(useEmailStore.getState().mailboxSnapshots['mb-1'].emails).toEqual([{ id: 'e9' }]);
}); });
@@ -569,6 +577,208 @@ describe('email-store', () => {
expect(result).toEqual(fullEmail); expect(result).toEqual(fullEmail);
}); });
}); });
// ── Search scope (#788), tag view, keeping the search across folders (#553)
describe('search scope', () => {
it('searches every folder by default when a text query is active (#788)', async () => {
useEmailStore.setState({ currentMailboxId: 'mb-1', searchQuery: 'invoice', filters: {} });
mockQueryEmails.mockResolvedValue({ ids: [], total: 0, queryState: 'q' });
await useEmailStore.getState().refreshEmails();
const [mailboxId, opts] = mockQueryEmails.mock.calls[0];
expect(mailboxId).toBeUndefined();
expect(opts.filter).toEqual({ text: 'invoice*' });
});
it('scopes a search to the open folder or a picked folder when asked', async () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
mailboxes: [
{ id: 'mb-1', name: 'Inbox', isShared: false } as any,
{ id: 'mb-2', name: 'Receipts', isShared: false } as any,
],
searchQuery: 'invoice',
filters: { folder: 'current' },
});
mockQueryEmails.mockResolvedValue({ ids: [], total: 0, queryState: 'q' });
await useEmailStore.getState().refreshEmails();
expect(mockQueryEmails.mock.calls[0][0]).toBe('mb-1');
useEmailStore.setState({ filters: { folder: 'mb-2' } });
await useEmailStore.getState().refreshEmails();
expect(mockQueryEmails.mock.calls[1][0]).toBe('mb-2');
});
it('a tag view queries hasKeyword across all folders', async () => {
useEmailStore.setState({ currentMailboxId: 'mb-1', searchQuery: '', filters: { keyword: '$label:work' } });
mockQueryEmails.mockResolvedValue({ ids: [], total: 0, queryState: 'q' });
await useEmailStore.getState().refreshEmails();
const [mailboxId, opts] = mockQueryEmails.mock.calls[0];
expect(mailboxId).toBeUndefined();
expect(opts.filter).toEqual({ hasKeyword: '$label:work' });
});
it('keeps the search when switching folders (#553)', async () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
mailboxes: [
{ id: 'mb-1', name: 'Inbox', isShared: false } as any,
{ id: 'mb-2', name: 'Receipts', isShared: false } as any,
],
searchQuery: 'invoice',
filters: { folder: 'current' },
emails: [{ id: 'hit' } as any],
});
mockQueryEmails.mockResolvedValue({ ids: [], total: 0, queryState: 'q' });
await useEmailStore.getState().selectMailbox('mb-2');
expect(useEmailStore.getState().searchQuery).toBe('invoice');
expect(mockQueryEmails.mock.calls[0][0]).toBe('mb-2');
expect(mockQueryEmails.mock.calls[0][1].filter).toEqual({ text: 'invoice*' });
});
});
describe('list hygiene', () => {
it('load-more drops ids that are already on screen', async () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: [{ id: 'e1' } as any, { id: 'e2' } as any],
totalEmails: 10,
});
mockQueryEmails.mockResolvedValue({ ids: ['e2', 'e3'], total: 10 });
mockGetEmails.mockResolvedValue([{ id: 'e3' }]);
await useEmailStore.getState().loadMoreEmails();
expect(mockGetEmails).toHaveBeenCalledWith(['e3'], undefined);
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1', 'e2', 'e3']);
});
it('drains Email/changes while hasMoreChanges is set', async () => {
const mockGetEmailChanges = emailApi.getEmailChanges as ReturnType<typeof vi.fn>;
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: [{ id: 'e1', keywords: {} } as any],
totalEmails: 1,
queryState: 'q-1',
emailStates: { '@primary': 'em-1' },
mailboxSnapshots: { 'mb-1': { emails: [{ id: 'e1', keywords: {} } as any], total: 1, queryState: 'q-1' } },
});
mockGetEmailQueryChanges.mockResolvedValue({
oldQueryState: 'q-1', newQueryState: 'q-2', total: 1, removed: [], added: [],
});
mockGetEmailChanges
.mockResolvedValueOnce({ oldState: 'em-1', newState: 'em-2', hasMoreChanges: true, created: [], updated: ['e1'], destroyed: [] })
.mockResolvedValueOnce({ oldState: 'em-2', newState: 'em-3', hasMoreChanges: false, created: [], updated: [], destroyed: [] });
mockGetEmailsWithState.mockResolvedValue({ list: [{ id: 'e1', keywords: { $seen: true } }], state: 'em-3' });
await useEmailStore.getState().refreshEmails();
expect(mockGetEmailChanges).toHaveBeenCalledTimes(2);
expect(mockGetEmailChanges.mock.calls[1][0]).toBe('em-2');
expect(useEmailStore.getState().emailStates['@primary']).toBe('em-3');
expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: true });
});
it('keeps a just-read row in an open Unread view until it is re-opened', async () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
filters: { isUnread: true },
emails: [{ id: 'e1', keywords: {} } as any, { id: 'e2', keywords: {} } as any],
totalEmails: 2,
});
await useEmailStore.getState().markRead('e1');
expect(useEmailStore.getState().retainedIds).toEqual(['e1']);
mockQueryEmails.mockResolvedValue({ ids: ['e2'], total: 1, queryState: 'q' });
mockGetEmailsWithState.mockResolvedValue({ list: [{ id: 'e2', keywords: {} }], state: 's' });
await useEmailStore.getState().refreshEmails();
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1', 'e2']);
// Re-opening the view (new filters) forgets the retained rows.
useEmailStore.getState().setFilters({ isUnread: true });
expect(useEmailStore.getState().retainedIds).toEqual([]);
});
it('coalesces overlapping refreshes into one run plus one re-run', async () => {
useEmailStore.setState({ currentMailboxId: 'mb-1' });
mockGetEmailQueryChanges.mockResolvedValue(null);
let resolveQuery: (v: unknown) => void = () => {};
mockQueryEmails.mockImplementationOnce(() => new Promise((r) => { resolveQuery = r; }));
mockQueryEmails.mockResolvedValue({ ids: [], total: 0, queryState: 'q' });
const first = useEmailStore.getState().refreshEmails();
const second = useEmailStore.getState().refreshEmails();
const third = useEmailStore.getState().refreshEmails();
expect(second).toBe(first);
expect(third).toBe(first);
resolveQuery({ ids: [], total: 0, queryState: 'q' });
await first;
await new Promise((r) => setTimeout(r, 0));
expect(mockQueryEmails).toHaveBeenCalledTimes(2);
});
});
describe('pin and spam keywords', () => {
it('togglePin writes $pinned, not $important', async () => {
useEmailStore.setState({ emails: [{ id: 'e1', keywords: {} } as any] });
await useEmailStore.getState().togglePin('e1', true);
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $pinned: true }, undefined);
await useEmailStore.getState().togglePin('e1', false);
expect(mockSetKeywords).toHaveBeenLastCalledWith('e1', {}, undefined);
});
const RIGHTS = {} as any;
const inbox = { id: 'mb-1', name: 'Inbox', role: 'inbox', myRights: RIGHTS, isShared: false } as any;
const junk = { id: 'mb-junk', name: 'Junk', role: 'junk', myRights: RIGHTS, isShared: false } as any;
it('markSpam files into Junk flipping $junk/$notjunk and offers an undo that restores keywords', async () => {
const mockMarkAsSpam = emailApi.markAsSpam as ReturnType<typeof vi.fn>;
const mockRestore = emailApi.restoreEmailMailboxes as ReturnType<typeof vi.fn>;
const mockSetKeywordsForEmails = emailApi.setKeywordsForEmails as ReturnType<typeof vi.fn>;
useSettingsStore.getState().updateSetting('deleteAction', 'trash-and-read');
useEmailStore.setState({
mailboxes: [inbox, junk],
currentMailboxId: 'mb-1',
emails: [{ id: 'e1', keywords: { $notjunk: true }, mailboxIds: { 'mb-1': true } } as any],
});
await useEmailStore.getState().markSpam(['e1']);
expect(mockMarkAsSpam).toHaveBeenCalledWith(['e1'], 'mb-junk', undefined, { markRead: true });
expect(useEmailStore.getState().emails).toHaveLength(0);
const undo = useEmailStore.getState().pendingUndo!;
expect(undo.kind).toBe('spam');
expect(undo.items[0].originalKeywords).toEqual({ $notjunk: true });
await useEmailStore.getState().undoLast();
expect(mockRestore).toHaveBeenCalledWith([{ id: 'e1', mailboxIds: { 'mb-1': true } }], undefined);
expect(mockSetKeywordsForEmails).toHaveBeenCalledWith([{ id: 'e1', keywords: { $notjunk: true } }], undefined);
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1']);
useSettingsStore.getState().updateSetting('deleteAction', 'trash');
});
it('unmarkSpam moves back to Inbox with $notjunk', async () => {
const mockUndoSpam = emailApi.undoSpam as ReturnType<typeof vi.fn>;
useEmailStore.setState({
mailboxes: [inbox, junk],
currentMailboxId: 'mb-junk',
emails: [{ id: 'e1', keywords: { $junk: true }, mailboxIds: { 'mb-junk': true } } as any],
});
await useEmailStore.getState().unmarkSpam(['e1']);
expect(mockUndoSpam).toHaveBeenCalledWith(['e1'], 'mb-1', undefined);
expect(useEmailStore.getState().emails).toHaveLength(0);
expect(useEmailStore.getState().pendingUndo?.items[0].originalKeywords).toEqual({ $junk: true });
});
});
// ── Shared (Stalwart group account) mailboxes ──────────────────────────── // ── Shared (Stalwart group account) mailboxes ────────────────────────────
describe('shared mailboxes', () => { describe('shared mailboxes', () => {
const RIGHTS = { const RIGHTS = {
@@ -621,7 +831,6 @@ describe('email-store', () => {
expect(mockQueryEmails).toHaveBeenCalledWith('s-inbox', expect.objectContaining({ expect(mockQueryEmails).toHaveBeenCalledWith('s-inbox', expect.objectContaining({
accountId: 'grp-1', accountId: 'grp-1',
filter: { inMailbox: 's-inbox' },
})); }));
expect(mockGetEmailsWithState).toHaveBeenCalledWith(['e1'], 'grp-1'); expect(mockGetEmailsWithState).toHaveBeenCalledWith(['e1'], 'grp-1');
// Email state tokens are per-account, so the shared account gets its own. // Email state tokens are per-account, so the shared account gets its own.
+673 -347
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
// Recent mail searches, surfaced as suggestions under the search bar (#845).
// Port of the webmail's stores/search-history-store.ts: only the raw text
// queries are kept (never filter panel state), most recent first,
// deduplicated, and capped so the dropdown stays scannable.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const MAX_RECENT_SEARCHES = 10;
interface SearchHistoryState {
recentSearches: string[];
addRecentSearch: (query: string) => void;
removeRecentSearch: (query: string) => void;
clearRecentSearches: () => void;
}
export const useSearchHistoryStore = create<SearchHistoryState>()(
persist(
(set) => ({
recentSearches: [],
addRecentSearch: (query) => {
const trimmed = query.trim();
if (!trimmed) return;
set((state) => ({
recentSearches: [
trimmed,
...state.recentSearches.filter((q) => q.toLowerCase() !== trimmed.toLowerCase()),
].slice(0, MAX_RECENT_SEARCHES),
}));
},
removeRecentSearch: (query) => {
set((state) => ({
recentSearches: state.recentSearches.filter((q) => q !== query),
}));
},
clearRecentSearches: () => set({ recentSearches: [] }),
}),
{
name: 'search-history-storage',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({ recentSearches: state.recentSearches }),
},
),
);
+21
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Identity } from '../api/types'; import type { Identity } from '../api/types';
import { getIdentities as fetchIdentities } from '../api/identity'; import { getIdentities as fetchIdentities } from '../api/identity';
import type { SortLevel, MessageListOrderScope } from '../lib/message-list-order';
export type ExternalContentPolicy = 'allow' | 'block' | 'ask'; export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
export type ThemeMode = 'light' | 'dark' | 'system'; export type ThemeMode = 'light' | 'dark' | 'system';
@@ -179,6 +180,18 @@ interface PersistedSettings {
// (the JMAP Email/query sorts by receivedAt). // (the JMAP Email/query sorts by receivedAt).
mailSortAscending: boolean; mailSortAscending: boolean;
disableThreading: boolean; disableThreading: boolean;
// Configurable list order (#718): presets / up to 3 levels mapped onto the
// JMAP sort, applied to the Inbox only or to every folder. Same shape as
// the webmail so a settings blob round-trips.
messageListOrder: SortLevel[];
messageListOrderScope: MessageListOrderScope;
// Load sender favicons inside Junk (off by default, webmail 1.5.1).
showAvatarsInJunk: boolean;
// Show the "/ total" part of the folder counts in the drawer (#498).
showFolderTotalCount: boolean;
// Unified views span every logged-in account instead of just the active
// one (own + its shared/group folders). Off by default like the webmail.
unifiedCrossAccount: boolean;
mailAttachmentAction: MailAttachmentAction; mailAttachmentAction: MailAttachmentAction;
attachmentPosition: AttachmentPosition; attachmentPosition: AttachmentPosition;
// Reader body: font for text/plain bodies (#830), gutter around the body, // Reader body: font for text/plain bodies (#830), gutter around the body,
@@ -336,6 +349,11 @@ const DEFAULT_PERSISTED: PersistedSettings = {
emailsPerPage: 25, emailsPerPage: 25,
mailSortAscending: false, mailSortAscending: false,
disableThreading: false, disableThreading: false,
messageListOrder: [],
messageListOrderScope: 'inbox',
showAvatarsInJunk: false,
showFolderTotalCount: true,
unifiedCrossAccount: false,
mailAttachmentAction: 'preview', mailAttachmentAction: 'preview',
attachmentPosition: 'beside-sender', attachmentPosition: 'beside-sender',
plainTextFont: 'sans', plainTextFont: 'sans',
@@ -498,6 +516,9 @@ const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolea
dateFormat: oneOf(['smart', 'relative', 'full']), dateFormat: oneOf(['smart', 'relative', 'full']),
timeFormat: oneOf(['12h', '24h']), timeFormat: oneOf(['12h', '24h']),
theme: oneOf(['light', 'dark', 'system']), theme: oneOf(['light', 'dark', 'system']),
messageListOrderScope: oneOf(['inbox', 'all']),
// Levels are sanitized by the consumer (lib/message-list-order sanitizeSortLevels).
messageListOrder: (v) => Array.isArray(v),
fontSize: oneOf(['small', 'medium', 'large']), fontSize: oneOf(['small', 'medium', 'large']),
density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']), density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']),
attachmentReminderKeywords: stringArray, attachmentReminderKeywords: stringArray,