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)

This commit is contained in:
Linus Rath
2026-08-29 18:39:45 +02:00
parent d6670c2b60
commit 6ae21d5690
14 changed files with 2197 additions and 343 deletions
+21 -21
View File
@@ -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:<id>')` 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:<id>' }`; 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.
+75
View File
@@ -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<string, number>;
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<KeywordScanResult> {
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<string, number> = {};
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<string, boolean> }> | 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 };
}
+59
View File
@@ -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<TagCount[]> {
if (tagIds.length === 0) return [];
const accountId = accountIdOverride ?? jmapClient.accountId;
const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest());
const counts = new Map<string, TagCount>(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)!);
}
+466 -125
View File
@@ -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<string> = 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<string, string>;
/** Per-target paging cursors to pass back as `positions` for the next page. */
positions: Record<string, number>;
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<string, number>;
}
// ── 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<Response> {
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, any>, string]>;
interface Transport {
post: (methodCalls: JMAPMethodCall[]) => Promise<MethodResponses>;
}
async function jmapPost(
apiUrl: string,
authHeader: string,
methodCalls: JMAPMethodCall[],
): Promise<MethodResponses> {
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<Array<[string, Record<string, any>, 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<string, { list: Mailbox[]; at: number }>;
}
// 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<Email[]> {
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<string, AccountEntry>();
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<UnifiedEmail[]> {
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<AccountEntry> {
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<UnifiedEmail>((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<Mailbox[]> {
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<string, unknown> | null {
const conditions: Record<string, unknown>[] = [];
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<UnifiedInboxResult> {
const { includeGroup = false } = opts;
const errors: Record<string, string> = {};
const positions: Record<string, number> = { ...(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<string, Record<string, unknown>>,
destroy?: string[],
): Promise<void> {
const entry = await entryFor(accountId);
const args: Record<string, unknown> = { 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<string, { type?: string; description?: string }>;
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<string, { accountId: string; jmapAccountId: string; emails: UnifiedEmail[] }> {
const groups = new Map<string, { accountId: string; jmapAccountId: string; emails: UnifiedEmail[] }>();
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<string, boolean | null>,
): Promise<void> {
const pointer: Record<string, unknown> = {};
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<string, Record<string, unknown>> = {};
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<void> {
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<string, Record<string, unknown>> = {};
for (const e of g.emails) {
const patch: Record<string, unknown> = {};
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];
}
File diff suppressed because it is too large Load Diff
+195 -63
View File
@@ -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<string, string> = {
inbox: 'Inbox', drafts: 'Drafts', sent: 'Sent', trash: 'Trash',
junk: 'Junk', archive: 'Archive', important: 'Important', all: 'All',
};
const ROLE_ICON: Record<string, any> = {
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<Editor | null>(null);
const [draftName, setDraftName] = useState('');
const [draftParent, setDraftParent] = useState<string>(NO_PARENT);
const [draftAccount, setDraftAccount] = useState<string>(OWN_ACCOUNT);
const [draftRole, setDraftRole] = useState<string>(NO_ROLE);
const [busyId, setBusyId] = useState<string | null>(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<string>();
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<string>();
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 (
<View style={styles.container}>
<SettingsSection
title="Folders"
description={`${mailboxes.length} folder${mailboxes.length === 1 ? '' : 's'}, ${totalUnread} unread. Tap a folder to rename, long-press to delete.`}
title={t('settings.folders.title', 'Folders')}
description={t(
'settings.folders.description_mobile',
`${mailboxes.length} folders, ${totalUnread} unread. Tap a folder to edit, long-press to delete.`,
{ count: mailboxes.length, unread: totalUnread },
)}
>
<View style={styles.headerRow}>
<Button
@@ -157,7 +245,7 @@ export function FolderSettings() {
onPress={openCreate}
icon={<Plus size={14} color={c.primaryForeground} />}
>
New folder
{t('settings.folders.new_folder', 'New folder')}
</Button>
</View>
{mailboxes.length === 0 ? (
@@ -168,24 +256,24 @@ export function FolderSettings() {
<View>
{sorted.map((mb) => {
const Icon = getIcon(mb);
const editable = !mb.role;
const depth = pathOf(mailboxes, mb, t).split(' / ').length - 1;
return (
<Pressable
key={mb.id}
onPress={() => 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 },
]}
>
<View style={styles.folderLeft}>
<Icon size={16} color={mb.role ? c.primary : c.mutedForeground} />
<Text style={styles.folderName} numberOfLines={1}>{mb.name}</Text>
{mb.role && ROLE_LABEL[mb.role] && (
<Text style={styles.folderName} numberOfLines={1}>{localizeMailboxName(mb.role, mb.name, t)}</Text>
{mb.role && (
<View style={styles.rolePill}>
<Text style={styles.rolePillText}>{ROLE_LABEL[mb.role]}</Text>
<Text style={styles.rolePillText}>{mb.role}</Text>
</View>
)}
</View>
@@ -196,12 +284,10 @@ export function FolderSettings() {
</View>
)}
<Text style={styles.total}>{mb.totalEmails}</Text>
{editable && (
busyId === mb.id ? (
<ActivityIndicator size="small" color={c.primary} />
) : (
<Pencil size={14} color={c.textMuted} />
)
{busyId === mb.id ? (
<ActivityIndicator size="small" color={c.primary} />
) : (
<Pencil size={14} color={c.textMuted} />
)}
</View>
</Pressable>
@@ -216,23 +302,68 @@ export function FolderSettings() {
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{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')}
</Text>
<Pressable onPress={closeEditor} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>Folder name</Text>
<ScrollView contentContainerStyle={styles.modalBody} keyboardShouldPersistTaps="handled">
<Text style={styles.fieldLabel}>{t('settings.folders.folder_name', 'Folder name')}</Text>
<TextInput
value={draftName}
onChangeText={setDraftName}
placeholder="Receipts"
placeholder={t('settings.folders.name_placeholder', 'Receipts')}
placeholderTextColor={c.textMuted}
style={styles.input}
autoFocus
editable={editor?.kind !== 'edit' || !editor.mailbox.role}
autoFocus={editor?.kind === 'create'}
/>
{editor?.kind === 'rename' && (
{editor?.kind === 'edit' && editor.mailbox.role ? (
<Text style={styles.hint}>{t('settings.folders.system_folder_rename', 'System folders cannot be renamed.')}</Text>
) : null}
{editor?.kind === 'create' && sharedAccounts.length > 0 && (
<>
<Text style={styles.fieldLabel}>{t('settings.folders.account', 'Account')}</Text>
<Select
value={draftAccount}
onChange={(v) => { setDraftAccount(v); setDraftParent(NO_PARENT); }}
options={[
{ value: OWN_ACCOUNT, label: t('settings.folders.own_account', 'My folders') },
...sharedAccounts.map((a) => ({ value: a.id, label: a.name })),
]}
/>
</>
)}
<Text style={styles.fieldLabel}>
{editor?.kind === 'edit'
? t('settings.folders.move_under', 'Move under')
: t('settings.folders.subfolder_of', 'Parent folder')}
</Text>
<Select
value={draftParent}
onChange={setDraftParent}
options={[
{ value: NO_PARENT, label: t('settings.folders.top_level', 'Top level') },
...parentCandidates,
]}
/>
{draftAccount === OWN_ACCOUNT && (
<>
<Text style={styles.fieldLabel}>{t('settings.folders.role', 'Special use')}</Text>
<Select value={draftRole} onChange={setDraftRole} options={roleOptions} />
<Text style={styles.hint}>
{t('settings.folders.role_hint', 'Assign a special-use role (Archive, Junk, …) to this folder. Each role can be held by one folder.')}
</Text>
</>
)}
{editor?.kind === 'edit' && !editor.mailbox.role && (
<Pressable
onPress={() => {
closeEditor();
@@ -241,19 +372,19 @@ export function FolderSettings() {
style={styles.deleteRow}
>
<Trash2 size={14} color={c.error} />
<Text style={styles.deleteRowText}>Delete folder</Text>
<Text style={styles.deleteRowText}>{t('mailbox_context_menu.delete_folder', 'Delete folder')}</Text>
</Pressable>
)}
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>Cancel</Button>
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>{t('common.cancel', 'Cancel')}</Button>
<Button
variant="default"
size="sm"
onPress={() => { void saveDraft(); }}
loading={saving}
>
Save
{t('common.save', 'Save')}
</Button>
</View>
</View>
@@ -293,7 +424,7 @@ function makeStyles(c: ThemePalette) {
modalSheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg,
maxHeight: '80%',
maxHeight: '85%',
},
modalHeader: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
@@ -303,6 +434,7 @@ function makeStyles(c: ThemePalette) {
modalTitle: { ...typography.h3, color: c.text, flexShrink: 1 },
modalBody: { padding: spacing.lg, gap: spacing.md },
fieldLabel: { ...typography.captionMedium, color: c.textSecondary },
hint: { ...typography.caption, color: c.textMuted },
input: {
...typography.body, color: c.text,
backgroundColor: c.surface,
+99 -3
View File
@@ -1,12 +1,15 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw } from 'lucide-react-native';
import { View, Text, StyleSheet, Pressable, TextInput, Alert, ActivityIndicator } from 'react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
import { DARK_COLORS } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { discoverKeywords } from '../../api/keyword-discovery';
import { findUnrecognizedKeywords, type UnrecognizedKeyword } from '../../lib/keyword-discovery';
import { jmapClient } from '../../api/jmap-client';
type Keyword = KeywordDef;
@@ -46,6 +49,51 @@ export function KeywordSettings() {
removeKeyword(id);
};
// One stray tap used to wipe a carefully built tag list (webmail removed
// the button in 1.8.1); keep it behind a confirm.
const confirmReset = () => {
Alert.alert(
t('settings.keywords.reset_defaults', 'Reset to Defaults'),
t('settings.keywords.reset_confirm', 'Replace your tag list with the default tags? Tags already set on messages stay on the server.'),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: t('settings.keywords.reset_defaults', 'Reset to Defaults'), style: 'destructive', onPress: resetDefaults },
],
);
};
// Scan the mailbox for `$label:` keywords no local tag explains (#658) and
// offer to add them with a proposed name and colour.
const [scan, setScan] = useState<{
phase: 'idle' | 'running' | 'done' | 'error';
scanned: number;
total: number;
complete: boolean;
found: UnrecognizedKeyword[];
error?: string;
}>({ phase: 'idle', scanned: 0, total: 0, complete: false, found: [] });
const scanAbort = React.useRef({ aborted: false });
const runScan = async () => {
if (!jmapClient.isConnected) return;
scanAbort.current = { aborted: false };
setScan({ phase: 'running', scanned: 0, total: 0, complete: false, found: [] });
try {
const result = await discoverKeywords({
signal: scanAbort.current,
onProgress: (scanned, total) => setScan((s) => ({ ...s, scanned, total })),
});
const found = findUnrecognizedKeywords(result.keywords, useKeywordsStore.getState().keywords);
setScan({ phase: 'done', scanned: result.scanned, total: result.total, complete: result.complete, found });
} catch (err) {
setScan((s) => ({ ...s, phase: 'error', error: err instanceof Error ? err.message : String(err) }));
}
};
useEffect(() => () => { scanAbort.current.aborted = true; }, []);
const adoptFound = (kw: UnrecognizedKeyword) => {
addKeyword({ id: kw.id, label: kw.label, color: kw.color });
setScan((s) => ({ ...s, found: s.found.filter((f) => f.id !== kw.id) }));
};
return (
<SettingsSection title={t('settings.keywords.title', "Email Tags")} description={t('settings.keywords.description_mobile', "Colored tags to organize your mail.")}>
<View style={{ gap: spacing.sm }}>
@@ -93,10 +141,56 @@ export function KeywordSettings() {
<Plus size={14} color={c.mutedForeground} />
<Text style={styles.outlineBtnText}>{t('settings.keywords.add_keyword', "Add Tag")}</Text>
</Pressable>
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
<Pressable style={styles.outlineBtn} onPress={confirmReset}>
<RotateCcw size={14} color={c.mutedForeground} />
<Text style={styles.outlineBtnText}>{t('settings.keywords.reset_defaults', "Reset to Defaults")}</Text>
</Pressable>
<Pressable style={styles.outlineBtn} onPress={() => { void runScan(); }} disabled={scan.phase === 'running'}>
{scan.phase === 'running' ? (
<ActivityIndicator size="small" color={c.mutedForeground} />
) : (
<ScanSearch size={14} color={c.mutedForeground} />
)}
<Text style={styles.outlineBtnText}>{t('settings.keywords.discover.scan', 'Find tags in mailbox')}</Text>
</Pressable>
</View>
)}
{scan.phase === 'running' && (
<Text style={styles.scanStatus}>
{t('settings.keywords.discover.progress', `Scanned ${scan.scanned} of ${scan.total} messages…`, { scanned: scan.scanned, total: scan.total })}
</Text>
)}
{scan.phase === 'error' && (
<Text style={[styles.scanStatus, { color: c.error }]}>{scan.error}</Text>
)}
{scan.phase === 'done' && (
<View style={styles.scanResults}>
<Text style={styles.scanStatus}>
{scan.found.length === 0
? t('settings.keywords.discover.none', `No unknown tags found in ${scan.scanned} messages.`, { scanned: scan.scanned })
: t('settings.keywords.discover.found', `${scan.found.length} tags found that are not defined here.`, { count: scan.found.length })}
{!scan.complete ? ` ${t('settings.keywords.discover.partial', '(The scan stopped at the newest messages only.)')}` : ''}
</Text>
{scan.found.map((kw) => (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: c.tags[kw.color]?.dot ?? c.textMuted }]} />
<View style={{ flex: 1 }}>
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>{kw.keyword} · {kw.count}</Text>
</View>
<Pressable style={styles.outlineBtn} onPress={() => adoptFound(kw)}>
<Plus size={14} color={c.mutedForeground} />
<Text style={styles.outlineBtnText}>{t('common.add', 'Add')}</Text>
</Pressable>
</View>
))}
{scan.found.length > 1 && (
<Pressable style={styles.outlineBtn} onPress={() => { for (const kw of [...scan.found]) adoptFound(kw); }}>
<Plus size={14} color={c.mutedForeground} />
<Text style={styles.outlineBtnText}>{t('settings.keywords.discover.add_all', 'Add all')}</Text>
</Pressable>
)}
</View>
)}
</View>
@@ -229,6 +323,8 @@ function makeStyles(c: ThemePalette) {
borderColor: c.border,
},
outlineBtnText: { ...typography.caption, color: c.mutedForeground },
scanStatus: { ...typography.caption, color: c.mutedForeground, paddingTop: spacing.xs },
scanResults: { gap: spacing.sm },
form: {
gap: spacing.md,
padding: spacing.md,
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { findUnrecognizedKeywords, suggestKeywordColor, suggestKeywordLabel } from '../keyword-discovery';
describe('keyword discovery (#658)', () => {
it('proposes humanized labels', () => {
expect(suggestKeywordLabel('q3-invoices')).toBe('Q3 Invoices');
expect(suggestKeywordLabel('work/clients')).toBe('Work/Clients');
});
it('keeps a palette-named id on its own colour and spreads the rest', () => {
expect(suggestKeywordColor('blue')).toBe('blue');
const a = suggestKeywordColor('alpha');
const b = suggestKeywordColor('alpha', [a]);
expect(a).not.toBe(b);
});
it('folds prefixes and case into one tag and skips defined ones', () => {
const found = findUnrecognizedKeywords(
{ '$label:work': 3, '$color:Work': 2, '$label:todo': 1, '$seen': 10, '$label:blue': 4 },
[{ id: 'blue', label: 'Blue', color: 'blue' }],
);
expect(found.map((f) => [f.id, f.count])).toEqual([['work', 5], ['todo', 1]]);
expect(found[0].keyword).toBe('$label:work');
expect(found[0].label).toBe('Work');
});
});
+4 -1
View File
@@ -1,4 +1,7 @@
export const MAX_ACCOUNTS = 5;
// Registry cap. Raised from 5: the per-account cost is one stored credential
// set and one cached snapshot; the SSE socket budget is bounded separately
// (MAX_SSE_STREAMS), so nothing scales with this number at runtime.
export const MAX_ACCOUNTS = 10;
export function generateAccountId(username: string, serverUrl: string): string {
let host = serverUrl;
+120
View File
@@ -0,0 +1,120 @@
import { colors } from '../theme/tokens';
import type { KeywordDef, KeywordColor } from '../stores/keywords-store';
import { KEYWORD_PREFIX, tagIdFromKeyword } from './thread-utils';
// Port of the webmail's lib/keyword-discovery.ts (#658): propose a label and
// colour for `$label:` / `$color:` keywords found on the server that no local
// tag definition explains, so a reinstalled app can recover its tags without
// retyping every id.
/** A keyword found on the server that no tag definition accounts for. */
export interface UnrecognizedKeyword {
/** The tag id: the keyword with its prefix removed. Fixed - never reworded. */
id: string;
/** The keyword as it is stored on messages, prefix included. */
keyword: string;
/** Proposed display name, derived from the id. */
label: string;
/** Proposed colour, a key of the tag palette. */
color: KeywordColor;
/**
* How many scanned messages carry the tag, under either prefix. A floor
* rather than a total when the scan that produced it was incomplete.
*/
count: number;
}
const KEYWORD_SEPARATOR = '/';
const PALETTE_KEYS = Object.keys(colors.tags) as KeywordColor[];
function keywordLevels(id: string): string[] {
return id.split(KEYWORD_SEPARATOR).filter(Boolean);
}
/** Turns one level of an id into words: `q3-invoices` -> `Q3 Invoices`. */
function humanizeLevel(level: string): string {
const words = level
.split(/[-_]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1));
return words.length > 0 ? words.join(' ') : level;
}
/**
* The name to propose for a tag whose definition is gone. The native app has
* no tag nesting, so the whole id is one opaque token: every level is named.
*/
export function suggestKeywordLabel(id: string): string {
const levels = keywordLevels(id);
if (levels.length === 0) return id;
return levels.map(humanizeLevel).join(KEYWORD_SEPARATOR);
}
/** A small stable spread over `range`, so the same id always starts at the same hue. */
function hashIndex(id: string, range: number): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0;
}
return Math.abs(hash) % Math.max(1, range);
}
/**
* The colour to propose for a tag whose definition is gone. An id that is
* itself a palette key keeps that colour (the default tags are named after
* their colour, `$label:blue`); otherwise a hue is picked by hashing the id
* and walked forward past any colour in `taken`.
*/
export function suggestKeywordColor(id: string, taken: Iterable<string> = []): KeywordColor {
const levels = keywordLevels(id);
const own = levels.length > 0 ? levels[levels.length - 1] : id;
if ((PALETTE_KEYS as string[]).includes(own)) return own as KeywordColor;
const used = new Set(taken);
const start = hashIndex(id, PALETTE_KEYS.length);
for (let step = 0; step < PALETTE_KEYS.length; step++) {
const candidate = PALETTE_KEYS[(start + step) % PALETTE_KEYS.length];
if (!used.has(candidate)) return candidate;
}
return PALETTE_KEYS[start];
}
/**
* The tags in `keywords` (a keyword-to-message-count map, as
* `discoverKeywords` returns) that no definition in `defined` explains, each
* with a name and colour to propose. Keywords are compared case-insensitively
* and `$label:` / legacy `$color:` spellings are folded into one tag; results
* are ordered by descending count, then id.
*/
export function findUnrecognizedKeywords(
keywords: Record<string, number>,
defined: KeywordDef[],
): UnrecognizedKeyword[] {
const known = new Set(defined.map((keyword) => keyword.id.toLowerCase()));
const taken = new Set<string>(defined.map((keyword) => keyword.color));
const unrecognized = new Map<string, { id: string; count: number }>();
for (const [keyword, count] of Object.entries(keywords)) {
const id = tagIdFromKeyword(keyword);
if (!id) continue;
const folded = id.toLowerCase();
if (known.has(folded)) continue;
const existing = unrecognized.get(folded);
if (existing) existing.count += count;
else unrecognized.set(folded, { id, count });
}
return [...unrecognized.values()]
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id))
.map(({ id, count }) => {
const color = suggestKeywordColor(id, taken);
taken.add(color);
return {
id,
keyword: KEYWORD_PREFIX + id,
label: suggestKeywordLabel(id),
color,
count,
};
});
}
+32 -2
View File
@@ -129,8 +129,17 @@ const ACCOUNT_NODE_RIGHTS: Mailbox['myRights'] = {
* (GitHub #151), so a Stalwart group mailbox shows up as its own section
* rather than being mixed into the user's folder list.
*/
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
const own = mailboxes.filter((m) => !m.isShared);
export function buildMailboxTree(
mailboxes: Mailbox[],
opts: {
/**
* Role folders to leave out of the user's own tree — e.g. the server's
* `scheduled` folder while the virtual Scheduled row is shown (#495).
*/
hideOwnRoles?: ReadonlySet<string>;
} = {},
): MailboxNode[] {
const own = mailboxes.filter((m) => !m.isShared && !(m.role && opts.hideOwnRoles?.has(m.role)));
const shared = mailboxes.filter((m) => m.isShared);
const roots = buildRoots(own);
@@ -180,6 +189,27 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
return [...roots, ...accountNodes];
}
/** Every folder below `mailboxId` (any depth) plus the folder itself. */
export function mailboxSubtreeIds(mailboxes: Mailbox[], mailboxId: string): string[] {
const childrenOf = new Map<string, string[]>();
for (const m of mailboxes) {
if (!m.parentId) continue;
const list = childrenOf.get(m.parentId);
if (list) list.push(m.id);
else childrenOf.set(m.parentId, [m.id]);
}
const out: string[] = [];
const seen = new Set<string>();
const walk = (id: string) => {
if (seen.has(id)) return;
seen.add(id);
out.push(id);
for (const child of childrenOf.get(id) ?? []) walk(child);
};
walk(mailboxId);
return out;
}
// Flatten the tree in traversal order, skipping children of collapsed nodes.
export function flattenVisible(
nodes: MailboxNode[],
+8 -1
View File
@@ -90,7 +90,14 @@ export type RootStackParamList = {
GroupDetail: { groupId: string };
AddAccount: undefined;
Scheduled: undefined;
UnifiedInbox: undefined;
UnifiedInbox:
| {
/** Per-role unified view ("All Sent", …); defaults to the inbox. */
role?: 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash';
/** Cross-folder views: every included folder, unread only, starred only. */
view?: 'all' | 'unread' | 'starred';
}
| undefined;
};
export type MainTabsParamList = {
+347 -66
View File
@@ -1,62 +1,149 @@
import React from 'react';
import {
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable,
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable, TextInput, Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ArrowLeft, Star, Paperclip, AlertTriangle } from 'lucide-react-native';
import { useFocusEffect } from '@react-navigation/native';
import {
ArrowLeft, Star, Paperclip, AlertTriangle, Search, X, Square, SquareCheck,
Mail as MailIcon, MailOpen, Archive, Trash2, ShieldAlert, ShieldCheck, Folder,
} from 'lucide-react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
import SenderAvatar from '../components/SenderAvatar';
import { fetchUnifiedInbox, type UnifiedEmail } from '../api/unified-inbox';
import { SwipeableRow } from '../components/SwipeableRow';
import {
fetchUnifiedInbox, patchUnifiedKeywords, moveUnifiedEmails, deleteUnifiedEmails, isInUnifiedTrash,
type UnifiedEmail, type UnifiedRole, type CrossView,
} from '../api/unified-inbox';
import { useAccountStore } from '../stores/account-store';
import { useAuthStore } from '../stores/auth-store';
import { useSettingsStore } from '../stores/settings-store';
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import { useLocaleStore } from '../stores/locale-store';
import { formatListDate } from '../lib/date-format';
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type Props = NativeStackScreenProps<RootStackParamList, 'UnifiedInbox'>;
function senderName(email: UnifiedEmail): string {
const PAGE_SIZE = 25;
function senderName(email: UnifiedEmail, showRecipient: boolean): string {
if (showRecipient) {
const to = email.to?.[0];
if (to) return to.name || to.email;
}
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
}
export default function UnifiedInboxScreen({ navigation }: Props) {
function rowKey(e: UnifiedEmail): string {
return `${e.sourceAccountId}:${e.jmapAccountId}:${e.id}`;
}
export default function UnifiedInboxScreen({ navigation, route }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const role: UnifiedRole = route.params?.role ?? 'inbox';
const view: CrossView | undefined = route.params?.view;
const accounts = useAccountStore((s) => s.accounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const dateFormat = useSettingsStore((s) => s.dateFormat);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const includeGroup = useSettingsStore((s) => s.includeGroupInUnified);
const crossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
const deleteAction = useSettingsStore((s) => s.deleteAction);
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const showAvatarsInJunk = useSettingsStore((s) => s.showAvatarsInJunk);
const locale = useLocaleStore((s) => s.locale);
const [emails, setEmails] = React.useState<UnifiedEmail[]>([]);
const [errors, setErrors] = React.useState<Record<string, string>>({});
const [loading, setLoading] = React.useState(true);
const [loadingMore, setLoadingMore] = React.useState(false);
const [opening, setOpening] = React.useState(false);
const [searchInput, setSearchInput] = React.useState('');
const [query, setQuery] = React.useState('');
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const positionsRef = React.useRef<Record<string, number>>({});
const hasMoreRef = React.useRef(false);
const loadSeq = React.useRef(0);
const accountById = React.useMemo(
() => new Map(accounts.map((a) => [a.id, a])),
[accounts],
);
const accountIds = React.useMemo(() => accounts.map((a) => a.id), [accounts]);
// Account-bounded by default (the active account + its shared/group
// folders); every signed-in account only with the cross-account opt-in.
const accountIds = React.useMemo(() => {
if (crossAccount || !activeAccountId) return accounts.map((a) => a.id);
return accounts.some((a) => a.id === activeAccountId) ? [activeAccountId] : accounts.map((a) => a.id);
}, [accounts, activeAccountId, crossAccount]);
const fetchOpts = React.useMemo(
() => ({ includeGroup, query, role, view }),
[includeGroup, query, role, view],
);
const load = React.useCallback(async () => {
const seq = ++loadSeq.current;
setLoading(true);
try {
const result = await fetchUnifiedInbox(accountIds, 25, { includeGroup });
const result = await fetchUnifiedInbox(accountIds, PAGE_SIZE, { ...fetchOpts, positions: {} });
if (seq !== loadSeq.current) return;
positionsRef.current = result.positions;
hasMoreRef.current = result.hasMore;
setEmails(result.emails);
setErrors(result.errors);
} finally {
setLoading(false);
if (seq === loadSeq.current) setLoading(false);
}
}, [accountIds, includeGroup]);
}, [accountIds, fetchOpts]);
const loadMore = React.useCallback(async () => {
if (loading || loadingMore || !hasMoreRef.current) return;
const seq = loadSeq.current;
setLoadingMore(true);
try {
const result = await fetchUnifiedInbox(accountIds, PAGE_SIZE, {
...fetchOpts,
positions: positionsRef.current,
});
if (seq !== loadSeq.current) return;
positionsRef.current = result.positions;
hasMoreRef.current = result.hasMore;
setEmails((prev) => {
const seen = new Set(prev.map(rowKey));
const merged = [...prev];
for (const e of result.emails) if (!seen.has(rowKey(e))) merged.push(e);
return merged.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
});
} finally {
setLoadingMore(false);
}
}, [accountIds, fetchOpts, loading, loadingMore]);
// Reload when the view comes back into focus (a message read or deleted
// in the thread screen is stale otherwise) and whenever the inputs change.
useFocusEffect(
React.useCallback(() => {
void load();
}, [load]),
);
// Search on submit / after a pause (2+ chars), like the folder list.
React.useEffect(() => {
void load();
}, [load]);
const trimmed = searchInput.trim();
if (trimmed === query) return;
if (trimmed.length > 0 && trimmed.length < 2) return;
const id = setTimeout(() => setQuery(trimmed), 600);
return () => clearTimeout(id);
}, [searchInput, query]);
const onOpen = React.useCallback(
(email: UnifiedEmail) => {
@@ -91,79 +178,247 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
[opening, switchAccount, navigation, emails],
);
// ── Actions (routed to each message's own account) ────────────────────
const removeRows = (targets: UnifiedEmail[]) => {
const gone = new Set(targets.map(rowKey));
setEmails((prev) => prev.filter((e) => !gone.has(rowKey(e))));
};
const patchRows = (targets: UnifiedEmail[], patch: Record<string, boolean | null>) => {
const keys = new Set(targets.map(rowKey));
setEmails((prev) => prev.map((e) => {
if (!keys.has(rowKey(e))) return e;
const keywords = { ...e.keywords };
for (const [k, v] of Object.entries(patch)) {
if (v) keywords[k] = true;
else delete keywords[k];
}
return { ...e, keywords };
}));
};
const fail = (err: unknown) => {
Alert.alert(t('email_list.error', 'Error'), err instanceof Error ? err.message : String(err));
void load();
};
const setRead = (targets: UnifiedEmail[], read: boolean) => {
patchRows(targets, { $seen: read ? true : null });
patchUnifiedKeywords(targets, { $seen: read ? true : null }).catch(fail);
};
const setStarred = (targets: UnifiedEmail[], starred: boolean) => {
patchRows(targets, { $flagged: starred ? true : null });
patchUnifiedKeywords(targets, { $flagged: starred ? true : null }).catch(fail);
};
const archive = (targets: UnifiedEmail[]) => {
removeRows(targets);
moveUnifiedEmails(targets, 'archive').catch(fail);
};
const spam = (targets: UnifiedEmail[]) => {
removeRows(targets);
moveUnifiedEmails(targets, role === 'junk' ? 'inbox' : 'junk', {
markRead: role !== 'junk' && deleteAction === 'trash-and-read',
}).catch(fail);
};
const remove = async (targets: UnifiedEmail[]) => {
const permanent = targets.some((e) => isPermanentDelete({
inTrash: role === 'trash' || isInUnifiedTrash(e),
inJunk: role === 'junk',
deleteAction,
permanentlyDeleteJunk,
}));
if (permanent && !(await confirmPermanentDelete(targets.length, t))) return;
removeRows(targets);
deleteUnifiedEmails(targets, {
permanent: deleteAction === 'permanent' || (permanentlyDeleteJunk && role === 'junk'),
markRead: deleteAction === 'trash-and-read',
}).catch(fail);
};
const handleSwipe = (email: UnifiedEmail, action: SwipeAction) => {
switch (action) {
case 'archive': if (role !== 'archive') archive([email]); break;
case 'delete': void remove([email]); break;
case 'spam': if (role !== 'sent' && role !== 'drafts') spam([email]); break;
case 'read': setRead([email], !email.keywords?.$seen); break;
case 'star': setStarred([email], !email.keywords?.$flagged); break;
case 'pin':
patchRows([email], { $pinned: email.keywords?.$pinned ? null : true });
patchUnifiedKeywords([email], { $pinned: email.keywords?.$pinned ? null : true }).catch(fail);
break;
default: break;
}
};
const selectionMode = selected.size > 0;
const selectedEmails = React.useMemo(
() => emails.filter((e) => selected.has(rowKey(e))),
[emails, selected],
);
const toggleSelect = (email: UnifiedEmail) => {
setSelected((prev) => {
const next = new Set(prev);
const key = rowKey(email);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
};
const clearSelection = () => setSelected(new Set());
const allRead = selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.$seen);
const allStarred = selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.$flagged);
const errorCount = Object.keys(errors).length;
const showRecipient = role === 'sent' || role === 'drafts';
const title = view
? t(`sidebar.unified_all_${view === 'all' ? 'mail' : view}`, { all: 'All mail', unread: 'All unread', starred: 'All starred' }[view])
: role === 'inbox'
? t('sidebar.unified_inbox', 'All inboxes')
: t(`sidebar.unified_${role}`, `All ${role}`);
const renderItem = ({ item }: { item: UnifiedEmail }) => {
const acc = accountById.get(item.sourceAccountId);
const unread = !item.keywords?.$seen;
const starred = !!item.keywords?.$flagged;
const isSelected = selected.has(rowKey(item));
return (
<Pressable
onPress={() => onOpen(item)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
<SwipeableRow
leftAction={selectionMode ? 'none' : swipeLeftAction}
rightAction={selectionMode ? 'none' : swipeRightAction}
mode={swipeMode}
context={{ unread, starred, pinned: !!item.keywords?.$pinned, inJunk: role === 'junk' }}
onAction={(action) => handleSwipe(item, action)}
>
{unread && <View style={styles.unreadDot} />}
<View style={styles.avatarWrap}>
<SenderAvatar
name={senderName(item)}
email={item.from?.[0]?.email}
size={componentSizes.avatarMd}
/>
{acc && <View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />}
</View>
<View style={styles.content}>
<View style={styles.line}>
<Text style={[styles.sender, unread && styles.bold]} numberOfLines={1}>
{senderName(item)}
</Text>
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
<Pressable
onPress={() => (selectionMode ? toggleSelect(item) : onOpen(item))}
onLongPress={() => toggleSelect(item)}
delayLongPress={300}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed, isSelected && styles.rowSelected]}
>
{unread && <View style={styles.unreadDot} />}
{selectionMode && (
<View style={styles.checkbox}>
{isSelected ? <SquareCheck size={16} color={c.primary} /> : <Square size={16} color={c.textMuted} />}
</View>
)}
<View style={styles.avatarWrap}>
<SenderAvatar
name={senderName(item, showRecipient)}
email={showRecipient ? item.to?.[0]?.email : item.from?.[0]?.email}
size={componentSizes.avatarMd}
disableImages={role === 'junk' && !showAvatarsInJunk}
/>
{acc && accountIds.length > 1 && (
<View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />
)}
</View>
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
{item.subject || '(no subject)'}
</Text>
<View style={styles.line}>
{acc && (
<Text style={styles.account} numberOfLines={1}>
{acc.email || acc.username}
<View style={styles.content}>
<View style={styles.line}>
<Text style={[styles.sender, unread && styles.bold]} numberOfLines={1}>
{senderName(item, showRecipient)}
</Text>
)}
{item.isShared && (
<View style={styles.sharedBadge}>
<Text style={styles.sharedBadgeText} numberOfLines={1}>
{item.sharedLabel || 'Shared'}
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
</View>
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
{item.subject || '(no subject)'}
</Text>
<View style={styles.line}>
{acc && accountIds.length > 1 && (
<Text style={styles.account} numberOfLines={1}>
{acc.email || acc.username}
</Text>
</View>
)}
)}
{item.isShared && (
<View style={styles.sharedBadge}>
<Text style={styles.sharedBadgeText} numberOfLines={1}>
{item.sharedLabel || t('sidebar.shared', 'Shared')}
</Text>
</View>
)}
{item.sourceFolder && (
<View style={styles.sharedBadge}>
<Folder size={10} color={c.mutedForeground} />
<Text style={styles.sharedBadgeText} numberOfLines={1}>{item.sourceFolder}</Text>
</View>
)}
</View>
{item.preview ? (
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
) : null}
</View>
{item.preview ? (
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
) : null}
</View>
</Pressable>
</Pressable>
</SwipeableRow>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>All inboxes</Text>
<View style={styles.headerBtn}>
{opening ? <ActivityIndicator size="small" color={c.primary} /> : null}
{selectionMode ? (
<View style={styles.header}>
<Pressable onPress={clearSelection} style={styles.headerBtn} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>
{t('context_menu.items_selected', `${selected.size} selected`, { count: selected.size })}
</Text>
<Pressable onPress={() => { setStarred(selectedEmails, !allStarred); clearSelection(); }} style={styles.headerBtn} hitSlop={6}>
<Star size={20} color={allStarred ? c.starred : c.text} fill={allStarred ? c.starred : 'transparent'} />
</Pressable>
<Pressable onPress={() => { setRead(selectedEmails, !allRead); clearSelection(); }} style={styles.headerBtn} hitSlop={6}>
{allRead ? <MailIcon size={20} color={c.text} /> : <MailOpen size={20} color={c.text} />}
</Pressable>
{role !== 'sent' && role !== 'drafts' && (
<Pressable onPress={() => { spam(selectedEmails); clearSelection(); }} style={styles.headerBtn} hitSlop={6}>
{role === 'junk' ? <ShieldCheck size={20} color={c.text} /> : <ShieldAlert size={20} color={c.text} />}
</Pressable>
)}
{role !== 'archive' && (
<Pressable onPress={() => { archive(selectedEmails); clearSelection(); }} style={styles.headerBtn} hitSlop={6}>
<Archive size={20} color={c.text} />
</Pressable>
)}
<Pressable onPress={() => { void remove(selectedEmails).then(clearSelection); }} style={styles.headerBtn} hitSlop={6}>
<Trash2 size={20} color={c.text} />
</Pressable>
</View>
) : (
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
<View style={styles.headerBtn}>
{opening ? <ActivityIndicator size="small" color={c.primary} /> : null}
</View>
</View>
)}
<View style={styles.searchBar}>
<Search size={16} color={c.textMuted} />
<TextInput
style={styles.searchInput}
placeholder={t('email_list.search_placeholder', 'Search mail...')}
placeholderTextColor={c.textMuted}
value={searchInput}
onChangeText={setSearchInput}
onSubmitEditing={() => setQuery(searchInput.trim())}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
/>
{searchInput.length > 0 && (
<Pressable onPress={() => { setSearchInput(''); setQuery(''); }} hitSlop={8}>
<X size={14} color={c.textMuted} />
</Pressable>
)}
</View>
{errorCount > 0 && (
<View style={styles.errorBanner}>
<AlertTriangle size={14} color={c.error} />
<Text style={styles.errorBannerText} numberOfLines={1}>
{errorCount === 1
? '1 account could not be loaded'
: `${errorCount} accounts could not be loaded`}
<Text style={styles.errorBannerText} numberOfLines={2}>
{t('unified_mailbox.accounts_failed', `${errorCount} account(s) could not be loaded`, { count: errorCount })}
{': '}
{Object.values(errors)[0]}
</Text>
</View>
)}
@@ -171,20 +426,26 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
{loading && emails.length === 0 ? (
<View style={styles.center}>
<ActivityIndicator color={c.primary} />
<Text style={styles.loadingText}>Loading all inboxes…</Text>
<Text style={styles.loadingText}>{t('email_list.loading', 'Loading…')}</Text>
</View>
) : emails.length === 0 ? (
<View style={styles.center}>
<Text style={styles.loadingText}>No mail across your accounts</Text>
<Text style={styles.loadingText}>
{query ? t('email_list.no_search_results', 'No emails found') : t('email_list.no_emails', 'No emails')}
</Text>
</View>
) : (
<FlatList
data={emails}
keyExtractor={(item) => `${item.sourceAccountId}:${item.id}`}
keyExtractor={rowKey}
renderItem={renderItem}
ItemSeparatorComponent={() => <View style={styles.separator} />}
refreshing={loading}
onRefresh={() => { void load(); }}
onEndReached={() => { void loadMore(); }}
onEndReachedThreshold={0.3}
ListFooterComponent={loadingMore ? <ActivityIndicator style={{ padding: spacing.md }} color={c.primary} /> : null}
contentContainerStyle={{ paddingBottom: 40 }}
/>
)}
</SafeAreaView>
@@ -207,6 +468,20 @@ function makeStyles(c: ThemePalette) {
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
searchBar: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: spacing.lg,
marginVertical: spacing.sm,
paddingHorizontal: spacing.md,
height: componentSizes.inputHeight,
backgroundColor: c.surface,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
gap: spacing.sm,
},
searchInput: { flex: 1, ...typography.body, color: c.text },
errorBanner: {
flexDirection: 'row',
alignItems: 'center',
@@ -224,8 +499,11 @@ function makeStyles(c: ThemePalette) {
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: c.background,
},
rowPressed: { backgroundColor: c.surfaceHover },
rowSelected: { backgroundColor: c.selection },
checkbox: { width: 16, height: componentSizes.avatarMd, alignItems: 'center', justifyContent: 'center' },
// Same unread indicator as the mailbox list (and the webmail): an 8px
// filled circle in the start gutter (#27). Anchored on the row's *first*
// line — top padding + half an avatar — because centring it on the row
@@ -256,11 +534,14 @@ function makeStyles(c: ThemePalette) {
subject: { ...typography.body, color: c.text },
account: { ...typography.caption, color: c.textMuted },
sharedBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: radius.full,
backgroundColor: c.muted,
maxWidth: 120,
maxWidth: 140,
},
sharedBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
preview: { ...typography.caption, color: c.textMuted },
+15 -5
View File
@@ -12,13 +12,23 @@ export interface KeywordDef {
color: KeywordColor;
}
const DEFAULT_KEYWORDS: KeywordDef[] = [
{ id: 'important', label: 'Important', color: 'red' },
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'personal', label: 'Personal', color: 'green' },
{ id: 'todo', label: 'Todo', color: 'amber' },
// Same ids/labels/colours as the webmail's DEFAULT_KEYWORDS
// (stores/settings-store.ts): the default tags are named after their colour,
// so a message tagged `$label:blue` on one client is the same tag on the
// other. The colour keys match the webmail's KEYWORD_PALETTE base row.
export const DEFAULT_KEYWORDS: KeywordDef[] = [
{ id: 'red', label: 'Red', color: 'red' },
{ id: 'orange', label: 'Orange', color: 'orange' },
{ id: 'yellow', label: 'Yellow', color: 'yellow' },
{ id: 'green', label: 'Green', color: 'green' },
{ id: 'blue', label: 'Blue', color: 'blue' },
{ id: 'purple', label: 'Purple', color: 'purple' },
{ id: 'pink', label: 'Pink', color: 'pink' },
];
/** The colour a tag falls back to when its definition is gone. */
export const FALLBACK_KEYWORD_COLOR: KeywordColor = 'gray';
/**
* JMAP keyword token used on emails for a given keyword id.
* Matches webmail convention: `$label:<id>`.