From 6ae21d56909cc2394934df05c29228bbf9101531 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:39:45 +0200 Subject: [PATCH] feat: folder actions, tags and unified views in the drawer, folder parent/role editing, tag discovery and account-routed unified actions (#404, #498, #495, #175, #658, #855) --- docs/parity/02-mail-list-folders.md | 42 +- src/api/keyword-discovery.ts | 75 ++ src/api/tag-counts.ts | 59 ++ src/api/unified-inbox.ts | 591 +++++++++++---- src/components/SidebarDrawer.tsx | 786 ++++++++++++++++++-- src/components/settings/FolderSettings.tsx | 258 +++++-- src/components/settings/KeywordSettings.tsx | 102 ++- src/lib/__tests__/keyword-discovery.test.ts | 26 + src/lib/account-utils.ts | 5 +- src/lib/keyword-discovery.ts | 120 +++ src/lib/mailbox-tree.ts | 34 +- src/navigation/types.ts | 9 +- src/screens/UnifiedInboxScreen.tsx | 413 ++++++++-- src/stores/keywords-store.ts | 20 +- 14 files changed, 2197 insertions(+), 343 deletions(-) create mode 100644 src/api/keyword-discovery.ts create mode 100644 src/api/tag-counts.ts create mode 100644 src/lib/__tests__/keyword-discovery.test.ts create mode 100644 src/lib/keyword-discovery.ts diff --git a/docs/parity/02-mail-list-folders.md b/docs/parity/02-mail-list-folders.md index 905211f7..da5e42ca 100644 --- a/docs/parity/02-mail-list-folders.md +++ b/docs/parity/02-mail-list-folders.md @@ -12,17 +12,17 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: `deduplicate()` in `src/lib/mailbox-tree.ts:49-68` uses `lower.includes(rn) || rn.includes(lower)` — "Old Inbox", "2025 Archive", "Sent to Accounting" vanish from the drawer and the move sheet (also from `findTrashMailbox` etc. only indirectly). - Fix hint: replace the substring test with `r.name.trim().toLowerCase() === lower`; keep the nested/parent guards. Add a case to `src/lib/__tests__/mailbox-tree.test.ts`. -- [ ] **Role folders not localized (#404)** — `P3` — `missing` +- [x] **Role folders not localized (#404)** — `P3` — `missing` — fixed in PENDING-D - What WEB does: `localizeMailboxName(role, name, t)` maps inbox/sent/drafts/trash/archive/junk/important/flagged/all to translated labels (`lib/mailbox-label.ts:35`); used by sidebar (`components/layout/sidebar.tsx:520`), move menu, context menus. - What RN does: raw server name everywhere: drawer `src/components/SidebarDrawer.tsx:491`, list header `src/screens/EmailListScreen.tsx:637-641`, `MoveSheet.tsx:120`, `FolderSettings.tsx:26-29` (English-only pill). - Fix hint: add `src/lib/mailbox-label.ts` (port of WEB) using `useLocaleStore().t` keys `sidebar.mailboxes.*` (already present in `locales/*/common.json`), apply in the four places. -- [ ] **Setting to hide the total message count on folders (#498)** — `P3` — `missing` +- [x] **Setting to hide the total message count on folders (#498)** — `P3` — `missing` — fixed in PENDING-D - What WEB does: `showFolderTotalCount` setting (`stores/settings-store.ts`, `components/settings/layout-settings.tsx:236`) hides the `/ total` part in `SidebarRowCounts` (`components/layout/sidebar.tsx:207-209`). - What RN does: `RowCounts` always shows unread/total (`src/components/SidebarDrawer.tsx:68-79`). - Fix hint: add `showFolderTotalCount` to settings-store + LayoutSettings toggle; gate `total` in `RowCounts`. -- [ ] **Create subfolder / choose parent when creating** — `P3` — `partial` +- [x] **Create subfolder / choose parent when creating** — `P3` — `partial` — fixed in PENDING-D - What WEB does: "New subfolder" in the folder context menu and `subfolder_of` in settings (`components/layout/mailbox-context-menu.tsx:187-193`, `components/settings/folder-settings.tsx:408-423`); store `createMailbox(client, name, parentId, accountId)` (`stores/email-store.ts:3753`), incl. creating in a shared account. - What RN does: `FolderSettings.saveDraft` always calls `createMailbox({ name })` (`src/components/settings/FolderSettings.tsx:96`) although `createMailbox` in `src/api/email.ts:156` already accepts `parentId` and `accountIdOverride`. - Fix hint: add a "Parent folder" select (own folders, or the shared account when creating there) to the editor modal and pass `parentId`. @@ -37,7 +37,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: fixed role colours always on, `iconFor` knows only inbox/sent/drafts/trash/junk/archive/star (`src/components/SidebarDrawer.tsx:36-66`); no role assignment. - Fix hint: low priority; at least extend `iconFor` with the #288 roles (important/memos/scheduled/snoozed/shared) and ideally a role picker in FolderSettings using `updateMailbox(id, { role })` (needs `role` added to the `changes` type). -- [ ] **Hide the server "Scheduled" role folder when the virtual Scheduled row is shown (#495)** — `P3` — `bugfix-parity` +- [x] **Hide the server "Scheduled" role folder when the virtual Scheduled row is shown (#495)** — `P3` — `bugfix-parity` — fixed in PENDING-D - What WEB does: filters `role === 'scheduled'` nodes out of the own tree while the virtual row is rendered (`components/layout/sidebar.tsx:986-990`). - What RN does: drawer always renders the virtual "Scheduled" quick row (`src/components/SidebarDrawer.tsx:447-453`) and would also list a Stalwart `role: scheduled` mailbox as a plain folder. - Fix hint: skip nodes with `role === 'scheduled'` in `buildMailboxTree`/drawer when `jmapClient.hasDelayedSend()`. @@ -47,7 +47,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: nothing — the only way is select-all-on-page + delete, page by page. - Fix hint: add `emptyMailbox(mailboxId, accountId?)` to `src/api/email.ts` mirroring the WEB loop (stop when `found.length === 0 || destroyed === 0 || found.length < batch`), a confirm `Alert`, a button in the list header when `currentMailbox.role` is trash/junk, then `refreshEmails()` + `fetchMailboxes()` and `dropFromCache`. -- [ ] **Mark folder / folder tree / all folders as read** — `P2` — `missing` +- [x] **Mark folder / folder tree / all folders as read** — `P2` — `missing` — fixed in PENDING-D - What WEB does: folder context menu → `markMailboxAsRead` (paged `Email/query notKeyword $seen` + `Email/set`, `lib/jmap/client.ts:2327-2364`), "mark folder tree read" and "mark all folders read" with confirm (`components/mail/mail-app.tsx:2571-2645`, `client.markAllAsRead` `:2366`); store zeroes unread counters (`stores/email-store.ts:4003-4043`). - What RN does: only per-message and batch-on-loaded-page mark read (`EmailListScreen.tsx:456-464`). - Fix hint: long-press on a drawer folder row → action sheet (Mark all read / Empty / Rename / Delete / New subfolder); api `markMailboxAsRead(rawId, accountId)` with the paged loop; then `fetchMailboxes()` and, if current, `refreshEmails()`. @@ -62,49 +62,49 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: every push event awaits `fetchMailboxes()` then `refreshEmails()` (`src/stores/email-store.ts:981-1015`); overlapping pushes, the mount effects (`EmailListScreen.tsx:554-567`) and `archiveEmail`'s follow-up `fetchMailboxes` can all run in parallel; a `RateLimitError` (`src/api/jmap-client.ts:436-440`) is only logged. The tree itself is preserved on failure (`email-store.ts:604-612`), so this is perf/noise rather than data loss. - Fix hint: wrap `fetchMailboxes`/`refreshEmails` bodies in a small in-flight map keyed by `activeAccountId` with a single queued re-run, like WEB's `coalesceRefresh`. -- [ ] **Tap the unread count of a folder to open it filtered to unread** — `P3` — `missing` +- [x] **Tap the unread count of a folder to open it filtered to unread** — `P3` — `missing` — fixed in PENDING-D - What WEB does: unread badge is a button that toggles `isUnread: true` on that folder (`components/layout/sidebar.tsx:570`, `components/mail/mail-app.tsx:2533-2567`). - What RN does: counts are static text (`SidebarDrawer.tsx:68-79`). - Fix hint: make the unread count pressable → `selectMailbox(id)` then `setFilters({ isUnread: true })`. -- [ ] **Shared-account header has no folder actions** — `P3` — `missing` +- [x] **Shared-account header has no folder actions** — `P3` — `missing` — fixed in PENDING-D - What WEB does: right-click on a shared account header offers "New folder" in that account (`components/layout/sidebar.tsx:handleSharedAccountContextMenu`, `mailbox-context-menu.tsx:119-147`; changelog 1.9.0 "route shared-folder management to the owner account"). - What RN does: `FolderSettings` manages own folders only (`src/components/settings/FolderSettings.tsx:47-50`); the account node in the drawer only expands/collapses (`SidebarDrawer.tsx:499-503`). - Fix hint: after the subfolder/move work, add an account picker (own + shared accounts the user `mayCreateChild` in) to the folder editor and pass `accountIdOverride`. ### Unified mailbox / cross-account views -- [ ] **Unified scope: account-bounded by default, cross-account opt-in; single-account + group inboxes case** — `P2` — `partial` +- [x] **Unified scope: account-bounded by default, cross-account opt-in; single-account + group inboxes case** — `P2` — `partial` — fixed in PENDING-D - What WEB does: unified views stay inside the active account (own + shared/group folders) unless `unifiedCrossAccount` is on *and* the admin gate allows it (`components/mail/mail-app.tsx:465-500`, `stores/settings-store.ts:405-431`); the section is shown with one account as soon as `includeGroupInUnified && hasGroupInboxes` (`components/layout/sidebar.tsx:905-907`). - What RN does: "All inboxes" is shown only when `accounts.length > 1` (`src/components/SidebarDrawer.tsx:438-446`) and always spans every account (`src/api/unified-inbox.ts:236-257`). A single account with group inboxes gets no unified view even with "Include Group Inboxes" on (`ReadingSettings.tsx:257`). Admin policy gates do not exist in RN (see N/A). - Fix hint: show the row when `accounts.length > 1 || (includeGroupInUnified && mailboxes.some(m => m.isShared))`; add a `unifiedCrossAccount` toggle (default off, like WEB) and pass only the active account id when it is off. Note WEB's default for `includeGroupInUnified` is `true` (`settings-store.ts:639`) while RN defaults to `false` (`src/stores/settings-store.ts:235`). -- [ ] **Unified per-role views (All Sent/Drafts/Junk/Archive/Trash) with live counts** — `P3` — `missing` +- [x] **Unified per-role views (All Sent/Drafts/Junk/Archive/Trash) with live counts** — `P3` — `missing` — fixed in PENDING-D - What WEB does: one row per role present in any account with summed unread/total (`lib/unified-mailbox.ts:295-320`, sidebar rows `components/layout/sidebar.tsx` "unified_*"), counts projected from live mailbox lists. - What RN does: inbox only (`src/api/unified-inbox.ts:149`), no counts on the "All inboxes" row. - Fix hint: after the per-account mailbox fetch, pick `role` instead of hard-coding inbox; sum `unreadEmails` of each account's inbox for the drawer badge. -- [ ] **All mail / Unread / Starred cross views + per-account folder selection** — `P2` — `missing` +- [x] **All mail / Unread / Starred cross views + per-account folder selection** — `P2` — `missing` — fixed in PENDING-D - What WEB does: `buildCrossFilter` (OR of included mailboxes AND `notKeyword $seen` / `hasKeyword $flagged`) fanned out per account (`lib/unified-mailbox.ts:338-492`); default = inbox + custom folders (`CROSS_EXCLUDED_ROLES`), narrowed by `allMailFolderIds[accountId]` chosen in Layout settings (`components/settings/layout-settings.tsx:291-340`); rows show the source folder chip (`components/email/thread-list-item.tsx:385`, `resolveSourceFolderName` `lib/unified-mailbox.ts:63`). - What RN does: none. - Fix hint: reuse `fetchInboxEmailsForJmapAccount` with a filter built like `buildCrossFilter` over `Mailbox/get` results minus excluded roles; add a `sourceFolder` label to `UnifiedEmail` rows; folder picker can come later. -- [ ] **Unified list has no pagination ("load more")** — `P2` — `missing` +- [x] **Unified list has no pagination ("load more")** — `P2` — `missing` — fixed in PENDING-D - What WEB does: `loadMoreUnifiedEmails` / cross-view load-more with `position` per account (`stores/email-store.ts:4079-4107`, `1473-1515`). - What RN does: fixed `perAccountLimit = 25`, no `position`, no `onEndReached` (`src/api/unified-inbox.ts:236-241`, `src/screens/UnifiedInboxScreen.tsx:175-182`). - Fix hint: keep a per-account `position`, add `onEndReached` that re-queries each account at its own offset and merges. -- [ ] **Unified search** — `P3` — `missing` +- [x] **Unified search** — `P3` — `missing` — fixed in PENDING-D - What WEB does: text + advanced filter fan-out in all unified/cross views (`lib/unified-mailbox.ts:177-207`, `455-492`; changelog 1.7.8). - What RN does: no search box on `UnifiedInboxScreen`. - Fix hint: add the same search bar as `EmailListScreen` and AND `{ text: toWildcardQuery(q) }` into each account's query. -- [ ] **No actions on unified rows (swipe, star, read, delete, archive, spam, selection)** — `P2` — `missing` +- [x] **No actions on unified rows (swipe, star, read, delete, archive, spam, selection)** — `P2` — `missing` — fixed in PENDING-D - What WEB does: every list action resolves the email's own client + owner account via `resolveEmailActionContext` (`stores/email-store.ts:581-640`), incl. batch actions grouped by `sourceAccountId` (`:2634-2660`, `:2740-2760`), archive into the owner's archive (`components/mail/mail-app.tsx:2141-2160`); changelog 1.7.5/1.7.7 "route counter/keyword updates to the email's own account in aggregate views". - What RN does: rows are plain `Pressable`s (`src/screens/UnifiedInboxScreen.tsx:95-138`); nothing but open. - Fix hint: wrap rows in `SwipeableRow`; because the JMAP client is single-account, actions on a *different registry account* need the detached `jmapPost` path (`unified-inbox.ts:116-131`) with `Email/set` against `email.jmapAccountId`; for the active account and its group accounts route through `setEmailKeywords(..., email.jmapAccountId)` / `moveEmail(...)` overrides. -- [ ] **Unified list stays stale after acting on a message; just-read mail handling** — `P3` — `partial` +- [x] **Unified list stays stale after acting on a message; just-read mail handling** — `P3` — `partial` — fixed in PENDING-D - What WEB does: aggregate views refresh through the fan-out on push (`refreshCurrentMailbox`, #791) and keep just-read/unstarred rows in the Unread/Starred views until re-opened (`retainedInViewIds`, `stores/email-store.ts:1007-1050`). - What RN does: `UnifiedInboxScreen` loads once per mount/`includeGroup` change (`:57-59`); after opening (mark read) or deleting in the thread screen and going back, the unread dot/row is stale until pull-to-refresh. No push hookup. - Fix hint: `useFocusEffect` → reload, or patch the local list from the thread screen result; when Unread/Starred views are added, keep the WEB retain semantics. @@ -114,7 +114,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: `UnifiedInboxScreen.onOpen` switches account then navigates to `EmailThread` (`src/screens/UnifiedInboxScreen.tsx:61-86`). `EmailThreadScreen` pages over the *active folder's* `emails` (`src/screens/EmailThreadScreen.tsx:131-133`, FlatList `data={emails}` `:538`, `initialScrollIndex={Math.max(0, findIndex)}`). A group-inbox message (or any message not in the first page of the user's own inbox snapshot) is not in `emails`, so index 0 is shown: the pane renders `emails[0]` while the toolbar/`activeEmailId` (and delete/archive/spam) refer to the message that was tapped; with an empty snapshot the body area is blank. Bare-id `findIndex` also collides across accounts (Stalwart reuses id ranges, #847). - Fix hint: when `route.params.emailId` is not found in `emails` (or `jmapAccountId` is set), page over a one-element list `[{ id, threadId }]` instead of `emails`; compare ids together with the owning account. -- [ ] **Unified fetch re-discovers the session and mailboxes on every refresh** — `P3` — `rn-only-bug` +- [x] **Unified fetch re-discovers the session and mailboxes on every refresh** — `P3` — `rn-only-bug` — fixed in PENDING-D - What RN does: `fetchInboxForAccount` does `/.well-known/jmap` + `Mailbox/get` + `Email/query` + `Email/get` per account per load (`src/api/unified-inbox.ts:175-234`); the active account is also fetched detached instead of via the live client/snapshot. - Fix hint: cache `apiUrl`/`primaryJmapId`/inbox id per account (keyed by serverUrl+username) for the session; use the live `jmapClient` + existing inbox snapshot for the active account. @@ -130,7 +130,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: `EmailRow` shows no tags; `tagPill/tagDot/tagText` styles exist but are unused (`src/screens/EmailListScreen.tsx:1481-1495`). Tags are only visible inside the thread screen tag menu. - Fix hint: compute tag ids from `item.keywords` (`$label:*`, `$color:*`), look up `useKeywordsStore().keywords` for label/colour (unknown ids → grey with raw id), render pills on the subject row; add a `tintListRowsByTag` setting if desired. -- [ ] **Tag view (tap a tag → cross-folder `hasKeyword` list, #175) and tag counts in the drawer** — `P2` — `missing` +- [x] **Tag view (tap a tag → cross-folder `hasKeyword` list, #175) and tag counts in the drawer** — `P2` — `missing` — fixed in PENDING-D - What WEB does: "Tags" sidebar section with unread/total per tag (`fetchTagCounts`, `stores/email-store.ts:1192`), visibility rules (`components/layout/sidebar.tsx:1002-1013`), `selectKeyword` → `getEmails(undefined, …, '$label:')` across all folders (`stores/email-store.ts:1343-1420`, changelog 1.4.13 #175), counts kept in step with read/unread (1.7.8). - What RN does: no tag section in `SidebarDrawer`; `EmailFilters` has no keyword field (`src/stores/email-store.ts:140-149`). - Fix hint: add `keyword?: string` to `EmailFilters` and a "Tags" section in the drawer; when set, `buildJmapFilter` should omit `inMailbox` (search across folders) and add `{ hasKeyword: '$label:' }`; counts via one `Email/query` (calculateTotal) per tag, optional. @@ -145,7 +145,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: `togglePin` writes `$important` (`src/stores/email-store.ts:1151-1173`), `isPinned` reads `$important` (`src/screens/EmailListScreen.tsx:47-49`); no icon, no ordering. A pin set in RN is invisible in WEB and vice versa. - Fix hint: switch the keyword to `$pinned`; show a pin icon; optionally sort pinned to the top client-side (server sort needs the polarity probe, see next item). -- [x] **Configurable message-list order presets/custom levels (#718) incl. Stalwart `hasKeyword` polarity probe; native issue #5** — `P2` — `partial` — fixed in PENDING-C +- [x] **Configurable message-list order presets/custom levels (#718) incl. Stalwart `hasKeyword` polarity probe; native issue #5** — `P2` — `partial` — fixed in bea3fc4 - What WEB does: presets (unread/starred/tagged first) or up to 3 levels mapped to the JMAP sort, Inbox-only or all folders (`lib/message-list-order.ts`, `components/settings/message-list-order-settings.tsx:52-62`, `getMessageListOrderFor` `stores/settings-store.ts:1396`); polarity probe per account because Stalwart inverts `isAscending` on keyword comparators (`lib/jmap/client.ts:1376-1431`), `unsupportedSort` fallback (`:1490-1500`); thread grouping mirrors the order (`lib/thread-utils.ts:82`). - What RN does: only `receivedAt` asc/desc via `mailSortAscending` (persisted, header toggle `src/screens/EmailListScreen.tsx:699-711`, store `setSortAscending` `src/stores/email-store.ts:1039-1053`). This satisfies the "per default" part of native issue #5 but nothing else. - Fix hint: port `message-list-order.ts` (pure, no deps) and pass `buildEmailSort(levels, …)` as `sort` to `queryEmails`/`getEmailQueryChanges`; probe polarity once per account like `probeKeywordSortPolarity`; add a settings screen with the presets. Keep `setSortAscending`'s snapshot invalidation for any order change. @@ -219,7 +219,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: `formatListDate` follows the app locale only (`src/lib/date-format.ts:17-31`), "Just now"/"m ago" are English (`:38-41`). - Fix hint: localize the relative strings via `t()`; add a `dateLocale` select if parity is wanted. -- [x] **Show avatars in Junk (off by default, 1.5.1); avatar priority** — `P3` — `partial` — fixed in PENDING-C +- [x] **Show avatars in Junk (off by default, 1.5.1); avatar priority** — `P3` — `partial` — fixed in bea3fc4 - What WEB does: `showAvatarsInJunk` hides favicon/photo images in Junk (`components/email/thread-list-item.tsx:137`); avatar priority contact photo > plugin (Gravatar) > favicon > initials (`components/ui/avatar.tsx:236`). - What RN does: favicons always load, including in Junk (`src/components/SenderAvatar.tsx:37`); no contact-photo lookup. - Fix hint: pass `disableImages={currentMailbox.role === 'junk' && !showAvatarsInJunk}` into `SenderAvatar`; contact photos optional. @@ -263,17 +263,17 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: `{ id, label, color }` only (`src/stores/keywords-store.ts:9-13`); `TagSheet` lists defined tags only (`src/components/TagSheet.tsx:76-95`), so a `$label:work/clients` set by WEB can neither be seen nor removed on RN. Tag definitions are device-local (see native issue #1). - Fix hint: show unknown `$label:*` ids found on the selected emails at the bottom of `TagSheet`; add `visibility` when the drawer tag section lands; nesting/reorder are lower priority. -- [ ] **"Reset defaults" wipes the tag list with no confirmation (removed in WEB 1.8.1)** — `P3` — `bugfix-parity` +- [x] **"Reset defaults" wipes the tag list with no confirmation (removed in WEB 1.8.1)** — `P3` — `bugfix-parity` — fixed in PENDING-D - What WEB does: removed the button ("one stray click wiped a carefully built tag list", changelog 1.8.1 Changes). - What RN does: `KeywordSettings.tsx:94-97` → `resetDefaults()` immediately (`src/stores/keywords-store.ts:85-88`). - Fix hint: remove the button or guard with `Alert.alert` confirm. -- [ ] **Scan for keywords no local tag explains (#658)** — `P3` — `missing` +- [x] **Scan for keywords no local tag explains (#658)** — `P3` — `missing` — fixed in PENDING-D - What WEB does: `discoverKeywords` pages `Email/query`+`Email/get keywords` and proposes label/colour for unknown `$label:`/`$color:` ids (`lib/jmap/client.ts:1616-1676`, `lib/keyword-discovery.ts:140`, `components/settings/keyword-settings.tsx:374-490`). - What RN does: none; after a reinstall all tags must be re-typed with the exact id. - Fix hint: port `discoverKeywords` (cap 5000 like offline sync) + `findUnrecognizedKeywords` (pure) and an "Add" list in `KeywordSettings`. -- [ ] **Keyword palette parity** — `P3` — `partial` +- [x] **Keyword palette parity** — `P3` — `partial` — fixed in PENDING-D - What WEB does: `KEYWORD_PALETTE` keys (`stores/settings-store.ts:226-280`) are the tag ids of the default tags and the colour names used by `suggestKeywordColor`. - What RN does: `colors.tags` keys in `src/theme/tokens.ts`; defaults are `important/work/personal/todo` (`src/stores/keywords-store.ts:15-20`) whereas WEB's `DEFAULT_KEYWORDS` (`settings-store.ts:281`) are colour-named. A message tagged on one client with a default tag shows as unknown on the other. - Fix hint: verify the key sets match; consider aligning the default ids with WEB's. diff --git a/src/api/keyword-discovery.ts b/src/api/keyword-discovery.ts new file mode 100644 index 00000000..d6f9fbb3 --- /dev/null +++ b/src/api/keyword-discovery.ts @@ -0,0 +1,75 @@ +import { jmapClient } from './jmap-client'; + +// Port of the webmail's JMAPClient.discoverKeywords (#658): page through the +// account's messages newest-first (query + back-referenced Email/get of the +// keywords only) and count every keyword that is set. Capped like the +// offline sync so a noisy account can't be enumerated end to end. + +export const DEFAULT_KEYWORD_SCAN_LIMIT = 5000; + +export interface KeywordScanResult { + /** keyword → number of scanned messages carrying it. */ + keywords: Record; + scanned: number; + total: number; + /** False when the cap stopped the scan before the end of the list. */ + complete: boolean; +} + +export async function discoverKeywords(options?: { + limit?: number; + onProgress?: (scanned: number, total: number) => void; + signal?: { aborted: boolean }; + accountId?: string; +}): Promise { + const accountId = options?.accountId ?? jmapClient.accountId; + const cap = Math.max(0, options?.limit ?? DEFAULT_KEYWORD_SCAN_LIMIT); + const pageSize = Math.max(1, Math.min(500, jmapClient.getMaxObjectsInGet())); + const keywords: Record = {}; + let scanned = 0; + let total = 0; + let complete = false; + + while (scanned < cap) { + if (options?.signal?.aborted) break; + const limit = Math.min(pageSize, cap - scanned); + const res = await jmapClient.request([ + ['Email/query', { + accountId, + sort: [{ property: 'receivedAt', isAscending: false }], + limit, + position: scanned, + calculateTotal: scanned === 0, + }, '0'], + ['Email/get', { + accountId, + '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, + properties: ['keywords'], + }, '1'], + ]); + const query = res.methodResponses?.[0]?.[1] ?? {}; + const got = res.methodResponses?.[1]?.[1] ?? {}; + const ids: string[] = (query.ids as string[] | undefined) ?? []; + if (scanned === 0) total = (query.total as number | undefined) ?? 0; + + for (const email of (got.list as Array<{ keywords?: Record }> | undefined) ?? []) { + for (const [keyword, isSet] of Object.entries(email.keywords ?? {})) { + if (isSet) keywords[keyword] = (keywords[keyword] ?? 0) + 1; + } + } + + // Page by what the query returned, not by what the get did: a message + // destroyed between the two lands in `notFound` and would otherwise + // shift every later page by one. + scanned += ids.length; + options?.onProgress?.(scanned, Math.max(total, scanned)); + + // A short page is the end of the list, however much `total` claimed. + if (ids.length === 0 || ids.length < limit) { + complete = true; + break; + } + } + + return { keywords, scanned, total: Math.max(total, scanned), complete }; +} diff --git a/src/api/tag-counts.ts b/src/api/tag-counts.ts new file mode 100644 index 00000000..afb94fdc --- /dev/null +++ b/src/api/tag-counts.ts @@ -0,0 +1,59 @@ +import { jmapClient } from './jmap-client'; +import type { JMAPMethodCall } from './types'; + +export interface TagCount { + /** Tag id (the part after `$label:`). */ + id: string; + total: number; + unread: number; +} + +/** + * Unread/total counts per tag across every folder of one account — two + * `Email/query` calls (`calculateTotal`, `limit: 0`) per tag, batched to the + * server's maxCallsInRequest. Port of the webmail's `fetchTagCounts` + * (stores/email-store.ts). A tag whose queries fail is reported as 0/0 + * rather than sinking the whole batch. + */ +export async function fetchTagCounts(tagIds: string[], accountIdOverride?: string): Promise { + if (tagIds.length === 0) return []; + const accountId = accountIdOverride ?? jmapClient.accountId; + const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest()); + const counts = new Map(tagIds.map((id) => [id, { id, total: 0, unread: 0 }])); + + const calls: Array<{ id: string; kind: 'total' | 'unread'; call: JMAPMethodCall }> = []; + for (const id of tagIds) { + const keyword = `$label:${id}`; + calls.push({ + id, + kind: 'total', + call: ['Email/query', { accountId, filter: { hasKeyword: keyword }, limit: 0, calculateTotal: true }, `t:${id}`], + }); + calls.push({ + id, + kind: 'unread', + call: ['Email/query', { + accountId, + filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }] }, + limit: 0, + calculateTotal: true, + }, `u:${id}`], + }); + } + + for (let i = 0; i < calls.length; i += perRequest) { + const chunk = calls.slice(i, i + perRequest); + const res = await jmapClient.request(chunk.map((c) => c.call)); + for (const [name, body, callId] of res.methodResponses) { + if (name !== 'Email/query' || typeof callId !== 'string') continue; + const [kind, ...rest] = callId.split(':'); + const id = rest.join(':'); + const entry = counts.get(id); + if (!entry) continue; + const total = typeof body.total === 'number' ? body.total : 0; + if (kind === 't') entry.total = total; + else if (kind === 'u') entry.unread = total; + } + } + return tagIds.map((id) => counts.get(id)!); +} diff --git a/src/api/unified-inbox.ts b/src/api/unified-inbox.ts index 1e5619f9..9994d748 100644 --- a/src/api/unified-inbox.ts +++ b/src/api/unified-inbox.ts @@ -1,21 +1,39 @@ import { CAPABILITIES } from './types'; import type { Email, JMAPSession, JMAPMethodCall, Mailbox } from './types'; -import { jmapClient, type StoredCredentials } from './jmap-client'; +import { + jmapClient, rewriteSessionUrl, extractOrigin, parseRetryAfter, REQUEST_TIMEOUT_MS, + type StoredCredentials, +} from './jmap-client'; +import { keywordPointer, mailboxPointer } from './patch-pointer'; import { secureFetch } from '../lib/client-cert'; import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth'; +import { toWildcardQuery } from '../lib/search-utils'; -// Aggregated inbox across every logged-in account. Because the JMAP client is -// a single-account singleton (one live session at a time), we fetch each -// account's inbox "detached" here — reading its stored credentials directly and -// issuing requests against its own API URL — so the active account's live -// session is never disturbed. +// Aggregated views across accounts ("All inboxes", "All Sent", All mail / +// Unread / Starred). Because the JMAP client is a single-account singleton +// (one live session at a time), the *active* registry account goes through +// the live client while every other account is fetched "detached" — reading +// its stored credentials directly and posting to its own API URL — so the +// active session is never disturbed. Session discovery and the per-account +// mailbox lists are cached for the session so a refresh is one Email/query + +// Email/get per account instead of four round-trips. const EMAIL_LIST_PROPERTIES = [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', - 'receivedAt', 'from', 'to', 'subject', 'preview', 'hasAttachment', + 'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment', ]; const TOKEN_REFRESH_LEEWAY_MS = 60_000; +const SESSION_CACHE_TTL_MS = 10 * 60 * 1000; +const MAILBOX_CACHE_TTL_MS = 60 * 1000; + +export type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash'; +export type CrossView = 'all' | 'unread' | 'starred'; + +/** Roles left out of the cross-account All mail / Unread / Starred views. */ +export const CROSS_EXCLUDED_ROLES: ReadonlySet = new Set([ + 'junk', 'spam', 'sent', 'archive', 'trash', 'drafts', +]); export interface UnifiedEmail extends Email { /** Registry account id this message belongs to (not the JMAP account id). */ @@ -23,40 +41,104 @@ export interface UnifiedEmail extends Email { /** * JMAP account id the message lives under. Equals the registry account's * primary JMAP account for own mail, or a group/shared owner account when - * the message came from a group inbox. Carried so opening can target the - * right account via an Email/get override. + * the message came from a group inbox. Carried so opening/acting can target + * the right account via an Email/get or Email/set override. */ jmapAccountId: string; /** True when this message belongs to a group/shared inbox, not the user's own. */ isShared: boolean; /** Human label for the owning account when shared (e.g. "Support"). */ sharedLabel?: string; + /** Name of the folder the message was found in (cross views). */ + sourceFolder?: string; } export interface UnifiedInboxResult { emails: UnifiedEmail[]; /** accountId → error message for accounts that could not be fetched. */ errors: Record; + /** Per-target paging cursors to pass back as `positions` for the next page. */ + positions: Record; + hasMore: boolean; } +export interface UnifiedFetchOptions { + /** Per-role view (default inbox) — ignored when `view` is set. */ + role?: UnifiedRole; + /** Cross-folder view over inbox + custom folders. */ + view?: CrossView; + /** Also scan group/shared accounts reachable through each login. */ + includeGroup?: boolean; + /** Free-text search ANDed into every account's query. */ + query?: string; + /** Cursors from the previous page (`UnifiedInboxResult.positions`). */ + positions?: Record; +} + +// ── Detached transport ────────────────────────────────────────────────── + function authHeaderFor(creds: StoredCredentials): string { if (creds.accessToken) return `Bearer ${creds.accessToken}`; return `Basic ${btoa(`${creds.username}:${creds.password}`)}`; } -function originOf(url: string): string | null { - const m = url.match(/^(https?:\/\/[^/?#]+)/i); - return m ? m[1] : null; +/** Thrown when a detached account's server rate-limited us (429). */ +export class UnifiedRateLimitError extends Error { + retryAfterMs: number; + constructor(retryAfterMs: number) { + super('Rate limited'); + this.name = 'UnifiedRateLimitError'; + this.retryAfterMs = retryAfterMs; + } } -// Same intent as JMAPClient.rewriteSessionUrls: point the advertised apiUrl at -// the origin we actually connected to (servers often self-report unreachable -// container-internal hosts). -function rewriteApiUrl(session: JMAPSession, serverUrl: string): string { - const serverOrigin = originOf(serverUrl); - const apiOrigin = originOf(session.apiUrl); - if (!apiOrigin || !serverOrigin || apiOrigin === serverOrigin) return session.apiUrl; - return serverOrigin + session.apiUrl.slice(apiOrigin.length); +/** + * `fetch` with the same deadline the live client applies + * (`jmapClient.authenticatedFetch`): an AbortController-backed timeout, 401 + * → "Session expired", 429 → rate-limit error carrying Retry-After. + */ +export async function fetchWithDeadline( + url: string, + init: RequestInit, + timeoutMs = REQUEST_TIMEOUT_MS, +): Promise { + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + const timer = setTimeout(() => controller?.abort(), timeoutMs); + try { + const response = await secureFetch(url, { ...init, signal: controller?.signal }); + if (response.status === 401) throw new Error('Session expired'); + if (response.status === 429) { + const retryAfter = response.headers?.get?.('Retry-After') ?? null; + throw new UnifiedRateLimitError(parseRetryAfter(retryAfter)); + } + if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`); + return response; + } catch (err) { + if ((err as Error)?.name === 'AbortError') throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`); + throw err; + } finally { + clearTimeout(timer); + } +} + +type MethodResponses = Array<[string, Record, string]>; + +interface Transport { + post: (methodCalls: JMAPMethodCall[]) => Promise; +} + +async function jmapPost( + apiUrl: string, + authHeader: string, + methodCalls: JMAPMethodCall[], +): Promise { + const response = await fetchWithDeadline(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: authHeader }, + body: JSON.stringify({ using: [CAPABILITIES.CORE, CAPABILITIES.MAIL], methodCalls }), + }); + const body = await response.json(); + return (body.methodResponses ?? []) as MethodResponses; } function resolveJmapAccountId(session: JMAPSession): string | null { @@ -68,6 +150,22 @@ function resolveJmapAccountId(session: JMAPSession): string | null { ); } +// Same rule as JMAPClient.getSharedMailAccounts(): Stalwart doesn't always +// populate `accountCapabilities` on shared accounts, so an account counts as +// mail-capable when it advertises the mail capability OR is non-personal. +function sharedMailAccountsOf(session: JMAPSession, primaryId: string): { id: string; name: string }[] { + const out: { id: string; name: string }[] = []; + for (const [id, info] of Object.entries(session.accounts ?? {})) { + if (id === primaryId) continue; + const advertisesMail = info.accountCapabilities + ? CAPABILITIES.MAIL in info.accountCapabilities + : false; + if (!advertisesMail && info.isPersonal) continue; + out.push({ id, name: info.name || id }); + } + return out; +} + // Refresh an about-to-expire OAuth token and persist it so the next fetch (and // a later account switch) reuse the fresh one. Returns possibly-updated creds. async function ensureFreshCredentials( @@ -113,139 +211,260 @@ async function ensureFreshCredentials( } } -async function jmapPost( - apiUrl: string, - authHeader: string, - methodCalls: JMAPMethodCall[], - using: string[], -): Promise, string]>> { - const response = await secureFetch(apiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: authHeader }, - body: JSON.stringify({ using, methodCalls }), - }); - if (response.status === 401) throw new Error('Session expired'); - if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`); - const body = await response.json(); - return body.methodResponses ?? []; +// ── Per-account session + mailbox cache ──────────────────────────────── + +interface AccountEntry { + accountId: string; + transport: Transport; + primaryJmapId: string; + shared: { id: string; name: string }[]; + /** Registry account served by the live jmapClient right now. */ + live: boolean; + discoveredAt: number; + mailboxes: Map; } -// Fetch the most recent inbox messages for a single JMAP account reachable via -// `apiUrl`/`authHeader`. Returns the bare Email objects; the caller decorates -// them with registry/sharing metadata. -async function fetchInboxEmailsForJmapAccount( - apiUrl: string, - authHeader: string, - jmapAccountId: string, - limit: number, -): Promise { - const mailboxResponses = await jmapPost( - apiUrl, - authHeader, - [['Mailbox/get', { accountId: jmapAccountId, properties: ['id', 'role', 'name'] }, '0']], - [CAPABILITIES.CORE, CAPABILITIES.MAIL], - ); - const mailboxes = (mailboxResponses[0]?.[1]?.list as Mailbox[]) ?? []; - const inbox = mailboxes.find((m) => m.role === 'inbox') ?? mailboxes[0]; - if (!inbox) return []; +const entries = new Map(); - const queryResponses = await jmapPost( - apiUrl, - authHeader, - [['Email/query', { - accountId: jmapAccountId, - filter: { inMailbox: inbox.id }, - sort: [{ property: 'receivedAt', isAscending: false }], - limit, - }, '0']], - [CAPABILITIES.CORE, CAPABILITIES.MAIL], - ); - const ids = (queryResponses[0]?.[1]?.ids as string[]) ?? []; - if (ids.length === 0) return []; - - const getResponses = await jmapPost( - apiUrl, - authHeader, - [['Email/get', { accountId: jmapAccountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0']], - [CAPABILITIES.CORE, CAPABILITIES.MAIL], - ); - return (getResponses[0]?.[1]?.list as Email[]) ?? []; +/** Forget every cached session/mailbox list (logout, credential change, tests). */ +export function resetUnifiedCache(accountId?: string): void { + if (accountId) entries.delete(accountId); + else entries.clear(); } -async function fetchInboxForAccount( - accountId: string, - limit: number, - includeGroup: boolean, -): Promise { +function isLiveAccount(accountId: string): boolean { + if (!jmapClient.isConnected) return false; + const username = jmapClient.username; + const serverUrl = jmapClient.serverUrl; + if (!username || !serverUrl) return false; + // Same rule as generateAccountId(username, serverUrl) in lib/account-utils. + let host = serverUrl; + try { host = new URL(serverUrl).hostname; } catch { host = serverUrl.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); } + return `${username}@${host}` === accountId; +} + +async function entryFor(accountId: string): Promise { + const live = isLiveAccount(accountId); + const cached = entries.get(accountId); + if (cached && cached.live === live && Date.now() - cached.discoveredAt < SESSION_CACHE_TTL_MS) { + return cached; + } + + if (live && jmapClient.currentSession) { + const entry: AccountEntry = { + accountId, + live: true, + transport: { post: async (calls) => (await jmapClient.request(calls)).methodResponses as MethodResponses }, + primaryJmapId: jmapClient.accountId, + shared: jmapClient.getSharedMailAccounts(), + discoveredAt: Date.now(), + mailboxes: cached?.mailboxes ?? new Map(), + }; + entries.set(accountId, entry); + return entry; + } + let creds = await jmapClient.getStoredCredentials(accountId); if (!creds) throw new Error('No stored credentials'); creds = await ensureFreshCredentials(accountId, creds); - const baseUrl = creds.serverUrl.replace(/\/+$/, ''); const authHeader = authHeaderFor(creds); // Session discovery. - const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, { + const sessionRes = await fetchWithDeadline(`${baseUrl}/.well-known/jmap`, { headers: { Authorization: authHeader, Accept: 'application/json' }, }); - if (sessionRes.status === 401) throw new Error('Session expired'); - if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`); const session = (await sessionRes.json()) as JMAPSession; - const apiUrl = rewriteApiUrl(session, baseUrl); + // Point the advertised apiUrl at the origin we actually connected to + // (servers often self-report unreachable container-internal hosts) and + // resolve relative URLs — same as the live client does. + const apiUrl = rewriteSessionUrl(session.apiUrl, extractOrigin(baseUrl)) ?? session.apiUrl; const primaryJmapId = resolveJmapAccountId(session); if (!primaryJmapId) throw new Error('No mail account in session'); - // Always include the user's own primary inbox. When group inboxes are on, - // also scan every other (non-personal / shared) account reachable in the - // session — these are the group/shared mailboxes the user belongs to. - const targets: { jmapId: string; isShared: boolean; label?: string }[] = [ - { jmapId: primaryJmapId, isShared: false }, - ]; - if (includeGroup) { - for (const [id, info] of Object.entries(session.accounts ?? {})) { - if (id === primaryJmapId) continue; - // Only accounts that expose the Mail capability can have an inbox. - const hasMail = - info.accountCapabilities == null || CAPABILITIES.MAIL in (info.accountCapabilities ?? {}); - if (!hasMail) continue; - targets.push({ jmapId: id, isShared: true, label: info.name }); - } - } - - const perTarget = await Promise.all( - targets.map(async (target) => { - try { - const list = await fetchInboxEmailsForJmapAccount(apiUrl, authHeader, target.jmapId, limit); - return list.map((e) => ({ - ...e, - sourceAccountId: accountId, - jmapAccountId: target.jmapId, - isShared: target.isShared, - sharedLabel: target.isShared ? target.label : undefined, - })); - } catch { - // A single inaccessible shared account shouldn't sink the whole - // account's inbox; skip it and keep the others. - return [] as UnifiedEmail[]; - } - }), - ); - return perTarget.flat(); + const entry: AccountEntry = { + accountId, + live: false, + transport: { + post: async (calls) => { + try { + return await jmapPost(apiUrl, authHeader, calls); + } catch (err) { + // A dead session must not be reused for the next refresh. + if (err instanceof Error && err.message === 'Session expired') entries.delete(accountId); + throw err; + } + }, + }, + primaryJmapId, + shared: sharedMailAccountsOf(session, primaryJmapId), + discoveredAt: Date.now(), + mailboxes: cached?.mailboxes ?? new Map(), + }; + entries.set(accountId, entry); + return entry; } +async function mailboxesFor(entry: AccountEntry, jmapAccountId: string, force = false): Promise { + const cached = entry.mailboxes.get(jmapAccountId); + if (cached && !force && Date.now() - cached.at < MAILBOX_CACHE_TTL_MS) return cached.list; + const res = await entry.transport.post([ + ['Mailbox/get', { + accountId: jmapAccountId, + properties: ['id', 'role', 'name', 'parentId', 'unreadEmails', 'totalEmails', 'myRights'], + }, '0'], + ]); + const [name, body] = res[0] ?? []; + if (name !== 'Mailbox/get') throw new Error(body?.description || 'Mailbox/get failed'); + const list = ((body.list as Mailbox[]) ?? []).map((m) => ({ ...m, accountId: jmapAccountId })); + entry.mailboxes.set(jmapAccountId, { list, at: Date.now() }); + return list; +} + +/** Cached mailbox lists per JMAP account of one registry account (for counts). */ +export function cachedUnifiedMailboxes(accountId: string): Array<{ jmapAccountId: string; mailboxes: Mailbox[] }> { + const entry = entries.get(accountId); + if (!entry) return []; + return [...entry.mailboxes.entries()].map(([jmapAccountId, v]) => ({ jmapAccountId, mailboxes: v.list })); +} + +// ── Queries ───────────────────────────────────────────────────────────── + +function findByRole(mailboxes: Mailbox[], role: UnifiedRole): Mailbox | undefined { + if (role === 'junk') return mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'); + return mailboxes.find((m) => m.role === role); +} + +/** Folders of one JMAP account that feed the cross views: inbox + custom folders. */ +export function crossIncludedMailboxes(mailboxes: Mailbox[]): Mailbox[] { + return mailboxes.filter((m) => !CROSS_EXCLUDED_ROLES.has(m.role ?? '')); +} + +function buildFilter( + mailboxes: Mailbox[], + opts: UnifiedFetchOptions, +): Record | null { + const conditions: Record[] = []; + if (opts.view) { + const included = crossIncludedMailboxes(mailboxes); + if (included.length === 0) return null; + conditions.push( + included.length === 1 + ? { inMailbox: included[0].id } + : { operator: 'OR', conditions: included.map((m) => ({ inMailbox: m.id })) }, + ); + if (opts.view === 'unread') conditions.push({ notKeyword: '$seen' }); + if (opts.view === 'starred') conditions.push({ hasKeyword: '$flagged' }); + } else { + const target = findByRole(mailboxes, opts.role ?? 'inbox'); + if (!target) return null; + conditions.push({ inMailbox: target.id }); + } + const q = opts.query?.trim(); + if (q) conditions.push({ text: toWildcardQuery(q) }); + return conditions.length === 1 ? conditions[0] : { operator: 'AND', conditions }; +} + +function targetKey(accountId: string, jmapAccountId: string): string { + return `${accountId}|${jmapAccountId}`; +} + +async function fetchTarget( + entry: AccountEntry, + target: { jmapId: string; isShared: boolean; label?: string }, + opts: UnifiedFetchOptions, + position: number, + limit: number, +): Promise<{ emails: UnifiedEmail[]; hasMore: boolean }> { + const mailboxes = await mailboxesFor(entry, target.jmapId); + const filter = buildFilter(mailboxes, opts); + if (!filter) return { emails: [], hasMore: false }; + + const res = await entry.transport.post([ + ['Email/query', { + accountId: target.jmapId, + filter, + sort: [{ property: 'receivedAt', isAscending: false }], + position, + limit, + calculateTotal: true, + }, '0'], + ['Email/get', { + accountId: target.jmapId, + '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, + properties: EMAIL_LIST_PROPERTIES, + }, '1'], + ]); + const [qName, qBody] = res[0] ?? []; + if (qName !== 'Email/query') throw new Error(qBody?.description || 'Email/query failed'); + const ids = (qBody.ids as string[]) ?? []; + const total = typeof qBody.total === 'number' ? qBody.total : undefined; + const list = ((res[1]?.[1]?.list as Email[]) ?? []); + const byId = new Map(list.map((e) => [e.id, e])); + const nameOf = (e: Email): string | undefined => + mailboxes.find((m) => e.mailboxIds?.[m.id])?.name; + const emails = ids.flatMap((id) => { + const e = byId.get(id); + if (!e) return []; + return [{ + ...e, + sourceAccountId: entry.accountId, + jmapAccountId: target.jmapId, + isShared: target.isShared, + sharedLabel: target.isShared ? target.label : undefined, + sourceFolder: opts.view ? nameOf(e) : undefined, + } as UnifiedEmail]; + }); + const hasMore = total !== undefined ? position + ids.length < total : ids.length === limit; + return { emails, hasMore }; +} + +/** + * One page of the unified view over `accountIds`. Every target (a registry + * account's own mailbox plus, with `includeGroup`, each group/shared account + * reachable through it) is queried at its own `positions` cursor and the + * results are merged newest-first. Per-account failures are collected in + * `errors` while the others still show. + */ export async function fetchUnifiedInbox( accountIds: string[], perAccountLimit = 25, - opts: { includeGroup?: boolean } = {}, + opts: UnifiedFetchOptions = {}, ): Promise { const { includeGroup = false } = opts; const errors: Record = {}; + const positions: Record = { ...(opts.positions ?? {}) }; + let hasMore = false; + const settled = await Promise.all( - accountIds.map(async (id) => { + accountIds.map(async (accountId) => { try { - return await fetchInboxForAccount(id, perAccountLimit, includeGroup); + const entry = await entryFor(accountId); + const targets: { jmapId: string; isShared: boolean; label?: string }[] = [ + { jmapId: entry.primaryJmapId, isShared: false }, + ]; + if (includeGroup) { + for (const acc of entry.shared) targets.push({ jmapId: acc.id, isShared: true, label: acc.name }); + } + const perTarget = await Promise.all(targets.map(async (target) => { + const key = targetKey(accountId, target.jmapId); + const position = positions[key] ?? 0; + try { + const page = await fetchTarget(entry, target, opts, position, perAccountLimit); + positions[key] = position + page.emails.length; + if (page.hasMore) hasMore = true; + return page.emails; + } catch (err) { + // A single inaccessible shared account shouldn't sink the whole + // account's view; skip it and keep the others. Own-mail failures + // are the account's failure. + if (!target.isShared) throw err; + return [] as UnifiedEmail[]; + } + })); + return perTarget.flat(); } catch (err) { - errors[id] = err instanceof Error ? err.message : 'Failed to load'; + errors[accountId] = err instanceof Error ? err.message : 'Failed to load'; return [] as UnifiedEmail[]; } }), @@ -253,5 +472,127 @@ export async function fetchUnifiedInbox( const emails = settled .flat() .sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()); - return { emails, errors }; + return { emails, errors, positions, hasMore }; +} + +// ── Actions routed to the owning account ──────────────────────────────── + +async function emailSet( + accountId: string, + jmapAccountId: string, + update: Record>, + destroy?: string[], +): Promise { + const entry = await entryFor(accountId); + const args: Record = { accountId: jmapAccountId }; + if (Object.keys(update).length > 0) args.update = update; + if (destroy && destroy.length > 0) args.destroy = destroy; + const res = await entry.transport.post([['Email/set', args, '0']]); + const [name, body] = res[0] ?? []; + if (name !== 'Email/set') throw new Error(body?.description || 'Email/set failed'); + const failures = { ...(body.notUpdated ?? {}), ...(body.notDestroyed ?? {}) } as Record; + const first = Object.entries(failures)[0]; + if (first) throw new Error(`${first[1]?.type ?? 'error'}${first[1]?.description ? `: ${first[1].description}` : ''}`); +} + +function groupByOwner(emails: UnifiedEmail[]): Map { + const groups = new Map(); + for (const e of emails) { + const key = targetKey(e.sourceAccountId, e.jmapAccountId); + const g = groups.get(key); + if (g) g.emails.push(e); + else groups.set(key, { accountId: e.sourceAccountId, jmapAccountId: e.jmapAccountId, emails: [e] }); + } + return groups; +} + +/** Patch keywords (`{ $seen: true, $flagged: null }`) on messages of any account. */ +export async function patchUnifiedKeywords( + emails: UnifiedEmail[], + patch: Record, +): Promise { + const pointer: Record = {}; + for (const [k, v] of Object.entries(patch)) pointer[keywordPointer(k)] = v === false ? null : v; + for (const g of groupByOwner(emails).values()) { + const update: Record> = {}; + for (const e of g.emails) update[e.id] = { ...pointer }; + await emailSet(g.accountId, g.jmapAccountId, update); + } +} + +export type UnifiedMoveTarget = 'archive' | 'trash' | 'junk' | 'inbox'; + +/** + * Move messages into the role folder of their *own* account (archive into + * the owner's Archive, …). Spam/not-spam also flip `$junk`/`$notjunk` (#850). + * Throws when an owning account lacks the target folder. + */ +export async function moveUnifiedEmails( + emails: UnifiedEmail[], + target: UnifiedMoveTarget, + opts: { markRead?: boolean } = {}, +): Promise { + for (const g of groupByOwner(emails).values()) { + const entry = await entryFor(g.accountId); + const mailboxes = await mailboxesFor(entry, g.jmapAccountId); + const dest = findByRole(mailboxes, target); + if (!dest) throw new Error(`No ${target} folder in this account`); + const update: Record> = {}; + for (const e of g.emails) { + const patch: Record = {}; + for (const id of Object.keys(e.mailboxIds ?? {})) { + if (id !== dest.id) patch[mailboxPointer(id)] = null; + } + patch[mailboxPointer(dest.id)] = true; + if (target === 'junk') { + patch[keywordPointer('$junk')] = true; + patch[keywordPointer('$notjunk')] = null; + } else if (target === 'inbox') { + patch[keywordPointer('$junk')] = null; + patch[keywordPointer('$notjunk')] = true; + } + if (opts.markRead) patch[keywordPointer('$seen')] = true; + update[e.id] = patch; + } + await emailSet(g.accountId, g.jmapAccountId, update); + } +} + +/** + * Delete like the folder list does: move to the owner's Trash, or destroy + * outright when the message already sits in Trash or `permanent` is set. + */ +export async function deleteUnifiedEmails( + emails: UnifiedEmail[], + opts: { permanent?: boolean; markRead?: boolean } = {}, +): Promise<{ destroyed: number; trashed: number }> { + let destroyed = 0; + let trashed = 0; + for (const g of groupByOwner(emails).values()) { + const entry = await entryFor(g.accountId); + const mailboxes = await mailboxesFor(entry, g.jmapAccountId); + const trash = findByRole(mailboxes, 'trash'); + const toDestroy = g.emails.filter((e) => opts.permanent || !trash || !!(trash && e.mailboxIds?.[trash.id])); + const toTrash = g.emails.filter((e) => !toDestroy.includes(e)); + if (toDestroy.length > 0 && !trash && !opts.permanent) { + throw new Error('No Trash folder in this account'); + } + if (toDestroy.length > 0) { + await emailSet(g.accountId, g.jmapAccountId, {}, toDestroy.map((e) => e.id)); + destroyed += toDestroy.length; + } + if (toTrash.length > 0 && trash) { + await moveUnifiedEmails(toTrash, 'trash', { markRead: opts.markRead }); + trashed += toTrash.length; + } + } + return { destroyed, trashed }; +} + +/** Whether a message sits in its account's Trash (drives the permanent-delete confirm). */ +export function isInUnifiedTrash(email: UnifiedEmail): boolean { + const entry = entries.get(email.sourceAccountId); + const list = entry?.mailboxes.get(email.jmapAccountId)?.list ?? []; + const trash = findByRole(list, 'trash'); + return !!trash && !!email.mailboxIds?.[trash.id]; } diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 46f27296..446a464b 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -1,28 +1,41 @@ import React from 'react'; import { - View, Text, StyleSheet, Pressable, ScrollView, Modal, - Animated, Dimensions, Easing, + View, Text, StyleSheet, Pressable, ScrollView, Modal, TextInput, Alert, + Animated, Dimensions, Easing, ActivityIndicator, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star, Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus, - Clock, Layers, Users, + Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag, + CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, + type LucideIcon, } from 'lucide-react-native'; -import type { LucideIcon } from 'lucide-react-native'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useAnimDuration } from '../theme/dynamic'; import { useEmailStore } from '../stores/email-store'; import { useAuthStore } from '../stores/auth-store'; import { useAccountStore } from '../stores/account-store'; +import { useSettingsStore } from '../stores/settings-store'; +import { useKeywordsStore, keywordToken } from '../stores/keywords-store'; +import { useLocaleStore } from '../stores/locale-store'; import { MAX_ACCOUNTS } from '../lib/account-utils'; -import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree'; +import { + buildMailboxTree, flattenVisible, mailboxSubtreeIds, ownMailboxes, type MailboxNode, +} from '../lib/mailbox-tree'; +import { localizeMailboxName } from '../lib/mailbox-label'; import { generateAvatarColor, getAccountInitials } from '../lib/avatar-utils'; +import { jmapClient } from '../api/jmap-client'; +import { + markMailboxAsRead, emptyMailbox, createMailbox, updateMailbox, deleteMailbox, +} from '../api/email'; +import { fetchTagCounts, type TagCount } from '../api/tag-counts'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/types'; +import type { Mailbox } from '../api/types'; const CHEVRON_SLOT = 20; const INDENT_STEP = 12; @@ -31,21 +44,48 @@ const ROW_PX_BASE = 8; const STORAGE_KEYS = { expanded: 'sidebar:expandedMailboxes', foldersExpanded: 'sidebar:foldersExpanded', + tagsExpanded: 'sidebar:tagsExpanded', + unifiedExpanded: 'sidebar:unifiedExpanded', }; +type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash'; +const UNIFIED_ROLES: UnifiedRole[] = ['sent', 'drafts', 'archive', 'junk', 'trash']; +// Role folders that are kept out of the own tree while a virtual row stands +// in for them (#495). +const HIDDEN_WITH_VIRTUAL_ROW = new Set(['scheduled']); + +// Distinct icons for the special-use roles (#288), then a name-based guess +// for servers that don't set roles, then a plain folder. function iconFor( role: string | null | undefined, name: string | undefined, hasChildren: boolean, isExpanded: boolean, ): LucideIcon { + switch (role) { + case 'inbox': return Inbox; + case 'sent': return Send; + case 'drafts': return FileIcon; + case 'trash': return Trash2; + case 'junk': + case 'spam': return Ban; + case 'archive': return Archive; + case 'important': return Flag; + case 'flagged': return Star; + case 'all': return Mails; + case 'memos': return StickyNote; + case 'scheduled': return Clock; + case 'snoozed': return AlarmClock; + case 'shared': return Users; + default: break; + } const lower = (name ?? '').toLowerCase(); - if (role === 'inbox' || lower.includes('inbox')) return Inbox; - if (role === 'sent' || lower.includes('sent')) return Send; - if (role === 'drafts' || lower.includes('draft')) return FileIcon; - if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2; - if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban; - if (role === 'archive' || lower.includes('archive')) return Archive; + if (lower.includes('inbox')) return Inbox; + if (lower.includes('sent')) return Send; + if (lower.includes('draft')) return FileIcon; + if (lower.includes('trash') || lower.includes('deleted')) return Trash2; + if (lower.includes('junk') || lower.includes('spam')) return Ban; + if (lower.includes('archive')) return Archive; if (lower.includes('star') || lower.includes('flag')) return Star; if (hasChildren) return isExpanded ? FolderOpen : Folder; return Folder; @@ -56,7 +96,13 @@ const ROLE_COLOR_FIXED: Record = { sent: '#4ade80', drafts: '#a78bfa', junk: '#f87171', + spam: '#f87171', archive: '#fbbf24', + important: '#f97316', + flagged: '#f59e0b', + scheduled: '#38bdf8', + snoozed: '#c084fc', + memos: '#fbbf24', }; function iconColor(c: ThemePalette, role: string | null | undefined, isSelected: boolean): string { @@ -65,15 +111,32 @@ function iconColor(c: ThemePalette, role: string | null | undefined, isSelected: return isSelected ? c.text : c.textSecondary; } -function RowCounts({ unread, total }: { unread: number; total: number }) { +function RowCounts({ unread, total, showTotal, onPressUnread }: { + unread: number; + total: number; + showTotal: boolean; + /** Tap the unread badge to open the folder filtered to unread. */ + onPressUnread?: () => void; +}) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - if (unread === 0 && total === 0) return null; + const t = useLocaleStore((s) => s.t); + if (unread === 0 && (total === 0 || !showTotal)) return null; return ( - {unread > 0 && {unread}} - {unread > 0 && total > 0 && /} - {total > 0 && {total}} + {unread > 0 && ( + + {unread} + + )} + {showTotal && unread > 0 && total > 0 && /} + {showTotal && total > 0 && {total}} ); } @@ -87,15 +150,18 @@ interface SidebarRowProps { isAccountHeader?: boolean; unread: number; total: number; + showTotal: boolean; hasChildren: boolean; isExpanded: boolean; onPress: () => void; + onLongPress?: () => void; onToggleExpand: () => void; + onPressUnread?: () => void; } function SidebarRow({ - icon, label, depth, isSelected, isAccountHeader, unread, total, - hasChildren, isExpanded, onPress, onToggleExpand, + icon, label, depth, isSelected, isAccountHeader, unread, total, showTotal, + hasChildren, isExpanded, onPress, onLongPress, onToggleExpand, onPressUnread, }: SidebarRowProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -103,6 +169,8 @@ function SidebarRow({ return ( [ styles.row, isSelected && styles.rowSelected, @@ -137,11 +205,94 @@ function SidebarRow({ > {label} - + ); } +// A long-press action sheet for a folder (the webmail's folder context menu). +interface SheetAction { + key: string; + label: string; + icon: LucideIcon; + destructive?: boolean; + onPress: () => void; +} + +function ActionSheet({ title, actions, onClose }: { title: string; actions: SheetAction[]; onClose: () => void }) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + return ( + + + + {title} + {actions.map((a) => { + const Icon = a.icon; + return ( + { onClose(); a.onPress(); }} + style={({ pressed }) => [styles.sheetRow, pressed && styles.rowPressed]} + > + + {a.label} + + ); + })} + + + ); +} + +// Cross-platform text prompt (Alert.prompt is iOS-only). +function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }: { + title: string; + message?: string; + initial?: string; + confirmLabel: string; + onSubmit: (value: string) => void; + onClose: () => void; +}) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const [value, setValue] = React.useState(initial ?? ''); + const submit = () => { + const name = value.trim(); + if (!name) return; + onClose(); + onSubmit(name); + }; + return ( + + + + {title} + {message ? {message} : null} + + + + {t('common.cancel', 'Cancel')} + + + {confirmLabel} + + + + + ); +} + interface SidebarDrawerProps { visible: boolean; onClose: () => void; @@ -150,9 +301,16 @@ interface SidebarDrawerProps { export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const mailboxes = useEmailStore((s) => s.mailboxes); + const accountSnapshots = useEmailStore((s) => s.accountSnapshots); const currentMailboxId = useEmailStore((s) => s.currentMailboxId); + const filters = useEmailStore((s) => s.filters); const selectMailbox = useEmailStore((s) => s.selectMailbox); + const setFilters = useEmailStore((s) => s.setFilters); + const clearSearchAndFilters = useEmailStore((s) => s.clearSearchAndFilters); + const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes); + const refreshEmails = useEmailStore((s) => s.refreshEmails); const username = useAuthStore((s) => s.username); const serverUrl = useAuthStore((s) => s.serverUrl); const logout = useAuthStore((s) => s.logout); @@ -161,18 +319,36 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) const activeAccountId = useAuthStore((s) => s.activeAccountId); const accounts = useAccountStore((s) => s.accounts); const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount); + const showFolderTotalCount = useSettingsStore((s) => s.showFolderTotalCount); + const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified); + const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount); + const keywordDefs = useKeywordsStore((s) => s.keywords); + const keywordsHydrated = useKeywordsStore((s) => s.hydrated); + const hydrateKeywords = useKeywordsStore((s) => s.hydrate); const navigation = useNavigation>(); const [foldersExpanded, setFoldersExpanded] = React.useState(true); + const [tagsExpanded, setTagsExpanded] = React.useState(true); + const [unifiedExpanded, setUnifiedExpanded] = React.useState(false); const [expandedFolders, setExpandedFolders] = React.useState>(() => new Set()); const [accountMenuOpen, setAccountMenuOpen] = React.useState(false); + const [sheet, setSheet] = React.useState<{ title: string; actions: SheetAction[] } | null>(null); + const [prompt, setPrompt] = React.useState<{ + title: string; message?: string; initial?: string; confirmLabel: string; onSubmit: (v: string) => void; + } | null>(null); + const [busy, setBusy] = React.useState(false); + const [tagCounts, setTagCounts] = React.useState>(new Map()); + + React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]); React.useEffect(() => { void (async () => { try { - const [rawExp, rawFld] = await Promise.all([ + const [rawExp, rawFld, rawTags, rawUnified] = await Promise.all([ AsyncStorage.getItem(STORAGE_KEYS.expanded), AsyncStorage.getItem(STORAGE_KEYS.foldersExpanded), + AsyncStorage.getItem(STORAGE_KEYS.tagsExpanded), + AsyncStorage.getItem(STORAGE_KEYS.unifiedExpanded), ]); if (rawExp) { try { @@ -191,11 +367,20 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) setExpandedFolders(new Set(collect(tree))); } if (rawFld != null) setFoldersExpanded(rawFld === 'true'); + if (rawTags != null) setTagsExpanded(rawTags === 'true'); + if (rawUnified != null) setUnifiedExpanded(rawUnified === 'true'); } catch { /* ignore */ } })(); }, [mailboxes]); - const tree = React.useMemo(() => buildMailboxTree(mailboxes), [mailboxes]); + // The server's Scheduled folder is hidden while the virtual row stands in + // for it (#495) — only when the server can actually schedule sends. + const hideOwnRoles = React.useMemo( + () => (jmapClient.hasDelayedSend() ? HIDDEN_WITH_VIRTUAL_ROW : undefined), + // eslint-disable-next-line react-hooks/exhaustive-deps + [mailboxes], + ); + const tree = React.useMemo(() => buildMailboxTree(mailboxes, { hideOwnRoles }), [mailboxes, hideOwnRoles]); const visibleNodes = React.useMemo( () => flattenVisible(tree, expandedFolders), [tree, expandedFolders], @@ -216,18 +401,276 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) }); }, [persistExpanded]); - const toggleFoldersSection = React.useCallback(() => { - setFoldersExpanded((prev) => { + const toggleSection = (key: keyof typeof STORAGE_KEYS, setter: React.Dispatch>) => { + setter((prev) => { const next = !prev; - void AsyncStorage.setItem(STORAGE_KEYS.foldersExpanded, String(next)).catch(() => {}); + void AsyncStorage.setItem(STORAGE_KEYS[key], String(next)).catch(() => {}); return next; }); - }, []); + }; const handleSelect = React.useCallback((id: string) => { + // A tag view was open: leave it so the folder shows its own mail. + if (filters.keyword) clearSearchAndFilters(); void selectMailbox(id); onClose(); - }, [selectMailbox, onClose]); + }, [selectMailbox, onClose, filters.keyword, clearSearchAndFilters]); + + // Tap the unread count → the folder filtered to unread (webmail sidebar). + const handleSelectUnread = React.useCallback((id: string) => { + void selectMailbox(id).then(() => setFilters({ isUnread: true })); + onClose(); + }, [selectMailbox, setFilters, onClose]); + + // Tag view (#175): every folder, messages carrying the tag. + const selectTag = React.useCallback((id: string) => { + setFilters({ keyword: keywordToken(id) }); + onClose(); + }, [setFilters, onClose]); + + // Tag counts: one query pair per tag, refreshed each time the drawer opens + // with the section expanded. + React.useEffect(() => { + if (!visible || !tagsExpanded || keywordDefs.length === 0 || !jmapClient.isConnected) return; + let cancelled = false; + fetchTagCounts(keywordDefs.map((k) => k.id)) + .then((counts) => { + if (cancelled) return; + setTagCounts(new Map(counts.map((tc) => [tc.id, tc]))); + }) + .catch(() => { /* counts are decoration */ }); + return () => { cancelled = true; }; + }, [visible, tagsExpanded, keywordDefs]); + + // ── Folder actions (long-press) ─────────────────────────────────────── + const refFor = (mb: Mailbox) => ({ + id: mb.originalId ?? mb.id, + accountId: mb.isShared ? mb.accountId : undefined, + }); + const runFolderAction = async (label: string, work: () => Promise, affectsCurrent: boolean) => { + setBusy(true); + try { + await work(); + await fetchMailboxes(); + if (affectsCurrent) await refreshEmails(); + } catch (err) { + Alert.alert(label, err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + const markRead = (ids: string[]) => { + const targets = ids + .map((id) => mailboxes.find((m) => m.id === id)) + .filter((m): m is Mailbox => !!m && m.unreadEmails > 0); + const affectsCurrent = !!currentMailboxId && ids.includes(currentMailboxId); + void runFolderAction( + t('mailbox_context_menu.toast_error_mark_read', 'Failed to mark as read'), + async () => { + for (const mb of targets) { + const ref = refFor(mb); + await markMailboxAsRead(ref.id, ref.accountId); + } + }, + affectsCurrent, + ); + }; + const openFolderSheet = (node: MailboxNode) => { + const mb = mailboxes.find((m) => m.id === node.id); + if (!mb) return; + const name = localizeMailboxName(mb.role, mb.name, t); + const ref = refFor(mb); + const subtree = mailboxSubtreeIds(mailboxes, mb.id); + const actions: SheetAction[] = [ + { key: 'read', label: t('mailbox_context_menu.mark_folder_read', 'Mark folder as read'), icon: CheckCheck, onPress: () => markRead([mb.id]) }, + ]; + if (subtree.length > 1) { + actions.push({ key: 'tree', label: t('mailbox_context_menu.mark_folder_tree_read', 'Mark folder & subfolders as read'), icon: CheckCheck, onPress: () => markRead(subtree) }); + } + if (!mb.isShared) { + actions.push({ + key: 'all', + label: t('mailbox_context_menu.mark_all_folders_read', 'Mark all folders as read'), + icon: CheckCheck, + onPress: () => Alert.alert( + t('mailbox_context_menu.mark_all_confirm_title', 'Mark all folders as read'), + t('mailbox_context_menu.mark_all_confirm_message', 'Mark every unread message in your personal account as read?'), + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { text: t('mailbox_context_menu.mark_all_folders_read', 'Mark all folders as read'), onPress: () => markRead(ownMailboxes(mailboxes).map((m) => m.id)) }, + ], + ), + }); + } + if (mb.role === 'trash' || mb.role === 'junk' || mb.role === 'spam') { + actions.push({ + key: 'empty', + label: t('mailbox_context_menu.empty_folder', 'Empty folder'), + icon: Eraser, + destructive: true, + onPress: () => Alert.alert( + t('email_list.empty_folder.confirm_title', 'Empty folder'), + t('email_list.empty_folder.confirm_message', 'All emails in this folder will be permanently deleted. This action cannot be undone.'), + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { + text: t('email_list.empty_folder.confirm_button', 'Empty folder'), + style: 'destructive', + onPress: () => void runFolderAction( + t('mailbox_context_menu.toast_error_empty', 'Failed to empty folder'), + () => emptyMailbox(ref.id, ref.accountId), + currentMailboxId === mb.id, + ), + }, + ], + ), + }); + } + if (mb.myRights?.mayCreateChild !== false) { + actions.push({ + key: 'sub', + label: t('mailbox_context_menu.new_subfolder', 'New subfolder...'), + icon: FolderPlus, + onPress: () => setPrompt({ + title: t('mailbox_context_menu.new_subfolder', 'New subfolder...'), + message: t('mailbox_context_menu.prompt_new_subfolder', 'Enter a name for the new subfolder.'), + confirmLabel: t('mailbox_context_menu.create', 'Create'), + onSubmit: (value) => void runFolderAction( + t('mailbox_context_menu.toast_error_create', 'Failed to create folder'), + async () => { + await createMailbox({ name: value, parentId: ref.id }, ref.accountId); + setExpandedFolders((prev) => { + const next = new Set(prev); next.add(mb.id); void persistExpanded(next); return next; + }); + }, + false, + ), + }), + }); + } + if (!mb.role && mb.myRights?.mayRename !== false) { + actions.push({ + key: 'rename', + label: t('mailbox_context_menu.rename', 'Rename...'), + icon: Pencil, + onPress: () => setPrompt({ + title: t('mailbox_context_menu.rename', 'Rename...'), + message: t('mailbox_context_menu.prompt_rename', 'Enter a new name for this folder.'), + initial: mb.name, + confirmLabel: t('mailbox_context_menu.rename_confirm', 'Rename'), + onSubmit: (value) => void runFolderAction( + t('mailbox_context_menu.toast_error_rename', 'Failed to rename folder'), + () => updateMailbox(ref.id, { name: value }, ref.accountId), + false, + ), + }), + }); + } + if (!mb.role && mb.myRights?.mayDelete !== false) { + actions.push({ + key: 'delete', + label: t('mailbox_context_menu.delete_folder', 'Delete folder'), + icon: Trash2, + destructive: true, + onPress: () => Alert.alert( + t('mailbox_context_menu.delete_confirm_title', 'Delete folder'), + t('mailbox_context_menu.delete_confirm_message', `Permanently delete the folder "${mb.name}"? This action cannot be undone.`, { name: mb.name }), + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { + text: t('common.delete', 'Delete'), + style: 'destructive', + onPress: () => void runFolderAction( + t('mailbox_context_menu.toast_error_delete', 'Failed to delete folder'), + async () => { + await deleteMailbox(ref.id, ref.accountId, { onDestroyRemoveEmails: mb.totalEmails > 0 }); + if (currentMailboxId === mb.id) { + const inbox = ownMailboxes(mailboxes).find((m) => m.role === 'inbox'); + if (inbox) void selectMailbox(inbox.id); + } + }, + false, + ), + }, + ], + ), + }); + } + setSheet({ title: name, actions }); + }; + const openSharedAccountSheet = (node: MailboxNode) => { + const accountId = node.accountId; + if (!accountId) return; + const canCreate = node.children.some((n) => n.myRights?.mayCreateChild !== false); + const actions: SheetAction[] = [ + { + key: 'read', + label: t('mailbox_context_menu.mark_folder_tree_read', 'Mark folder & subfolders as read'), + icon: CheckCheck, + onPress: () => markRead(mailboxes.filter((m) => m.isShared && m.accountId === accountId).map((m) => m.id)), + }, + ]; + if (canCreate) { + actions.push({ + key: 'new', + label: t('mailbox_context_menu.new_folder', 'New folder...'), + icon: FolderPlus, + onPress: () => setPrompt({ + title: t('mailbox_context_menu.new_folder', 'New folder...'), + message: t('mailbox_context_menu.prompt_new_folder', 'Enter a name for the new folder.'), + confirmLabel: t('mailbox_context_menu.create', 'Create'), + onSubmit: (value) => void runFolderAction( + t('mailbox_context_menu.toast_error_create', 'Failed to create folder'), + () => createMailbox({ name: value }, accountId), + false, + ), + }), + }); + } + setSheet({ title: node.name, actions }); + }; + + // ── Unified rows ────────────────────────────────────────────────────── + // Shown with one account as soon as it has group inboxes to merge, and + // always with several accounts. Counts are projected from the mailbox + // lists we hold: the active account's (own + shared) plus, cross-account, + // the tucked-away snapshots of the other accounts. + const hasSharedInbox = mailboxes.some((m) => m.isShared && m.role === 'inbox'); + const showUnified = accounts.length > 1 || (includeGroupInUnified && hasSharedInbox); + const unifiedCounts = React.useMemo(() => { + const pools: Mailbox[][] = [mailboxes]; + if (unifiedCrossAccount) { + for (const [id, snap] of Object.entries(accountSnapshots)) { + if (id !== activeAccountId) pools.push(snap.mailboxes); + } + } + const roleCount = (role: string): { unread: number; total: number; present: boolean } => { + let unread = 0; let total = 0; let present = false; + for (const pool of pools) { + for (const m of pool) { + if (m.isShared && !includeGroupInUnified) continue; + if (m.role === role || (role === 'junk' && m.role === 'spam')) { + present = true; unread += m.unreadEmails ?? 0; total += m.totalEmails ?? 0; + } + } + } + return { unread, total, present }; + }; + let crossUnread = 0; + for (const pool of pools) { + for (const m of pool) { + if (m.isShared && !includeGroupInUnified) continue; + if (!['junk', 'spam', 'sent', 'archive', 'trash', 'drafts'].includes(m.role ?? '')) crossUnread += m.unreadEmails ?? 0; + } + } + return { + inbox: roleCount('inbox'), + roles: UNIFIED_ROLES.map((role) => ({ role, ...roleCount(role) })).filter((r) => r.present), + crossUnread, + }; + }, [mailboxes, accountSnapshots, activeAccountId, unifiedCrossAccount, includeGroupInUnified]); + + const unifiedIcon = (role: UnifiedRole): LucideIcon => iconFor(role, undefined, false, false); const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; @@ -267,6 +710,24 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) return hostname ? `${username}@${hostname}` : username; }, [username, hostname]); + const confirmRemoveAccount = (acc: { id: string; email: string; username: string }) => { + const label = acc.email || acc.username; + Alert.alert( + t('sidebar.remove_account', 'Remove account'), + t('sidebar.remove_account_confirm', `Remove ${label} from this device? You can add it back later.`, { account: label }), + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { + text: t('sidebar.remove_account', 'Remove account'), + style: 'destructive', + onPress: () => { void useAuthStore.getState().removeAccount(acc.id); }, + }, + ], + ); + }; + + const tagViewActive = !!filters.keyword; + return ( {initials} - {username || 'Account'} + {username || t('settings.account.title', 'Account')} {hostname ? ( {hostname} ) : null} - + {busy ? ( + + ) : ( + + )} @@ -320,12 +785,15 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) return ( { + if (isActive) return; setAccountMenuOpen(false); onClose(); void switchAccount(acc.id); }} + onLongPress={isActive ? undefined : () => confirmRemoveAccount(acc)} + delayLongPress={350} + accessibilityHint={isActive ? undefined : t('sidebar.remove_account_hint', 'Long-press to remove this account')} style={({ pressed }) => [ styles.accountMenuRow, isActive && styles.accountMenuRowActive, @@ -355,13 +823,33 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) {accEmail} - - {accHost} + {acc.hasError ? ( + <> + + + {acc.errorMessage || t('sidebar.account_error', 'Needs attention')} + + + ) : ( + <> + {/* The connection dot only means something for the + account the live session serves. */} + {isActive && ( + + )} + {accHost} + + )} + {!isActive && ( + confirmRemoveAccount(acc)} hitSlop={8} style={styles.accountMenuRemove}> + + + )} ); })} @@ -382,7 +870,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) }} > - Add account + {t('sidebar.add_account', 'Add account')} )} @@ -400,7 +888,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) onPress={() => setDefaultAccount(active.id)} > - Set as default + {t('sidebar.set_as_default', 'Set as default')} ); })()} @@ -413,7 +901,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) > - Sign out of {accountEmailFull || 'account'} + {t('sidebar.sign_out_of', `Sign out of ${accountEmailFull || 'account'}`, { account: accountEmailFull || 'account' })} {accounts.length > 1 && ( @@ -426,7 +914,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) > - Sign out all + {t('sidebar.sign_out_all', 'Sign out of all accounts')} )} @@ -435,13 +923,19 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) {/* Quick views */} - {accounts.length > 1 && ( + {showUnified && ( [styles.quickRow, pressed && styles.rowPressed]} - onPress={() => { onClose(); navigation.navigate('UnifiedInbox'); }} + onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: 'inbox' }); }} > - All inboxes + + {unifiedCrossAccount && accounts.length > 1 + ? t('sidebar.all_accounts', 'All accounts') + : t('sidebar.unified_inbox', 'All inboxes')} + + + )} { onClose(); navigation.navigate('Scheduled'); }} > - Scheduled + {t('sidebar.scheduled', 'Scheduled')} + {/* Unified per-role and cross views */} + {showUnified && ( + <> + toggleSection('unifiedExpanded', setUnifiedExpanded)}> + {unifiedExpanded ? ( + + ) : ( + + )} + {t('sidebar.unified_mailbox', 'Unified mailbox')} + + {unifiedExpanded && ( + <> + {([ + { view: 'unread' as const, Icon: MailOpen, label: t('sidebar.unified_all_unread', 'All unread'), unread: unifiedCounts.crossUnread }, + { view: 'starred' as const, Icon: Star, label: t('sidebar.unified_all_starred', 'All starred'), unread: 0 }, + { view: 'all' as const, Icon: Mails, label: t('sidebar.unified_all_mail', 'All mail'), unread: unifiedCounts.crossUnread }, + ]).map((row) => ( + } + label={row.label} + depth={0} + isSelected={false} + unread={row.unread} + total={0} + showTotal={false} + hasChildren={false} + isExpanded={false} + onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { view: row.view }); }} + onToggleExpand={() => {}} + /> + ))} + {unifiedCounts.roles.map((r) => { + const Icon = unifiedIcon(r.role); + return ( + } + label={t(`sidebar.unified_${r.role}`, `All ${r.role}`)} + depth={0} + isSelected={false} + unread={r.unread} + total={r.total} + showTotal={showFolderTotalCount} + hasChildren={false} + isExpanded={false} + onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: r.role }); }} + onToggleExpand={() => {}} + /> + ); + })} + + )} + + )} + {/* Folders section */} - + toggleSection('foldersExpanded', setFoldersExpanded)}> {foldersExpanded ? ( ) : ( )} - Folders + {t('sidebar.folders', 'Folders')} - + { onClose(); navigation.navigate('MainTabs'); }} + accessibilityLabel={t('sidebar.settings', 'Settings')} + > - + {foldersExpanded && ( mailboxes.length === 0 ? ( - Loading mailboxes… + {t('sidebar.loading_mailboxes', 'Loading mailboxes...')} ) : ( visibleNodes.map((node) => { const hasChildren = node.children.length > 0; @@ -478,7 +1034,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) const Icon = node.isAccountNode ? Users : iconFor(node.role, node.name, hasChildren, isExpanded); - const isSelected = !node.isAccountNode && node.id === currentMailboxId; + const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId; return ( } - label={node.name} + label={node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)} depth={node.depth} isSelected={isSelected} isAccountHeader={node.isAccountNode} unread={node.unreadEmails} total={node.totalEmails} + showTotal={showFolderTotalCount && !node.isAccountNode} hasChildren={hasChildren} isExpanded={isExpanded} onPress={ @@ -501,15 +1058,68 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) ? () => toggleExpand(node.id) : () => handleSelect(node.id) } + onLongPress={ + node.isAccountNode + ? () => openSharedAccountSheet(node) + : () => openFolderSheet(node) + } onToggleExpand={() => toggleExpand(node.id)} + onPressUnread={node.isAccountNode ? undefined : () => handleSelectUnread(node.id)} /> ); }) ) )} + + {/* Tags section (#175) */} + {keywordDefs.length > 0 && ( + <> + toggleSection('tagsExpanded', setTagsExpanded)}> + {tagsExpanded ? ( + + ) : ( + + )} + {t('sidebar.tags', 'Tags')} + + {tagsExpanded && keywordDefs.map((kw) => { + const counts = tagCounts.get(kw.id); + const isSelected = filters.keyword === keywordToken(kw.id); + const dot = c.tags[kw.color]?.dot ?? c.textMuted; + return ( + } + label={kw.label} + depth={0} + isSelected={isSelected} + unread={counts?.unread ?? 0} + total={counts?.total ?? 0} + showTotal={showFolderTotalCount} + hasChildren={false} + isExpanded={false} + onPress={() => selectTag(kw.id)} + onToggleExpand={() => {}} + /> + ); + })} + + )} + + {sheet && setSheet(null)} />} + {prompt && ( + setPrompt(null)} + /> + )} ); } @@ -590,9 +1200,6 @@ function makeStyles(c: ThemePalette) { shadowRadius: 12, elevation: 8, }, - accountMenuSection: { - paddingVertical: 4, - }, accountMenuList: { maxHeight: 260, }, @@ -667,6 +1274,11 @@ function makeStyles(c: ThemePalette) { color: c.textMuted, flexShrink: 1, }, + accountMenuRemove: { + width: 28, height: 28, + alignItems: 'center', justifyContent: 'center', + borderRadius: radius.sm, + }, accountMenuDivider: { height: StyleSheet.hairlineWidth, backgroundColor: c.border, @@ -789,5 +1401,67 @@ function makeStyles(c: ThemePalette) { color: c.textMuted, }, + // Long-press action sheet / name prompt + sheetOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + sheet: { + position: 'absolute', + left: 0, right: 0, bottom: 0, + backgroundColor: c.popover, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + borderTopWidth: 1, + borderColor: c.border, + paddingTop: spacing.sm, + paddingBottom: spacing.xl, + }, + sheetTitle: { + ...typography.bodySemibold, + color: c.text, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + sheetRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.lg, + minHeight: 44, + }, + sheetRowText: { ...typography.body, color: c.text }, + promptCard: { + position: 'absolute', + left: spacing.lg, right: spacing.lg, + top: '30%', + backgroundColor: c.popover, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + paddingVertical: spacing.sm, + }, + promptMessage: { ...typography.caption, color: c.textSecondary, paddingHorizontal: spacing.lg }, + promptInput: { + ...typography.body, + color: c.text, + marginHorizontal: spacing.lg, + marginTop: spacing.sm, + backgroundColor: c.surface, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + paddingHorizontal: spacing.md, + paddingVertical: 8, + }, + promptActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + }, + promptButton: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm }, + promptButtonText: { ...typography.bodyMedium, color: c.text }, }); } diff --git a/src/components/settings/FolderSettings.tsx b/src/components/settings/FolderSettings.tsx index cefd9a0c..1679ffd4 100644 --- a/src/components/settings/FolderSettings.tsx +++ b/src/components/settings/FolderSettings.tsx @@ -11,47 +11,70 @@ import { View, } from 'react-native'; import { - Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive, - Plus, Pencil, Trash2, X, + Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive, Flag, Star, Mails, + StickyNote, Clock, AlarmClock, Users, Plus, Pencil, Trash2, X, } from 'lucide-react-native'; -import { SettingsSection } from './settings-section'; +import { SettingsSection, Select } from './settings-section'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { ownMailboxes } from '../../lib/mailbox-tree'; +import { ownMailboxes, mailboxSubtreeIds } from '../../lib/mailbox-tree'; +import { localizeMailboxName } from '../../lib/mailbox-label'; import { useEmailStore } from '../../stores/email-store'; +import { useLocaleStore } from '../../stores/locale-store'; import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email'; +import { jmapClient } from '../../api/jmap-client'; import type { Mailbox } from '../../api/types'; -const ROLE_LABEL: Record = { - inbox: 'Inbox', drafts: 'Drafts', sent: 'Sent', trash: 'Trash', - junk: 'Junk', archive: 'Archive', important: 'Important', all: 'All', -}; const ROLE_ICON: Record = { inbox: Inbox, drafts: FileText, sent: Send, trash: Trash, - junk: ShieldAlert, archive: Archive, + junk: ShieldAlert, spam: ShieldAlert, archive: Archive, important: Flag, flagged: Star, + all: Mails, memos: StickyNote, scheduled: Clock, snoozed: AlarmClock, shared: Users, }; +// Special-use roles a user folder can be given (RFC 8621 §2 + the common +// Stalwart extras). The server enforces uniqueness per account. +const ASSIGNABLE_ROLES = ['inbox', 'drafts', 'sent', 'archive', 'junk', 'trash', 'important', 'all', 'flagged', 'memos', 'scheduled', 'snoozed']; + function getIcon(mb: Mailbox) { if (mb.role && ROLE_ICON[mb.role]) return ROLE_ICON[mb.role]; return Folder; } +const NO_PARENT = '__root__'; +const OWN_ACCOUNT = '__own__'; +const NO_ROLE = '__none__'; + type Editor = | { kind: 'create' } - | { kind: 'rename'; mailbox: Mailbox }; + | { kind: 'edit'; mailbox: Mailbox }; + +/** "Parent / Child" path used to label folders in the pickers. */ +function pathOf(all: Mailbox[], mb: Mailbox, t: (k: string, f?: string) => string): string { + const byId = new Map(all.map((m) => [m.id, m])); + const parts = [localizeMailboxName(mb.role, mb.name, t)]; + let node: Mailbox | undefined = mb; + let guard = 0; + while (node?.parentId && guard++ < 16) { + node = byId.get(node.parentId); + if (node) parts.unshift(localizeMailboxName(node.role, node.name, t)); + } + return parts.join(' / '); +} export function FolderSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - // Folder management (create/rename/delete) targets the user's own account; - // shared (group account) folders are read-only here. + const t = useLocaleStore((s) => s.t); const allMailboxes = useEmailStore((s) => s.mailboxes); const mailboxes = React.useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]); const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes); const [editor, setEditor] = useState(null); const [draftName, setDraftName] = useState(''); + const [draftParent, setDraftParent] = useState(NO_PARENT); + const [draftAccount, setDraftAccount] = useState(OWN_ACCOUNT); + const [draftRole, setDraftRole] = useState(NO_ROLE); const [busyId, setBusyId] = useState(null); const [saving, setSaving] = useState(false); @@ -59,11 +82,38 @@ export function FolderSettings() { if (mailboxes.length === 0) void fetchMailboxes(); }, [mailboxes.length, fetchMailboxes]); + // Shared/group accounts the user may create folders in (webmail: "New + // folder" on a shared account header routes to the owner account). + const sharedAccounts = React.useMemo(() => { + const out: { id: string; name: string }[] = []; + const seen = new Set(); + for (const m of allMailboxes) { + if (!m.isShared || !m.accountId || seen.has(m.accountId)) continue; + if (m.myRights?.mayCreateChild === false) continue; + seen.add(m.accountId); + out.push({ id: m.accountId, name: m.accountName || jmapClient.getAccountName(m.accountId) || m.accountId }); + } + return out; + }, [allMailboxes]); + + // Folders of the account the editor targets, for the parent picker. + const parentCandidates = React.useMemo(() => { + const pool = draftAccount === OWN_ACCOUNT + ? mailboxes + : allMailboxes.filter((m) => m.isShared && m.accountId === draftAccount); + // Moving a folder under itself or one of its descendants is impossible. + const excluded = editor?.kind === 'edit' ? new Set(mailboxSubtreeIds(pool, editor.mailbox.id)) : new Set(); + return pool + .filter((m) => !excluded.has(m.id) && m.myRights?.mayCreateChild !== false) + .map((m) => ({ value: m.id, label: pathOf(pool, m, t) })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [draftAccount, mailboxes, allMailboxes, editor, t]); + const sorted = [...mailboxes].sort((a, b) => { const ar = a.role ? 0 : 1; const br = b.role ? 0 : 1; if (ar !== br) return ar - br; - return a.name.localeCompare(b.name); + return pathOf(mailboxes, a, t).localeCompare(pathOf(mailboxes, b, t)); }); const totalUnread = mailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0); @@ -71,15 +121,17 @@ export function FolderSettings() { const openCreate = () => { setEditor({ kind: 'create' }); setDraftName(''); + setDraftParent(NO_PARENT); + setDraftAccount(OWN_ACCOUNT); + setDraftRole(NO_ROLE); }; - const openRename = (mailbox: Mailbox) => { - if (mailbox.role) { - Alert.alert('Cannot rename', 'System folders cannot be renamed.'); - return; - } - setEditor({ kind: 'rename', mailbox }); + const openEdit = (mailbox: Mailbox) => { + setEditor({ kind: 'edit', mailbox }); setDraftName(mailbox.name); + setDraftParent(mailbox.parentId ?? NO_PARENT); + setDraftAccount(OWN_ACCOUNT); + setDraftRole(mailbox.role ?? NO_ROLE); }; const closeEditor = () => setEditor(null); @@ -87,20 +139,37 @@ export function FolderSettings() { const saveDraft = async () => { const name = draftName.trim(); if (!name) { - Alert.alert('Name required'); + Alert.alert(t('settings.folders.name_required', 'Name required')); return; } setSaving(true); try { if (editor?.kind === 'create') { - await createMailbox({ name }); - } else if (editor?.kind === 'rename') { - await updateMailbox(editor.mailbox.id, { name }); + const accountId = draftAccount === OWN_ACCOUNT ? undefined : draftAccount; + const parentId = draftParent === NO_PARENT ? null : draftParent; + const raw = parentId + ? allMailboxes.find((m) => m.id === parentId)?.originalId ?? parentId + : null; + await createMailbox( + { name, parentId: raw, ...(draftRole !== NO_ROLE ? { role: draftRole } : {}) }, + accountId, + ); + } else if (editor?.kind === 'edit') { + const mb = editor.mailbox; + const changes: { name?: string; parentId?: string | null; role?: string | null } = {}; + if (name !== mb.name && !mb.role) changes.name = name; + const nextParent = draftParent === NO_PARENT ? null : draftParent; + if ((mb.parentId ?? null) !== nextParent) changes.parentId = nextParent; + const nextRole = draftRole === NO_ROLE ? null : draftRole; + if ((mb.role ?? null) !== nextRole) changes.role = nextRole; + if (Object.keys(changes).length > 0) await updateMailbox(mb.id, changes); } closeEditor(); + // A reparent moves the whole subtree: re-read the tree rather than + // patching one node (#855). await fetchMailboxes(); } catch (err) { - Alert.alert('Save failed', err instanceof Error ? err.message : String(err)); + Alert.alert(t('settings.folders.save_failed', 'Save failed'), err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } @@ -108,47 +177,66 @@ export function FolderSettings() { const confirmDelete = (mailbox: Mailbox) => { if (mailbox.role) { - Alert.alert('Cannot delete', 'System folders cannot be removed.'); + Alert.alert(t('settings.folders.cannot_delete', 'Cannot delete'), t('settings.folders.system_folder_delete', 'System folders cannot be removed.')); return; } + const name = mailbox.name; if ((mailbox.totalEmails ?? 0) > 0) { Alert.alert( - 'Folder not empty', - `"${mailbox.name}" contains ${mailbox.totalEmails} email${mailbox.totalEmails === 1 ? '' : 's'}. Delete anyway?`, + t('settings.folders.not_empty_title', 'Folder not empty'), + t('settings.folders.not_empty_message', `"${name}" contains ${mailbox.totalEmails} emails. Delete anyway?`, { name, count: mailbox.totalEmails }), [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Delete', style: 'destructive', onPress: () => { void performDelete(mailbox); } }, + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { text: t('common.delete', 'Delete'), style: 'destructive', onPress: () => { void performDelete(mailbox, true); } }, ], ); return; } Alert.alert( - 'Delete folder', - `Permanently delete "${mailbox.name}"?`, + t('mailbox_context_menu.delete_confirm_title', 'Delete folder'), + t('mailbox_context_menu.delete_confirm_message', `Permanently delete the folder "${name}"? This action cannot be undone.`, { name }), [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Delete', style: 'destructive', onPress: () => { void performDelete(mailbox); } }, + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { text: t('common.delete', 'Delete'), style: 'destructive', onPress: () => { void performDelete(mailbox, false); } }, ], ); }; - const performDelete = async (mailbox: Mailbox) => { + const performDelete = async (mailbox: Mailbox, removeEmails: boolean) => { setBusyId(mailbox.id); try { - await deleteMailbox(mailbox.id); + await deleteMailbox(mailbox.id, undefined, { onDestroyRemoveEmails: removeEmails }); await fetchMailboxes(); } catch (err) { - Alert.alert('Delete failed', err instanceof Error ? err.message : String(err)); + Alert.alert(t('mailbox_context_menu.toast_error_delete', 'Failed to delete folder'), err instanceof Error ? err.message : String(err)); } finally { setBusyId(null); } }; + const roleOptions = React.useMemo(() => { + const takenByOthers = new Set( + mailboxes + .filter((m) => m.role && (editor?.kind !== 'edit' || m.id !== editor.mailbox.id)) + .map((m) => m.role as string), + ); + return [ + { value: NO_ROLE, label: t('settings.folders.role_none', 'No special role') }, + ...ASSIGNABLE_ROLES + .filter((r) => !takenByOthers.has(r)) + .map((r) => ({ value: r, label: localizeMailboxName(r, r.charAt(0).toUpperCase() + r.slice(1), t) })), + ]; + }, [mailboxes, editor, t]); + return ( {mailboxes.length === 0 ? ( @@ -168,24 +256,24 @@ export function FolderSettings() { {sorted.map((mb) => { const Icon = getIcon(mb); - const editable = !mb.role; + const depth = pathOf(mailboxes, mb, t).split(' / ').length - 1; return ( editable && openRename(mb)} - onLongPress={() => editable && confirmDelete(mb)} - disabled={!editable && busyId !== mb.id} + onPress={() => openEdit(mb)} + onLongPress={() => !mb.role && confirmDelete(mb)} style={({ pressed }) => [ styles.folderRow, - editable && pressed && styles.folderRowPressed, + pressed && styles.folderRowPressed, + { paddingLeft: spacing.md + depth * 12 }, ]} > - {mb.name} - {mb.role && ROLE_LABEL[mb.role] && ( + {localizeMailboxName(mb.role, mb.name, t)} + {mb.role && ( - {ROLE_LABEL[mb.role]} + {mb.role} )} @@ -196,12 +284,10 @@ export function FolderSettings() { )} {mb.totalEmails} - {editable && ( - busyId === mb.id ? ( - - ) : ( - - ) + {busyId === mb.id ? ( + + ) : ( + )} @@ -216,23 +302,68 @@ export function FolderSettings() { - {editor?.kind === 'rename' ? `Rename "${editor.mailbox.name}"` : 'New folder'} + {editor?.kind === 'edit' + ? t('settings.folders.edit_folder', `Edit "${editor.mailbox.name}"`, { name: editor.mailbox.name }) + : t('settings.folders.new_folder', 'New folder')} - - Folder name + + {t('settings.folders.folder_name', 'Folder name')} - {editor?.kind === 'rename' && ( + {editor?.kind === 'edit' && editor.mailbox.role ? ( + {t('settings.folders.system_folder_rename', 'System folders cannot be renamed.')} + ) : null} + + {editor?.kind === 'create' && sharedAccounts.length > 0 && ( + <> + {t('settings.folders.account', 'Account')} + + + {draftAccount === OWN_ACCOUNT && ( + <> + {t('settings.folders.role', 'Special use')} +