From f07e039ef0286bbd1da11fe3a399148030162ae9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:22:14 +0200 Subject: [PATCH] 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) --- src/lib/__tests__/thread-utils.test.ts | 81 ++ src/lib/delete-confirm.ts | 41 + src/lib/mailbox-label.ts | 42 + src/lib/message-list-order.ts | 312 +++++++ src/lib/thread-utils.ts | 142 +++ src/stores/__tests__/email-store.test.ts | 219 ++++- src/stores/email-store.ts | 1020 ++++++++++++++-------- src/stores/search-history-store.ts | 49 ++ src/stores/settings-store.ts | 21 + 9 files changed, 1575 insertions(+), 352 deletions(-) create mode 100644 src/lib/__tests__/thread-utils.test.ts create mode 100644 src/lib/delete-confirm.ts create mode 100644 src/lib/mailbox-label.ts create mode 100644 src/lib/message-list-order.ts create mode 100644 src/lib/thread-utils.ts create mode 100644 src/stores/search-history-store.ts diff --git a/src/lib/__tests__/thread-utils.test.ts b/src/lib/__tests__/thread-utils.test.ts new file mode 100644 index 00000000..71ed4569 --- /dev/null +++ b/src/lib/__tests__/thread-utils.test.ts @@ -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 = {}): 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']); + }); +}); diff --git a/src/lib/delete-confirm.ts b/src/lib/delete-confirm.ts new file mode 100644 index 00000000..fc9b90db --- /dev/null +++ b/src/lib/delete-confirm.ts @@ -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; + +/** + * 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 { + 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) }, + ); + }); +} diff --git a/src/lib/mailbox-label.ts b/src/lib/mailbox-label.ts new file mode 100644 index 00000000..0962c3de --- /dev/null +++ b/src/lib/mailbox-label.ts @@ -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 = { + 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; +} diff --git a/src/lib/message-list-order.ts b/src/lib/message-list-order.ts new file mode 100644 index 00000000..2c03ae14 --- /dev/null +++ b/src/lib/message-list-order.ts @@ -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:). */ + 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 = 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 { + 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(); + 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'; + } +} diff --git a/src/lib/thread-utils.ts b/src/lib/thread-utils.ts new file mode 100644 index 00000000..0995496c --- /dev/null +++ b/src/lib/thread-utils.ts @@ -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 | undefined): string[] { + if (!keywords) return []; + const tags = new Set(); + 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(); + 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(); + 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 { + const groups = new Map(); + 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, + 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(); + for (const id of wanted) { + const e = byId.get(id); + if (e) keys.add(threadKeyOf(e, false)); + } + const out: string[] = []; + const seen = new Set(); + 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; +} diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index d7a058dd..722c5383 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -25,10 +25,19 @@ vi.mock('../../api/email', () => ({ deleteEmail: vi.fn(), deleteEmails: vi.fn(), searchEmails: vi.fn(), + markAsSpam: vi.fn(), + undoSpam: vi.fn(), unprefixMailboxId: (id: string, accountId?: string) => (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 // "online, nothing queued" fast path: run the supplied online runner (or the // op's primitive) immediately so the existing api-call assertions still hold, @@ -359,10 +368,9 @@ describe('email-store', () => { expect(mockGetEmailQueryChanges).not.toHaveBeenCalled(); const [mailboxId, opts] = mockQueryEmails.mock.calls[0]; expect(mailboxId).toBe('mb-1'); - expect(opts.filter).toEqual({ - operator: 'AND', - conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }], - }); + // The folder scope travels as the first argument (queryEmails adds the + // inMailbox condition); the user filter is passed on its own. + expect(opts.filter).toEqual({ notKeyword: '$seen' }); // Filter results must not leak into the base-view snapshot. expect(useEmailStore.getState().mailboxSnapshots['mb-1'].emails).toEqual([{ id: 'e9' }]); }); @@ -569,6 +577,208 @@ describe('email-store', () => { 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; + 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; + const mockRestore = emailApi.restoreEmailMailboxes as ReturnType; + const mockSetKeywordsForEmails = emailApi.setKeywordsForEmails as ReturnType; + 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; + 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 ──────────────────────────── describe('shared mailboxes', () => { const RIGHTS = { @@ -621,7 +831,6 @@ describe('email-store', () => { expect(mockQueryEmails).toHaveBeenCalledWith('s-inbox', expect.objectContaining({ accountId: 'grp-1', - filter: { inMailbox: 's-inbox' }, })); expect(mockGetEmailsWithState).toHaveBeenCalledWith(['e1'], 'grp-1'); // Email state tokens are per-account, so the shared account gets its own. diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 7b4f483b..6de81d6b 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -25,15 +25,50 @@ import { deleteEmails as apiDeleteEmails, restoreEmailMailboxes, searchEmails as apiSearchEmails, + markAsSpam as apiMarkAsSpam, + undoSpam as apiUndoSpam, unprefixMailboxId, } from '../api/email'; -import { mailboxesForSiblingOf } from '../lib/mailbox-tree'; +import { mailboxesForSiblingOf, findJunkMailbox } from '../lib/mailbox-tree'; import { toWildcardQuery } from '../lib/search-utils'; import { generateAccountId } from '../lib/account-utils'; +import { t } from './locale-store'; import { useSettingsStore } from './settings-store'; import { useOfflineCacheStore } from './offline-cache-store'; import { useOutboxStore, applyOrQueue, applyOrQueueBatch, type OutboxOp } from './outbox-store'; +// ── Refresh coalescing ───────────────────────────────────────────────── +// Push events, mount effects and post-action follow-ups all call +// fetchMailboxes()/refreshEmails(); overlapping runs only multiply requests +// (and trip maxConcurrentRequests / 429 on Stalwart). Share the in-flight run +// per key and queue at most one re-run, like the webmail's `coalesceRefresh` +// (#780). +const inflightRefresh = new Map>(); +const queuedRefresh = new Set(); + +function coalesceRefresh(key: string, run: () => Promise): Promise { + const current = inflightRefresh.get(key); + if (current) { + queuedRefresh.add(key); + return current; + } + const p = (async () => { + try { + await run(); + } finally { + inflightRefresh.delete(key); + if (queuedRefresh.delete(key)) void coalesceRefresh(key, run); + } + })(); + inflightRefresh.set(key, p); + return p; +} + +// Accounts that already got their one automatic mailbox re-fetch after an +// empty/failed first load (Stalwart provisions folders lazily at first login, +// #217). Keyed by the registry account id. +const provisionRetried = new Set(); + // Keep the offline body cache consistent with an optimistic/queued mutation so // re-opening a message while offline shows the change. Fire-and-forget. function patchCache(id: string, changes: { keywords?: Record; mailboxIds?: Record }): void { @@ -137,15 +172,34 @@ function jmapClientServesActiveAccount(activeAccountId: string | null): boolean return generateAccountId(username, serverUrl) === activeAccountId; } +/** Folder scope of a search/filter: every folder, the open one, or a store mailbox id. */ +export type SearchFolderScope = 'all' | 'current' | (string & {}); + export interface EmailFilters { from?: string; to?: string; subject?: string; + body?: string; dateAfter?: string; // YYYY-MM-DD dateBefore?: string; // YYYY-MM-DD hasAttachment?: boolean; // undefined = unset, true = with, false = without isStarred?: boolean; isUnread?: boolean; + /** + * Folder scope. Unset means "all folders" while a text query is active + * (#788) and "the open folder" otherwise; explicit values come from the + * folder chip in the filter panel. + */ + folder?: SearchFolderScope; + /** Tag view (#175): messages carrying this JMAP keyword, across all folders. */ + keyword?: string; +} + +/** The folder scope a query runs in, resolving the unset default. */ +export function effectiveFolderScope(searchQuery: string, filters: EmailFilters): SearchFolderScope { + if (filters.keyword) return 'all'; + if (filters.folder) return filters.folder; + return searchQuery.trim() ? 'all' : 'current'; } // Snapshot of an action that can still be reversed via the undo snackbar. @@ -159,8 +213,16 @@ export interface UndoEntry { createdAt: number; /** JMAP account the messages live under; unset for the user's own mail. */ accountId?: string; - /** Each item is one email's pre-action mailboxIds, used to restore it. */ - items: Array<{ email: Email; originalMailboxIds: Record }>; + /** + * Each item is one email's pre-action mailboxIds, used to restore it. + * `originalKeywords` is set when the action also changed keywords + * (spam / not spam flip `$junk`/`$notjunk`) so undo puts them back too. + */ + items: Array<{ + email: Email; + originalMailboxIds: Record; + originalKeywords?: Record; + }>; } // Cached emails for one mailbox (the base view: no search query, no filters). @@ -210,6 +272,13 @@ export interface EmailState { searchQuery: string; filters: EmailFilters; pendingUndo: UndoEntry | null; + /** + * Rows the user just read/unstarred/untagged while an Unread/Starred/tag + * view was open. They stay in the list until the view is re-opened even + * though the server query no longer matches them (webmail 1.9.0 + * `retainedInViewIds`). + */ + retainedIds: string[]; // ── Actions ──────────────────────────────────────────────────── setActiveAccount: (accountId: string | null) => void; @@ -232,6 +301,14 @@ export interface EmailState { moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise; archiveEmail: (emailId: string) => Promise; deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise; + /** + * File messages into the current account's Junk and flip `$junk`/`$notjunk` + * (#850); honours the "trash-and-read" delete action by also marking read. + * Works for one id or a selection; offers undo. + */ + markSpam: (emailIds: string[]) => Promise; + /** Inverse of markSpam: back to Inbox with `$notjunk`. */ + unmarkSpam: (emailIds: string[]) => Promise; // ── Batch (multi-select) actions ────────────────────────────── archiveEmailsBatch: (emailIds: string[]) => Promise; moveEmailsToMailbox: (emailIds: string[], toMailboxId: string) => Promise; @@ -243,23 +320,29 @@ export interface EmailState { setSearchQuery: (query: string) => void; setFilters: (filters: EmailFilters) => void; setSortAscending: (ascending: boolean) => void; + /** + * Drop every cached queryState/snapshot window (they were built under the + * previous order) and re-query. Call after any change to the list order. + */ + invalidateListOrder: () => void; clearSearchAndFilters: () => void; reset: () => void; } function buildJmapFilter( - mailboxId: string, searchQuery: string, filters: EmailFilters, -): Record { - const conditions: Record[] = [{ inMailbox: mailboxId }]; +): Record | undefined { + const conditions: Record[] = []; const trimmed = searchQuery.trim(); if (trimmed) conditions.push({ text: toWildcardQuery(trimmed) }); + if (filters.keyword) conditions.push({ hasKeyword: filters.keyword }); if (filters.from) conditions.push({ from: filters.from }); if (filters.to) conditions.push({ to: filters.to }); if (filters.subject) conditions.push({ subject: filters.subject }); + if (filters.body) conditions.push({ body: filters.body }); if (filters.dateAfter) { const d = new Date(filters.dateAfter); @@ -282,16 +365,58 @@ function buildJmapFilter( if (filters.isStarred === true) conditions.push({ hasKeyword: '$flagged' }); else if (filters.isStarred === false) conditions.push({ notKeyword: '$flagged' }); + if (conditions.length === 0) return undefined; if (conditions.length === 1) return conditions[0]; return { operator: 'AND', conditions }; } +// The raw mailbox id an Email/query is scoped to: the open folder, an +// explicitly picked folder, or undefined for "all folders" (#788). +function queryScope(state: EmailState, current: MailboxRef): { mailboxId: string | undefined; accountId?: string } { + const scope = effectiveFolderScope(state.searchQuery, state.filters); + if (scope === 'current') return { mailboxId: current.id, accountId: current.accountId }; + if (scope === 'all') return { mailboxId: undefined, accountId: current.accountId }; + const ref = refFor(state.mailboxes, scope); + return { mailboxId: ref.id, accountId: ref.accountId }; +} + +// Filter keys that don't narrow the query on their own: a folder scope of +// "current" is the default and must not count as an active filter. +function activeFilterKeys(filters: EmailFilters): string[] { + return Object.keys(filters).filter((k) => { + const v = (filters as Record)[k]; + if (v === undefined || v === '') return false; + if (k === 'folder' && v === 'current') return false; + return true; + }); +} + // True when the user has no search/filters active. Only in this case do we // touch the per-mailbox snapshot cache or use Email/queryChanges — once a // filter is in play, the queryState belongs to a different query and the // cached list no longer represents what's on screen. function isBaseView(searchQuery: string, filters: EmailFilters): boolean { - return !searchQuery.trim() && Object.keys(filters).length === 0; + return !searchQuery.trim() && activeFilterKeys(filters).length === 0; +} + +// The Email/query sort for the current settings. Extended by the message-list +// order presets; today it's the receivedAt direction only. +function currentSort(): Array<{ property: string; isAscending: boolean; keyword?: string }> { + return [{ property: 'receivedAt', isAscending: useSettingsStore.getState().mailSortAscending }]; +} + +// Splice rows the user just read/unstarred back into a freshly re-queried +// Unread/Starred view at their previous position (webmail `mergeRetainedRows`). +function mergeRetainedRows(previous: Email[], fresh: Email[], retainedIds: string[]): Email[] { + if (retainedIds.length === 0) return fresh; + const freshIds = new Set(fresh.map((e) => e.id)); + const retained = new Set(retainedIds); + const out = [...fresh]; + previous.forEach((e, index) => { + if (!retained.has(e.id) || freshIds.has(e.id)) return; + out.splice(Math.min(index, out.length), 0, e); + }); + return out; } // View fields to apply when returning from a search/filter to the base view: @@ -423,6 +548,7 @@ export const useEmailStore = create()( searchQuery: '', filters: {}, pendingUndo: null, + retainedIds: [], // Swap which account's data is currently visible. The previous account's // view is tucked into accountSnapshots so a return-trip can restore it @@ -449,6 +575,7 @@ export const useEmailStore = create()( searchQuery: '', filters: {}, pendingUndo: null, + retainedIds: [], error: null, loading: false, }); @@ -513,7 +640,7 @@ export const useEmailStore = create()( void useOutboxStore.getState().setAccount(null); }, - fetchMailboxes: async () => { + fetchMailboxes: () => { // Skip silently when there's no live session, or when jmapClient is // mid-transition to a different account (see jmapClientServesActiveAccount). // Screens fire this from mount-time useEffects, and on cold start @@ -523,94 +650,8 @@ export const useEmailStore = create()( // switch — return the *previous* account's mailboxes and stamp them // into the new account's snapshot. const activeAccountId = get().activeAccountId; - if (!jmapClientServesActiveAccount(activeAccountId)) return; - - const prevState = get().mailboxState; - // Swap in a freshly-synced set of own folders while leaving the shared - // (group account) ones alone, and vice versa — the two are fetched by - // separate calls and must not clobber each other. - const replaceOwn = (own: Mailbox[], mailboxState?: string) => { - set({ - mailboxes: [...own, ...get().mailboxes.filter((m) => m.isShared)], - ...(mailboxState !== undefined ? { mailboxState } : {}), - }); - }; - - try { - let drainAgain = false; - - // Incremental path: ask for just what changed since last time. Fall - // through to a full refetch when the server can't compute the diff or - // we have no previous state to compare against. - let syncedOwn = false; - if (prevState) { - const changes = await getMailboxChanges(prevState); - // Bail if the user switched accounts during the await — anything we - // set() now would land in the wrong account's bucket. - if (get().activeAccountId !== activeAccountId) return; - if (changes) { - syncedOwn = true; - // No changes at all — keep the cached list, just bump the state. - if ( - changes.created.length === 0 && - changes.updated.length === 0 && - changes.destroyed.length === 0 - ) { - set({ mailboxState: changes.newState }); - } else { - const toFetch = [...changes.created, ...changes.updated]; - const fetched = toFetch.length > 0 - ? (await getMailboxesByIds(toFetch)).list - : []; - if (get().activeAccountId !== activeAccountId) return; - const destroyed = new Set(changes.destroyed); - const byId = new Map(); - for (const m of get().mailboxes) { - if (!m.isShared) byId.set(m.id, m); - } - for (const m of fetched) byId.set(m.id, m); - for (const id of destroyed) byId.delete(id); - replaceOwn( - Array.from(byId.values()), - changes.hasMoreChanges ? prevState : changes.newState, - ); - // hasMoreChanges = there are still pending changes past the - // server's response cap. Run the same path again to drain. - drainAgain = changes.hasMoreChanges; - } - } - // changes === null → cannotCalculateChanges. Fall through to full. - } - - if (!syncedOwn) { - const { list, state } = await getMailboxesWithState(); - if (get().activeAccountId !== activeAccountId) return; - replaceOwn(list, state); - } - - // Shared/group accounts have their own Mailbox state tokens, and there - // are only ever a handful of them, so they're re-read in full rather - // than diffed. Failing to reach one must not lose the own folders we - // just synced, hence the separate try. - try { - const shared = await getSharedMailboxes(); - if (get().activeAccountId !== activeAccountId) return; - set({ mailboxes: [...get().mailboxes.filter((m) => !m.isShared), ...shared] }); - } catch (err) { - console.warn('[email-store] shared mailbox fetch failed:', err); - } - - if (drainAgain) void get().fetchMailboxes(); - } catch (err) { - console.warn('[email-store] fetchMailboxes failed:', err); - if (get().activeAccountId !== activeAccountId) return; - // Don't overwrite the cached list on a transient failure — the user - // can still navigate folders. Only surface the error when we have no - // mailboxes at all to show. - if (get().mailboxes.length === 0) { - set({ error: err instanceof Error ? err.message : 'Failed to load mailboxes' }); - } - } + if (!jmapClientServesActiveAccount(activeAccountId)) return Promise.resolve(); + return coalesceRefresh(`${activeAccountId}:mailboxes`, () => fetchMailboxesImpl(activeAccountId!)); }, selectMailbox: async (mailboxId) => { @@ -620,11 +661,8 @@ export const useEmailStore = create()( // a filter or search makes the visible list unrepresentative of the // cached "no-filter" snapshot. let mailboxSnapshots = state.mailboxSnapshots; - if ( - state.currentMailboxId && - state.currentMailboxId !== mailboxId && - isBaseView(state.searchQuery, state.filters) - ) { + const baseView = isBaseView(state.searchQuery, state.filters); + if (state.currentMailboxId && state.currentMailboxId !== mailboxId && baseView) { mailboxSnapshots = { ...mailboxSnapshots, [state.currentMailboxId]: { @@ -640,11 +678,13 @@ export const useEmailStore = create()( // snapshot, fall through to the offline cache as a second-best seed; // if that's also empty we render the empty-state, not a spinner over // a blank list — better than the previous flash to "Loading…". - let seededEmails: Email[] = incoming?.emails ?? []; - let seededTotal = incoming?.total ?? 0; - let seededQueryState = incoming?.queryState; + // With a search/filter active the search is kept and re-run in the new + // folder (#553), so the current results stay on screen until it lands. + let seededEmails: Email[] = baseView ? incoming?.emails ?? [] : state.emails; + let seededTotal = baseView ? incoming?.total ?? 0 : state.totalEmails; + const seededQueryState = baseView ? incoming?.queryState : undefined; - if (seededEmails.length === 0) { + if (baseView && seededEmails.length === 0) { const cacheStore = useOfflineCacheStore.getState(); if (!cacheStore.hydrated) await cacheStore.hydrate(); if (cacheStore.totalCount() > 0) { @@ -673,9 +713,8 @@ export const useEmailStore = create()( mailboxSnapshots, loading: true, error: null, - searchQuery: '', - filters: {}, pendingUndo: null, + retainedIds: [], }); // Stop here if there's no live session OR jmapClient is mid-transition @@ -691,29 +730,31 @@ export const useEmailStore = create()( }, loadMoreEmails: async () => { - const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters, activeAccountId } = get(); + const state = get(); + const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters, activeAccountId } = state; if (!currentMailboxId || loading || emails.length >= totalEmails) return; if (!jmapClientServesActiveAccount(activeAccountId)) return; set({ loading: true }); try { - const ref = refFor(get().mailboxes, currentMailboxId); - const filter = buildJmapFilter(ref.id, searchQuery, filters); + const scope = queryScope(state, refFor(state.mailboxes, currentMailboxId)); + const filter = buildJmapFilter(searchQuery, filters); const limit = useSettingsStore.getState().emailsPerPage; - const { ids } = await queryEmails(ref.id, { + const { ids } = await queryEmails(scope.mailboxId, { position: emails.length, limit, - sort: [{ - property: 'receivedAt', - isAscending: useSettingsStore.getState().mailSortAscending, - }], + sort: currentSort(), filter, - accountId: ref.accountId, + accountId: scope.accountId, }); if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return; - const newEmails = ids.length > 0 ? await fetchEmailsChunked(ids, ref.accountId) : []; + // A message that arrived between pages shifts positions and would come + // back a second time — drop ids we already show (duplicate keys). + const existingIds = new Set(get().emails.map((e) => e.id)); + const fresh = ids.filter((id) => !existingIds.has(id)); + const newEmails = fresh.length > 0 ? await fetchEmailsChunked(fresh, scope.accountId) : []; if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return; - const merged = [...emails, ...newEmails]; + const merged = [...get().emails, ...newEmails]; const updates: Partial = { emails: merged, loading: false }; if (isBaseView(searchQuery, filters)) { updates.mailboxSnapshots = { @@ -767,215 +808,11 @@ export const useEmailStore = create()( return { imported, failed }; }, - refreshEmails: async () => { - const state = get(); - const { currentMailboxId, searchQuery, filters, emails: existing, activeAccountId } = state; - if (!currentMailboxId) return; - if (!jmapClientServesActiveAccount(activeAccountId)) return; - set({ loading: true, error: null }); - - // A shared (group account) folder is queried against its owning account - // with its unprefixed id; own folders resolve to no override at all. - const ref = refFor(state.mailboxes, currentMailboxId); - const emailState = state.emailStates[stateKey(ref.accountId)]; - const filter = buildJmapFilter(ref.id, searchQuery, filters); - const { emailsPerPage: limit, mailSortAscending: sortAscending } = - useSettingsStore.getState(); - const sort = [{ property: 'receivedAt', isAscending: sortAscending }]; - const baseView = isBaseView(searchQuery, filters); - - // A response that lands after the user switched account/mailbox or - // changed search/filters/sort must not overwrite the newer view. - const viewChanged = () => - get().activeAccountId !== activeAccountId || - get().currentMailboxId !== currentMailboxId || - get().searchQuery !== searchQuery || - get().filters !== filters || - useSettingsStore.getState().mailSortAscending !== sortAscending; - - // The incremental path diffs against the *base-view* list, which lives in - // the per-mailbox snapshot — NOT `emails`, which may still hold search or - // filter results (right after clearing a search, or after a cold start - // that rehydrated a persisted search-result list). Diffing against a - // non-base list lets Email/queryChanges "confirm" the search results as - // the whole mailbox and bakes them into the snapshot (issue #10). The - // snapshot is only trusted when its window is plausibly complete — - // anything shorter can't be patched incrementally and needs the full - // re-query below to rebuild it. - const snap = state.mailboxSnapshots[currentMailboxId]; - - try { - // Incremental sync path: requires the base unfiltered view AND a known - // queryState (so Email/queryChanges has something to diff against). - // Anything else — search, filter active, first-ever load — falls - // through to a full re-query. - if ( - baseView && - snap?.queryState && - snap.emails.length >= Math.min(limit, snap.total) - ) { - const baseEmails = snap.emails; - const queryChanges = await getEmailQueryChanges(ref.id, snap.queryState, { - sort, - filter: undefined, - accountId: ref.accountId, - }); - if (queryChanges) { - // What's in the visible window now: drop removed ids, then apply - // added (id, index) entries. Newly added ids need bodies fetched. - const removed = new Set(queryChanges.removed); - const addedIds = queryChanges.added.map((a) => a.id); - - // Email/changes catches updates to messages already in our list - // (e.g. another device toggled $seen) that queryChanges wouldn't - // report. Skipped when we have no emailState yet — first refresh - // after a cold start primes it from the Email/get below. - let updatedIds: string[] = []; - let destroyedExtra: string[] = []; - let nextEmailState: string | undefined = emailState; - if (emailState) { - const ec = await getEmailChanges(emailState, undefined, ref.accountId); - if (ec) { - updatedIds = ec.updated; - destroyedExtra = ec.destroyed; - nextEmailState = ec.newState; - } else { - // cannotCalculateChanges → forget the state and rely on the - // next full re-sync to repopulate it. - nextEmailState = undefined; - } - } - - // Fetch only what we don't already have. `addedIds` are new to the - // window; `updatedIds` may already be in the base list but their - // keywords/mailboxIds need refreshing. - const existingById = new Map(baseEmails.map((e) => [e.id, e])); - const idsToFetch = [ - ...addedIds.filter((id) => !existingById.has(id)), - ...updatedIds.filter((id) => existingById.has(id)), - ]; - let fetchState: string | undefined; - let fetched: Email[] = []; - if (idsToFetch.length > 0) { - const res = await getEmailsWithState(idsToFetch, ref.accountId); - fetched = res.list; - fetchState = res.state; - } - - // Rebuild the visible window order: start with existing emails, - // drop removed/destroyed, then splice added at their indices. - const allDestroyed = new Set([...destroyedExtra, ...removed]); - const kept = baseEmails.filter((e) => !allDestroyed.has(e.id)); - // Map updated entries onto kept array - const fetchedById = new Map(fetched.map((e) => [e.id, e])); - const updatedKept = kept.map((e) => fetchedById.get(e.id) ?? e); - - // Insert added entries at the indices the server gave us. Sort - // ascending by index so each splice lands at the right offset. - const sortedAdded = [...queryChanges.added].sort((a, b) => a.index - b.index); - const out = [...updatedKept]; - for (const entry of sortedAdded) { - const email = fetchedById.get(entry.id); - if (!email) continue; - const idx = Math.min(entry.index, out.length); - out.splice(idx, 0, email); - } - // Cap the visible list to the user's page size — Email/queryChanges - // can push entries past the original window if many were added. - const trimmed = out.slice(0, Math.max(limit, out.length)); - - const nextQueryState = queryChanges.newQueryState; - const nextTotal = queryChanges.total; - - if (viewChanged()) return; - - set({ - emails: trimmed, - totalEmails: nextTotal, - queryState: nextQueryState, - emailStates: withEmailState( - get().emailStates, - ref.accountId, - nextEmailState ?? fetchState ?? emailState, - ), - loading: false, - mailboxSnapshots: { - ...get().mailboxSnapshots, - [currentMailboxId]: { - emails: trimmed, - total: nextTotal, - queryState: nextQueryState, - }, - }, - }); - return; - } - // queryChanges === null → cannotCalculateChanges. Drop our queryState - // and fall through to a full re-query, which will repopulate it. - } - - // Full re-query path. Used when there's no prior queryState, when the - // user has search/filters active (queryState only tracks the base - // query), or when the server returned cannotCalculateChanges above. - const queryRes = await queryEmails(ref.id, { limit, sort, filter, accountId: ref.accountId }); - const fetched = queryRes.ids.length > 0 - ? await getEmailsWithState(queryRes.ids, ref.accountId) - : { list: [], state: undefined as string | undefined }; - - if (viewChanged()) return; - - const updates: Partial = { - emails: fetched.list, - totalEmails: queryRes.total, - loading: false, - }; - if (baseView) { - updates.queryState = queryRes.queryState; - updates.emailStates = withEmailState(get().emailStates, ref.accountId, fetched.state); - updates.mailboxSnapshots = { - ...get().mailboxSnapshots, - [currentMailboxId]: { - emails: fetched.list, - total: queryRes.total, - queryState: queryRes.queryState, - }, - }; - } - set(updates); - } catch (err) { - console.warn('[email-store] refreshEmails failed:', err); - if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return; - // Keep whatever's visible; only surface the error when the list is - // empty. With cached emails on screen the OfflineBanner already - // tells the user the data is stale. - if (existing.length === 0) { - try { - const cacheStore = useOfflineCacheStore.getState(); - if (!cacheStore.hydrated) await cacheStore.hydrate(); - if (cacheStore.totalCount() > 0) { - const cached = await cacheStore.getEmailsInMailbox( - ref.id, - Math.max(limit, 50), - ); - if (sortAscending) cached.reverse(); - if ( - get().activeAccountId === activeAccountId && - get().currentMailboxId === currentMailboxId && - cached.length > 0 - ) { - set({ emails: cached, totalEmails: cached.length, loading: false, error: null }); - return; - } - } - } catch (cacheErr) { - console.warn('[email-store] refresh cache fallback failed:', cacheErr); - } - } - set({ - loading: false, - error: existing.length > 0 ? null : (err instanceof Error ? err.message : 'Failed to load emails'), - }); - } + refreshEmails: () => { + const { currentMailboxId, activeAccountId } = get(); + if (!currentMailboxId) return Promise.resolve(); + if (!jmapClientServesActiveAccount(activeAccountId)) return Promise.resolve(); + return coalesceRefresh(`${activeAccountId}:emails`, refreshEmailsImpl); }, handleStateChange: async (change) => { @@ -1016,10 +853,12 @@ export const useEmailStore = create()( setSearchQuery: (query) => { const state = get(); + if (state.searchQuery === query) return; const backToBase = !isBaseView(state.searchQuery, state.filters) && isBaseView(query, state.filters); set({ searchQuery: query, + retainedIds: [], ...(backToBase ? restoredBaseView(state) : {}), }); void get().refreshEmails(); @@ -1031,6 +870,7 @@ export const useEmailStore = create()( !isBaseView(state.searchQuery, state.filters) && isBaseView(state.searchQuery, filters); set({ filters, + retainedIds: [], ...(backToBase ? restoredBaseView(state) : {}), }); void get().refreshEmails(); @@ -1040,9 +880,13 @@ export const useEmailStore = create()( const settings = useSettingsStore.getState(); if (settings.mailSortAscending === ascending) return; settings.updateSetting('mailSortAscending', ascending); + get().invalidateListOrder(); + }, + + invalidateListOrder: () => { // Every cached queryState and snapshot window was built under the old - // sort order — drop them all (active view and tucked-away accounts) so - // the next refresh does a full re-query instead of running + // order — drop them all (active view and tucked-away accounts) so the + // next refresh does a full re-query instead of running // Email/queryChanges against a differently-sorted query. const accountSnapshots: Record = {}; for (const [id, acc] of Object.entries(get().accountSnapshots)) { @@ -1054,8 +898,8 @@ export const useEmailStore = create()( clearSearchAndFilters: () => { const state = get(); - if (!state.searchQuery && Object.keys(state.filters).length === 0) return; - set({ searchQuery: '', filters: {}, ...restoredBaseView(state) }); + if (!state.searchQuery && activeFilterKeys(state.filters).length === 0) return; + set({ searchQuery: '', filters: {}, retainedIds: [], ...restoredBaseView(state) }); void get().refreshEmails(); }, @@ -1101,6 +945,7 @@ export const useEmailStore = create()( emails: get().emails.map((e) => e.id === emailId ? { ...e, keywords: nextKeywords } : e, ), + ...(state.filters.isUnread === true ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), }); patchCache(emailId, { keywords: nextKeywords }); }, @@ -1120,6 +965,7 @@ export const useEmailStore = create()( emails: get().emails.map((e) => e.id === emailId ? { ...e, keywords: rest } : e, ), + ...(state.filters.isUnread === false ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), }); patchCache(emailId, { keywords: rest }); }, @@ -1144,6 +990,9 @@ export const useEmailStore = create()( emails: get().emails.map((e) => e.id === emailId ? { ...e, keywords } : e, ), + ...(state.filters.isStarred !== undefined && state.filters.isStarred !== starred + ? { retainedIds: retain(get().retainedIds, [emailId]) } + : {}), }); patchCache(emailId, { keywords }); }, @@ -1152,11 +1001,13 @@ export const useEmailStore = create()( const state = get(); const email = state.emails.find((e) => e.id === emailId); if (!email) return; + // `$pinned` is what the webmail reads and writes; a pin set as + // `$important` was invisible to it (and vice versa). const keywords = { ...email.keywords }; if (pinned) { - keywords.$important = true; + keywords.$pinned = true; } else { - delete keywords.$important; + delete keywords.$pinned; } await applyOrQueue({ kind: 'keywords', @@ -1172,6 +1023,96 @@ export const useEmailStore = create()( patchCache(emailId, { keywords }); }, + markSpam: async (emailIds) => { + const state = get(); + const targets = state.emails.filter((e) => emailIds.includes(e.id)); + if (targets.length === 0) return; + const scoped = mailboxesForSiblingOf(state.mailboxes, state.currentMailboxId); + const junkMailbox = findJunkMailbox(scoped); + if (!junkMailbox) { + set({ error: t('email_list.no_junk_folder', 'Could not find a Spam/Junk folder on the server.') }); + return; + } + const junk = refFor(state.mailboxes, junkMailbox.id); + const markRead = useSettingsStore.getState().deleteAction === 'trash-and-read'; + const junkTarget = { [junk.id]: true }; + const nextKeywords = new Map(targets.map((e) => { + const { $notjunk: _drop, ...rest } = e.keywords ?? {}; + return [e.id, { ...rest, $junk: true, ...(markRead ? { $seen: true } : {}) }]; + })); + const items = targets.map((e) => ({ + email: e, + originalMailboxIds: { ...e.mailboxIds }, + originalKeywords: { ...e.keywords }, + })); + + await applyOrQueueBatch( + targets.flatMap((e): OutboxOp[] => [ + { kind: 'mailboxes', emailId: e.id, accountId: junk.accountId, mailboxIds: junkTarget }, + { kind: 'keywords', emailId: e.id, accountId: junk.accountId, keywords: nextKeywords.get(e.id)! }, + ]), + () => apiMarkAsSpam(targets.map((e) => e.id), junk.id, junk.accountId, { markRead }), + ); + + const removed = new Set(targets.map((e) => e.id)); + set({ + emails: get().emails.filter((e) => !removed.has(e.id)), + pendingUndo: { + kind: 'spam', + label: targets.length === 1 + ? t('email_list.marked_as_spam', 'Marked as spam') + : t('email_list.marked_as_spam_count', `${targets.length} emails marked as spam`, { count: targets.length }), + createdAt: Date.now(), + accountId: junk.accountId, + items, + }, + }); + for (const e of targets) patchCache(e.id, { mailboxIds: junkTarget, keywords: nextKeywords.get(e.id) }); + }, + + unmarkSpam: async (emailIds) => { + const state = get(); + const targets = state.emails.filter((e) => emailIds.includes(e.id)); + if (targets.length === 0) return; + const scoped = mailboxesForSiblingOf(state.mailboxes, state.currentMailboxId); + const inboxMailbox = scoped.find((m) => m.role === 'inbox'); + if (!inboxMailbox) return; + const inbox = refFor(state.mailboxes, inboxMailbox.id); + const inboxTarget = { [inbox.id]: true }; + const nextKeywords = new Map(targets.map((e) => { + const { $junk: _drop, ...rest } = e.keywords ?? {}; + return [e.id, { ...rest, $notjunk: true }]; + })); + const items = targets.map((e) => ({ + email: e, + originalMailboxIds: { ...e.mailboxIds }, + originalKeywords: { ...e.keywords }, + })); + + await applyOrQueueBatch( + targets.flatMap((e): OutboxOp[] => [ + { kind: 'mailboxes', emailId: e.id, accountId: inbox.accountId, mailboxIds: inboxTarget }, + { kind: 'keywords', emailId: e.id, accountId: inbox.accountId, keywords: nextKeywords.get(e.id)! }, + ]), + () => apiUndoSpam(targets.map((e) => e.id), inbox.id, inbox.accountId), + ); + + const removed = new Set(targets.map((e) => e.id)); + set({ + emails: get().emails.filter((e) => !removed.has(e.id)), + pendingUndo: { + kind: 'spam', + label: targets.length === 1 + ? t('email_list.marked_not_spam', 'Marked as not spam') + : t('email_list.marked_not_spam_count', `${targets.length} emails marked as not spam`, { count: targets.length }), + createdAt: Date.now(), + accountId: inbox.accountId, + items, + }, + }); + for (const e of targets) patchCache(e.id, { mailboxIds: inboxTarget, keywords: nextKeywords.get(e.id) }); + }, + moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => { const state = get(); const email = state.emails.find((e) => e.id === emailId); @@ -1181,7 +1122,7 @@ export const useEmailStore = create()( // the user's own folders and a shared account's (or between two shared // accounts) — that would be a copy-then-delete across accounts. if (from.accountId !== to.accountId) { - set({ error: 'Messages can only be moved within the same account' }); + set({ error: t('email_list.move_same_account', 'Messages can only be moved within the same account') }); return; } const original = email ? { ...email.mailboxIds } : null; @@ -1195,11 +1136,13 @@ export const useEmailStore = create()( patchCache(emailId, { mailboxIds: target }); if (email && original) { - const targetName = get().mailboxes.find((m) => m.id === toMailboxId)?.name; + const targetName = mailboxPath(get().mailboxes, toMailboxId); set({ pendingUndo: { kind: 'move', - label: targetName ? `Email moved to ${targetName}` : 'Email moved', + label: targetName + ? t('notifications.moved_to_mailbox', `Email moved to ${targetName}`, { mailbox: targetName }) + : t('notifications.email_moved', 'Email moved'), createdAt: Date.now(), accountId: from.accountId, items: [{ email, originalMailboxIds: original }], @@ -1249,7 +1192,7 @@ export const useEmailStore = create()( emails: get().emails.filter((e) => e.id !== emailId), pendingUndo: { kind: 'archive', - label: 'Email archived', + label: t('notifications.email_archived', 'Email archived'), createdAt: Date.now(), accountId: archive.accountId, items: [{ email, originalMailboxIds: original }], @@ -1325,7 +1268,7 @@ export const useEmailStore = create()( set({ pendingUndo: { kind: 'delete', - label: 'Email moved to Trash', + label: t('email_list.moved_to_trash', 'Email moved to Trash'), createdAt: Date.now(), accountId: source.accountId, items: [{ email, originalMailboxIds: original }], @@ -1376,7 +1319,9 @@ export const useEmailStore = create()( emails: get().emails.filter((e) => !removed.has(e.id)), pendingUndo: { kind: 'archive', - label: targets.length === 1 ? 'Email archived' : `${targets.length} emails archived`, + label: targets.length === 1 + ? t('notifications.email_archived', 'Email archived') + : t('email_list.emails_archived_count', `${targets.length} emails archived`, { count: targets.length }), createdAt: Date.now(), accountId: archive.accountId, items, @@ -1394,7 +1339,7 @@ export const useEmailStore = create()( const to = refFor(mailboxes, toMailboxId); // See moveToMailbox: one Email/set can't span two accounts. if (source.accountId !== to.accountId) { - set({ error: 'Messages can only be moved within the same account' }); + set({ error: t('email_list.move_same_account', 'Messages can only be moved within the same account') }); return; } const targets = emails.filter((e) => emailIds.includes(e.id)); @@ -1416,14 +1361,16 @@ export const useEmailStore = create()( for (const e of targets) { patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, to.id) }); } - const targetName = mailboxes.find((m) => m.id === toMailboxId)?.name; + const targetName = mailboxPath(mailboxes, toMailboxId); set({ emails: get().emails.filter((e) => !removed.has(e.id)), pendingUndo: { kind: 'move', label: targetName - ? `${targets.length === 1 ? 'Email' : `${targets.length} emails`} moved to ${targetName}` - : 'Emails moved', + ? (targets.length === 1 + ? t('notifications.moved_to_mailbox', `Email moved to ${targetName}`, { mailbox: targetName }) + : t('email_list.emails_moved_to', `${targets.length} emails moved to ${targetName}`, { count: targets.length, mailbox: targetName })) + : t('notifications.emails_moved', 'Emails moved'), createdAt: Date.now(), accountId: source.accountId, items, @@ -1517,7 +1464,9 @@ export const useEmailStore = create()( set({ pendingUndo: { kind: 'delete', - label: toTrash.length === 1 ? 'Email moved to Trash' : `${toTrash.length} emails moved to Trash`, + label: toTrash.length === 1 + ? t('email_list.moved_to_trash', 'Email moved to Trash') + : t('email_list.moved_to_trash_count', `${toTrash.length} emails moved to Trash`, { count: toTrash.length }), createdAt: Date.now(), accountId: source.accountId, items: toTrash.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } })), @@ -1551,6 +1500,10 @@ export const useEmailStore = create()( emails: get().emails.map((e) => byId.has(e.id) ? { ...e, keywords: byId.get(e.id)! } : e, ), + // Untagging inside that tag's view keeps the rows until it's re-opened. + ...(!on && state.filters.keyword === token + ? { retainedIds: retain(get().retainedIds, updates.map((u) => u.id)) } + : {}), }); for (const u of updates) patchCache(u.id, { keywords: u.keywords }); }, @@ -1573,11 +1526,31 @@ export const useEmailStore = create()( entry.accountId, ), ); + // Spam / not-spam also flipped `$junk`/`$notjunk` (and maybe `$seen`): + // put the keywords back as they were. + const withKeywords = entry.items.filter((it) => it.originalKeywords); + if (withKeywords.length > 0) { + await applyOrQueueBatch( + withKeywords.map((it): OutboxOp => ({ + kind: 'keywords', + emailId: it.email.id, + accountId: entry.accountId, + keywords: it.originalKeywords!, + })), + () => setKeywordsForEmails( + withKeywords.map((it) => ({ id: it.email.id, keywords: it.originalKeywords! })), + entry.accountId, + ), + ); + } for (const it of entry.items) { - patchCache(it.email.id, { mailboxIds: it.originalMailboxIds }); + patchCache(it.email.id, { + mailboxIds: it.originalMailboxIds, + ...(it.originalKeywords ? { keywords: it.originalKeywords } : {}), + }); } } catch (err) { - set({ error: err instanceof Error ? err.message : 'Undo failed' }); + set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') }); return; } @@ -1589,7 +1562,11 @@ export const useEmailStore = create()( const currentRawId = rawMailboxId(mailboxes, currentMailboxId); const restored = entry.items .filter((it) => it.originalMailboxIds[currentRawId]) - .map((it) => ({ ...it.email, mailboxIds: it.originalMailboxIds })); + .map((it) => ({ + ...it.email, + mailboxIds: it.originalMailboxIds, + ...(it.originalKeywords ? { keywords: it.originalKeywords } : {}), + })); if (restored.length > 0) { const ascending = useSettingsStore.getState().mailSortAscending; const merged = [...restored, ...emails].sort((a, b) => { @@ -1625,6 +1602,7 @@ export const useEmailStore = create()( error: null, searchQuery: '', filters: {}, + retainedIds: [], }), }), { @@ -1705,3 +1683,351 @@ export const useEmailStore = create()( }, ), ); + +// ── Refresh implementations (wrapped by coalesceRefresh above) ───────── + +function retain(current: string[], ids: string[]): string[] { + const set = new Set(current); + for (const id of ids) set.add(id); + return [...set]; +} + +/** "Parent / Child" path of a folder for toasts, like the webmail (1.5.0). */ +function mailboxPath(mailboxes: Mailbox[], mailboxId: string): string | undefined { + const byId = new Map(mailboxes.map((m) => [m.id, m])); + let node = byId.get(mailboxId); + if (!node) return undefined; + const parts = [node.name]; + let guard = 0; + while (node?.parentId && guard++ < 16) { + node = byId.get(node.parentId); + if (node) parts.unshift(node.name); + } + return parts.join(' / '); +} + +async function fetchMailboxesImpl(activeAccountId: string): Promise { + const get = useEmailStore.getState; + const set = useEmailStore.setState; + const prevState = get().mailboxState; + // Swap in a freshly-synced set of own folders while leaving the shared + // (group account) ones alone, and vice versa — the two are fetched by + // separate calls and must not clobber each other. + const replaceOwn = (own: Mailbox[], mailboxState?: string) => { + set({ + mailboxes: [...own, ...get().mailboxes.filter((m) => m.isShared)], + ...(mailboxState !== undefined ? { mailboxState } : {}), + }); + }; + + try { + let drainAgain = false; + + // Incremental path: ask for just what changed since last time. Fall + // through to a full refetch when the server can't compute the diff or + // we have no previous state to compare against. + let syncedOwn = false; + if (prevState) { + const changes = await getMailboxChanges(prevState); + // Bail if the user switched accounts during the await — anything we + // set() now would land in the wrong account's bucket. + if (get().activeAccountId !== activeAccountId) return; + if (changes) { + syncedOwn = true; + // No changes at all — keep the cached list, just bump the state. + if ( + changes.created.length === 0 && + changes.updated.length === 0 && + changes.destroyed.length === 0 + ) { + set({ mailboxState: changes.newState }); + } else { + const toFetch = [...changes.created, ...changes.updated]; + const fetched = toFetch.length > 0 + ? (await getMailboxesByIds(toFetch)).list + : []; + if (get().activeAccountId !== activeAccountId) return; + const destroyed = new Set(changes.destroyed); + const byId = new Map(); + for (const m of get().mailboxes) { + if (!m.isShared) byId.set(m.id, m); + } + for (const m of fetched) byId.set(m.id, m); + for (const id of destroyed) byId.delete(id); + replaceOwn( + Array.from(byId.values()), + changes.hasMoreChanges ? prevState : changes.newState, + ); + // hasMoreChanges = there are still pending changes past the + // server's response cap. Run the same path again to drain. + drainAgain = changes.hasMoreChanges; + } + } + // changes === null → cannotCalculateChanges. Fall through to full. + } + + if (!syncedOwn) { + const { list, state } = await getMailboxesWithState(); + if (get().activeAccountId !== activeAccountId) return; + replaceOwn(list, state); + } + + // Shared/group accounts have their own Mailbox state tokens, and there + // are only ever a handful of them, so they're re-read in full rather + // than diffed. Failing to reach one must not lose the own folders we + // just synced, hence the separate try. + try { + const shared = await getSharedMailboxes(); + if (get().activeAccountId !== activeAccountId) return; + set({ mailboxes: [...get().mailboxes.filter((m) => !m.isShared), ...shared] }); + } catch (err) { + console.warn('[email-store] shared mailbox fetch failed:', err); + } + + if (drainAgain) void get().fetchMailboxes(); + } catch (err) { + console.warn('[email-store] fetchMailboxes failed:', err); + if (get().activeAccountId !== activeAccountId) return; + // Don't overwrite the cached list on a transient failure — the user + // can still navigate folders. Only surface the error when we have no + // mailboxes at all to show. + if (get().mailboxes.length === 0) { + set({ error: err instanceof Error ? err.message : 'Failed to load mailboxes' }); + } + } + + // Stalwart provisions the system folders lazily on first login (#217): an + // empty or failed first Mailbox/get gets one automatic retry after ~2 s. + if (get().mailboxes.length === 0 && !provisionRetried.has(activeAccountId)) { + provisionRetried.add(activeAccountId); + setTimeout(() => { + if (get().activeAccountId === activeAccountId && get().mailboxes.length === 0) { + void get().fetchMailboxes(); + } + }, 2000); + } +} + +async function refreshEmailsImpl(): Promise { + const get = useEmailStore.getState; + const set = useEmailStore.setState; + const state = get(); + const { currentMailboxId, searchQuery, filters, emails: existing, activeAccountId } = state; + if (!currentMailboxId) return; + if (!jmapClientServesActiveAccount(activeAccountId)) return; + set({ loading: true, error: null }); + + // A shared (group account) folder is queried against its owning account + // with its unprefixed id; own folders resolve to no override at all. + const ref = refFor(state.mailboxes, currentMailboxId); + const emailState = state.emailStates[stateKey(ref.accountId)]; + const scope = queryScope(state, ref); + const filter = buildJmapFilter(searchQuery, filters); + const { emailsPerPage: limit, mailSortAscending: sortAscending } = + useSettingsStore.getState(); + const sort = currentSort(); + const baseView = isBaseView(searchQuery, filters); + + // A response that lands after the user switched account/mailbox or + // changed search/filters/sort must not overwrite the newer view. + const viewChanged = () => + get().activeAccountId !== activeAccountId || + get().currentMailboxId !== currentMailboxId || + get().searchQuery !== searchQuery || + get().filters !== filters || + useSettingsStore.getState().mailSortAscending !== sortAscending; + + // The incremental path diffs against the *base-view* list, which lives in + // the per-mailbox snapshot — NOT `emails`, which may still hold search or + // filter results (right after clearing a search, or after a cold start + // that rehydrated a persisted search-result list). Diffing against a + // non-base list lets Email/queryChanges "confirm" the search results as + // the whole mailbox and bakes them into the snapshot (issue #10). The + // snapshot is only trusted when its window is plausibly complete — + // anything shorter can't be patched incrementally and needs the full + // re-query below to rebuild it. + const snap = state.mailboxSnapshots[currentMailboxId]; + + try { + // Incremental sync path: requires the base unfiltered view AND a known + // queryState (so Email/queryChanges has something to diff against). + // Anything else — search, filter active, first-ever load — falls + // through to a full re-query. + if ( + baseView && + snap?.queryState && + snap.emails.length >= Math.min(limit, snap.total) + ) { + const baseEmails = snap.emails; + const queryChanges = await getEmailQueryChanges(ref.id, snap.queryState, { + sort, + filter: undefined, + accountId: ref.accountId, + }); + if (queryChanges) { + // What's in the visible window now: drop removed ids, then apply + // added (id, index) entries. Newly added ids need bodies fetched. + const removed = new Set(queryChanges.removed); + const addedIds = queryChanges.added.map((a) => a.id); + + // Email/changes catches updates to messages already in our list + // (e.g. another device toggled $seen) that queryChanges wouldn't + // report. Skipped when we have no emailState yet — first refresh + // after a cold start primes it from the Email/get below. + let updatedIds: string[] = []; + let destroyedExtra: string[] = []; + let nextEmailState: string | undefined = emailState; + if (emailState) { + // Drain `hasMoreChanges`: the server caps one response, so keep + // asking from the returned state until the delta is complete + // (bounded so a runaway server can't loop us forever). + let since: string | undefined = emailState; + for (let round = 0; since && round < 10; round++) { + const ec = await getEmailChanges(since, undefined, ref.accountId); + if (!ec) { + // cannotCalculateChanges → forget the state and rely on the + // next full re-sync to repopulate it. + nextEmailState = undefined; + break; + } + updatedIds.push(...ec.updated); + destroyedExtra.push(...ec.destroyed); + nextEmailState = ec.newState; + since = ec.hasMoreChanges && ec.newState !== since ? ec.newState : undefined; + } + } + + // Fetch only what we don't already have. `addedIds` are new to the + // window; `updatedIds` may already be in the base list but their + // keywords/mailboxIds need refreshing. + const existingById = new Map(baseEmails.map((e) => [e.id, e])); + const idsToFetch = [ + ...addedIds.filter((id) => !existingById.has(id)), + ...updatedIds.filter((id) => existingById.has(id)), + ]; + let fetchState: string | undefined; + let fetched: Email[] = []; + if (idsToFetch.length > 0) { + const res = await getEmailsWithState(idsToFetch, ref.accountId); + fetched = res.list; + fetchState = res.state; + } + + // Rebuild the visible window order: start with existing emails, + // drop removed/destroyed, then splice added at their indices. + const allDestroyed = new Set([...destroyedExtra, ...removed]); + const kept = baseEmails.filter((e) => !allDestroyed.has(e.id)); + // Map updated entries onto kept array + const fetchedById = new Map(fetched.map((e) => [e.id, e])); + const updatedKept = kept.map((e) => fetchedById.get(e.id) ?? e); + + // Insert added entries at the indices the server gave us. Sort + // ascending by index so each splice lands at the right offset. + const sortedAdded = [...queryChanges.added].sort((a, b) => a.index - b.index); + const out = [...updatedKept]; + for (const entry of sortedAdded) { + const email = fetchedById.get(entry.id); + if (!email) continue; + const idx = Math.min(entry.index, out.length); + out.splice(idx, 0, email); + } + // Keep the window at what the user had scrolled to (at least one + // page) — Email/queryChanges can push entries past the original + // window when many were added; `total` still drives load-more. + const trimmed = out.slice(0, Math.max(limit, baseEmails.length)); + + const nextQueryState = queryChanges.newQueryState; + const nextTotal = queryChanges.total; + + if (viewChanged()) return; + + set({ + emails: trimmed, + totalEmails: nextTotal, + queryState: nextQueryState, + emailStates: withEmailState( + get().emailStates, + ref.accountId, + nextEmailState ?? fetchState ?? emailState, + ), + loading: false, + mailboxSnapshots: { + ...get().mailboxSnapshots, + [currentMailboxId]: { + emails: trimmed, + total: nextTotal, + queryState: nextQueryState, + }, + }, + }); + return; + } + // queryChanges === null → cannotCalculateChanges. Drop our queryState + // and fall through to a full re-query, which will repopulate it. + } + + // Full re-query path. Used when there's no prior queryState, when the + // user has search/filters active (queryState only tracks the base + // query), or when the server returned cannotCalculateChanges above. + const queryRes = await queryEmails(scope.mailboxId, { limit, sort, filter, accountId: scope.accountId }); + const fetched = queryRes.ids.length > 0 + ? await getEmailsWithState(queryRes.ids, scope.accountId) + : { list: [], state: undefined as string | undefined }; + + if (viewChanged()) return; + + const updates: Partial = { + // Rows the user just read/unstarred in this filtered view stay put + // until the view is re-opened, instead of vanishing under them. + emails: baseView ? fetched.list : mergeRetainedRows(get().emails, fetched.list, get().retainedIds), + totalEmails: queryRes.total, + loading: false, + }; + if (baseView) { + updates.queryState = queryRes.queryState; + updates.emailStates = withEmailState(get().emailStates, ref.accountId, fetched.state); + updates.mailboxSnapshots = { + ...get().mailboxSnapshots, + [currentMailboxId]: { + emails: fetched.list, + total: queryRes.total, + queryState: queryRes.queryState, + }, + }; + } + set(updates); + } catch (err) { + console.warn('[email-store] refreshEmails failed:', err); + if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return; + // Keep whatever's visible; only surface the error when the list is + // empty. With cached emails on screen the OfflineBanner already + // tells the user the data is stale. + if (existing.length === 0) { + try { + const cacheStore = useOfflineCacheStore.getState(); + if (!cacheStore.hydrated) await cacheStore.hydrate(); + if (cacheStore.totalCount() > 0) { + const cached = await cacheStore.getEmailsInMailbox( + ref.id, + Math.max(limit, 50), + ); + if (sortAscending) cached.reverse(); + if ( + get().activeAccountId === activeAccountId && + get().currentMailboxId === currentMailboxId && + cached.length > 0 + ) { + set({ emails: cached, totalEmails: cached.length, loading: false, error: null }); + return; + } + } + } catch (cacheErr) { + console.warn('[email-store] refresh cache fallback failed:', cacheErr); + } + } + set({ + loading: false, + error: existing.length > 0 ? null : (err instanceof Error ? err.message : 'Failed to load emails'), + }); + } +} diff --git a/src/stores/search-history-store.ts b/src/stores/search-history-store.ts new file mode 100644 index 00000000..886102f4 --- /dev/null +++ b/src/stores/search-history-store.ts @@ -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()( + 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 }), + }, + ), +); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 645c1c45..9f8cd157 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; import type { Identity } from '../api/types'; import { getIdentities as fetchIdentities } from '../api/identity'; +import type { SortLevel, MessageListOrderScope } from '../lib/message-list-order'; export type ExternalContentPolicy = 'allow' | 'block' | 'ask'; export type ThemeMode = 'light' | 'dark' | 'system'; @@ -179,6 +180,18 @@ interface PersistedSettings { // (the JMAP Email/query sorts by receivedAt). mailSortAscending: 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; attachmentPosition: AttachmentPosition; // Reader body: font for text/plain bodies (#830), gutter around the body, @@ -336,6 +349,11 @@ const DEFAULT_PERSISTED: PersistedSettings = { emailsPerPage: 25, mailSortAscending: false, disableThreading: false, + messageListOrder: [], + messageListOrderScope: 'inbox', + showAvatarsInJunk: false, + showFolderTotalCount: true, + unifiedCrossAccount: false, mailAttachmentAction: 'preview', attachmentPosition: 'beside-sender', plainTextFont: 'sans', @@ -498,6 +516,9 @@ const VALIDATORS: Partial boolea dateFormat: oneOf(['smart', 'relative', 'full']), timeFormat: oneOf(['12h', '24h']), 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']), density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']), attachmentReminderKeywords: stringArray,