feat: cross-account moves via blob import, shared-account offline sync, parked failed outbox ops with retry and back-off, archive replay with foldering, localized relative dates

This commit is contained in:
Linus Rath
2026-08-29 18:47:12 +02:00
parent 09c24fa805
commit 0205aa0e1c
10 changed files with 543 additions and 177 deletions
+27 -27
View File
@@ -12,32 +12,32 @@ 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). - 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`. - 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`.
- [x] **Role folders not localized (#404)** — `P3` — `missing` — fixed in PENDING-D - [x] **Role folders not localized (#404)** — `P3` — `missing` — fixed in 6ae21d5
- 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 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). - 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. - 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.
- [x] **Setting to hide the total message count on folders (#498)** — `P3` — `missing` — fixed in PENDING-D - [x] **Setting to hide the total message count on folders (#498)** — `P3` — `missing` — fixed in 6ae21d5
- 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 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`). - 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`. - Fix hint: add `showFolderTotalCount` to settings-store + LayoutSettings toggle; gate `total` in `RowCounts`.
- [x] **Create subfolder / choose parent when creating** — `P3` — `partial` — fixed in PENDING-D - [x] **Create subfolder / choose parent when creating** — `P3` — `partial` — fixed in 6ae21d5
- 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 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`. - 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`. - Fix hint: add a "Parent folder" select (own folders, or the shared account when creating there) to the editor modal and pass `parentId`.
- [ ] **Move folder to another parent (#855) and reorder folders (sortOrder)** — `P3` — `missing` - [ ] **Move folder to another parent (#855) and reorder folders (sortOrder)** — `P3` — `missing` — partial in 6ae21d5: "Move under" picker + parent re-fetch done; deferred: sortOrder reorder UI (no drag-and-drop on mobile yet)
- What WEB does: drag & drop in folder settings and sidebar reparents (`moveMailbox` `stores/email-store.ts:3898-3956`, refetches after reparent per #855) and reorders via `sortOrder` (`reorderMailboxes` `:3859`); folder-settings DnD plan `components/settings/folder-settings.tsx:241-262`. - What WEB does: drag & drop in folder settings and sidebar reparents (`moveMailbox` `stores/email-store.ts:3898-3956`, refetches after reparent per #855) and reorders via `sortOrder` (`reorderMailboxes` `:3859`); folder-settings DnD plan `components/settings/folder-settings.tsx:241-262`.
- What RN does: no UI; `updateMailbox` in `src/api/email.ts:185` accepts `parentId` but not `sortOrder`. Tree sorts by `sortOrder` already (`src/lib/mailbox-tree.ts:70-81`). - What RN does: no UI; `updateMailbox` in `src/api/email.ts:185` accepts `parentId` but not `sortOrder`. Tree sorts by `sortOrder` already (`src/lib/mailbox-tree.ts:70-81`).
- Fix hint: in the rename editor add a "Move under…" picker (own-account folders, excluding descendants) → `updateMailbox(id, { parentId })` then `fetchMailboxes()`; optionally up/down buttons writing `sortOrder`. - Fix hint: in the rename editor add a "Move under…" picker (own-account folders, excluding descendants) → `updateMailbox(id, { parentId })` then `fetchMailboxes()`; optionally up/down buttons writing `sortOrder`.
- [ ] **Assign/clear a folder role, custom folder icons, colorful-icon toggle** — `P3` — `missing` - [ ] **Assign/clear a folder role, custom folder icons, colorful-icon toggle** — `P3` — `missing` — partial in 6ae21d5: role picker + #288 role icons done; deferred: per-folder custom icons and the colorful-icon toggle (cosmetic)
- What WEB does: role dropdown per folder (`components/settings/folder-settings.tsx:366-380`, store `setMailboxRole` `stores/email-store.ts:3830`), per-folder icon picker (`folder-settings.tsx:77`, `folderIcons` setting), `colorfulSidebarIcons` toggle (`layout-settings.tsx:222`). Distinct icons for shared/important/memos/scheduled/snoozed roles (#288, `components/layout/sidebar.tsx:131-160`). - What WEB does: role dropdown per folder (`components/settings/folder-settings.tsx:366-380`, store `setMailboxRole` `stores/email-store.ts:3830`), per-folder icon picker (`folder-settings.tsx:77`, `folderIcons` setting), `colorfulSidebarIcons` toggle (`layout-settings.tsx:222`). Distinct icons for shared/important/memos/scheduled/snoozed roles (#288, `components/layout/sidebar.tsx:131-160`).
- 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. - 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). - 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).
- [x] **Hide the server "Scheduled" role folder when the virtual Scheduled row is shown (#495)** — `P3` — `bugfix-parity` — fixed in PENDING-D - [x] **Hide the server "Scheduled" role folder when the virtual Scheduled row is shown (#495)** — `P3` — `bugfix-parity` — fixed in 6ae21d5
- 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 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. - 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()`. - 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. - 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`. - 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`.
- [x] **Mark folder / folder tree / all folders as read** — `P2` — `missing` — fixed in PENDING-D - [x] **Mark folder / folder tree / all folders as read** — `P2` — `missing` — fixed in 6ae21d5
- 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 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`). - 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()`. - 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. - 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`. - 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`.
- [x] **Tap the unread count of a folder to open it filtered to unread** — `P3` — `missing` — fixed in PENDING-D - [x] **Tap the unread count of a folder to open it filtered to unread** — `P3` — `missing` — fixed in 6ae21d5
- 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 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`). - What RN does: counts are static text (`SidebarDrawer.tsx:68-79`).
- Fix hint: make the unread count pressable → `selectMailbox(id)` then `setFilters({ isUnread: true })`. - Fix hint: make the unread count pressable → `selectMailbox(id)` then `setFilters({ isUnread: true })`.
- [x] **Shared-account header has no folder actions** — `P3` — `missing` — fixed in PENDING-D - [x] **Shared-account header has no folder actions** — `P3` — `missing` — fixed in 6ae21d5
- 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 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`). - 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`. - 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 mailbox / cross-account views
- [x] **Unified scope: account-bounded by default, cross-account opt-in; single-account + group inboxes case** — `P2` — `partial` — fixed in PENDING-D - [x] **Unified scope: account-bounded by default, cross-account opt-in; single-account + group inboxes case** — `P2` — `partial` — fixed in 6ae21d5
- 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 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). - 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`). - 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`).
- [x] **Unified per-role views (All Sent/Drafts/Junk/Archive/Trash) with live counts** — `P3` — `missing` — fixed in PENDING-D - [x] **Unified per-role views (All Sent/Drafts/Junk/Archive/Trash) with live counts** — `P3` — `missing` — fixed in 6ae21d5
- 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 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. - 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. - 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.
- [x] **All mail / Unread / Starred cross views + per-account folder selection** — `P2` — `missing` — fixed in PENDING-D - [x] **All mail / Unread / Starred cross views + per-account folder selection** — `P2` — `missing` — fixed in 6ae21d5
- 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 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. - 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. - 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.
- [x] **Unified list has no pagination ("load more")** — `P2` — `missing` — fixed in PENDING-D - [x] **Unified list has no pagination ("load more")** — `P2` — `missing` — fixed in 6ae21d5
- What WEB does: `loadMoreUnifiedEmails` / cross-view load-more with `position` per account (`stores/email-store.ts:4079-4107`, `1473-1515`). - 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`). - 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. - Fix hint: keep a per-account `position`, add `onEndReached` that re-queries each account at its own offset and merges.
- [x] **Unified search** — `P3` — `missing` — fixed in PENDING-D - [x] **Unified search** — `P3` — `missing` — fixed in 6ae21d5
- 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 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`. - 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. - Fix hint: add the same search bar as `EmailListScreen` and AND `{ text: toWildcardQuery(q) }` into each account's query.
- [x] **No actions on unified rows (swipe, star, read, delete, archive, spam, selection)** — `P2` — `missing` — fixed in PENDING-D - [x] **No actions on unified rows (swipe, star, read, delete, archive, spam, selection)** — `P2` — `missing` — fixed in 6ae21d5
- 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 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. - 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. - 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.
- [x] **Unified list stays stale after acting on a message; just-read mail handling** — `P3` — `partial` — fixed in PENDING-D - [x] **Unified list stays stale after acting on a message; just-read mail handling** — `P3` — `partial` — fixed in 6ae21d5
- 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 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. - 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. - 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). - 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. - 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.
- [x] **Unified fetch re-discovers the session and mailboxes on every refresh** — `P3` — `rn-only-bug` — fixed in PENDING-D - [x] **Unified fetch re-discovers the session and mailboxes on every refresh** — `P3` — `rn-only-bug` — fixed in 6ae21d5
- 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. - 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. - 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. - 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. - 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.
- [x] **Tag view (tap a tag → cross-folder `hasKeyword` list, #175) and tag counts in the drawer** — `P2` — `missing` — fixed in PENDING-D - [x] **Tag view (tap a tag → cross-folder `hasKeyword` list, #175) and tag counts in the drawer** — `P2` — `missing` — fixed in 6ae21d5
- 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 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`). - 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. - 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.
@@ -160,7 +160,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
- What RN does: tapping a draft opens the read-only `EmailThreadScreen`; `Compose` params have no draft/edit mode (`src/navigation/types.ts:7-24`), locale keys `email_viewer.draft_banner`/`edit_draft` exist but are unused. - What RN does: tapping a draft opens the read-only `EmailThreadScreen`; `Compose` params have no draft/edit mode (`src/navigation/types.ts:7-24`), locale keys `email_viewer.draft_banner`/`edit_draft` exist but are unused.
- Fix hint: in `handleRowPress`, if `item.keywords.$draft` (or folder role is drafts) navigate to `Compose` with a `draft: { id, to, cc, bcc, subject, body, blobId }` param; on send, destroy the old draft (composer area). - Fix hint: in `handleRowPress`, if `item.keywords.$draft` (or folder role is drafts) navigate to `Compose` with a `draft: { id, to, cc, bcc, subject, body, blobId }` param; on send, destroy the old draft (composer area).
- [ ] **Permanent delete without confirmation (Trash, `deleteAction: permanent`, junk auto-permanent)** — `P2` — `bugfix-parity` - [ ] **Permanent delete without confirmation (Trash, `deleteAction: permanent`, junk auto-permanent)** — `P2` — `bugfix-parity` — list side fixed in 3194b5e (single, batch, swipe via `src/lib/delete-confirm.ts`); deferred: thread screen `onDelete` belongs to the viewer area — use `confirmPermanentDelete()`/`isPermanentDelete()` from `src/lib/delete-confirm.ts`
- What WEB does: confirm dialog before any permanent destroy, single and batch (`components/mail/mail-app.tsx:2086-2100`, `components/email/email-list.tsx:229-247`). - What WEB does: confirm dialog before any permanent destroy, single and batch (`components/mail/mail-app.tsx:2086-2100`, `components/email/email-list.tsx:229-247`).
- What RN does: `deleteEmail`/`deleteEmailsBatch` destroy immediately when in Trash, when `deleteAction === 'permanent'`, or junk + `permanentlyDeleteJunk` (`src/stores/email-store.ts:1286-1298`, `1449-1458`); the swipe fires without any prompt (`EmailListScreen.tsx:369-378`), and no undo is offered (correctly, `:1322-1324`). - What RN does: `deleteEmail`/`deleteEmailsBatch` destroy immediately when in Trash, when `deleteAction === 'permanent'`, or junk + `permanentlyDeleteJunk` (`src/stores/email-store.ts:1286-1298`, `1449-1458`); the swipe fires without any prompt (`EmailListScreen.tsx:369-378`), and no undo is offered (correctly, `:1322-1324`).
- Fix hint: compute `destroy` in the screen before calling the store (same rule) and show `Alert.alert` with Cancel/Delete; same for the thread screen `onDelete`. - Fix hint: compute `destroy` in the screen before calling the store (same rule) and show `Alert.alert` with Cancel/Delete; same for the thread screen `onDelete`.
@@ -214,7 +214,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
- What RN does: 300 ms debounce into `setSearchQuery` → full `Email/query` + `Email/get` per pause (`src/screens/EmailListScreen.tsx:512-516`); with the `*` wildcard a single letter matches the whole mailbox. - What RN does: 300 ms debounce into `setSearchQuery` → full `Email/query` + `Email/get` per pause (`src/screens/EmailListScreen.tsx:512-516`); with the `*` wildcard a single letter matches the whole mailbox.
- Fix hint: search on `onSubmitEditing` (keep the clear button live), or debounce ≥ 600 ms with a minimum of 2 characters. - Fix hint: search on `onSubmitEditing` (keep the clear button live), or debounce ≥ 600 ms with a minimum of 2 characters.
- [ ] **Date format: no regional date-locale setting; relative strings hard-coded** — `P3` — `partial` - [x] **Date format: no regional date-locale setting; relative strings hard-coded** — `P3` — `partial` — fixed in PENDING-E
- What WEB does: user-selectable regional format (`dateLocale`, changelog 1.7.7) and a preset picker (#331); strings localized. - What WEB does: user-selectable regional format (`dateLocale`, changelog 1.7.7) and a preset picker (#331); strings localized.
- What RN does: `formatListDate` follows the app locale only (`src/lib/date-format.ts:17-31`), "Just now"/"m ago" are English (`:38-41`). - 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. - Fix hint: localize the relative strings via `t()`; add a `dateLocale` select if parity is wanted.
@@ -224,12 +224,12 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
- What RN does: favicons always load, including in Junk (`src/components/SenderAvatar.tsx:37`); no contact-photo lookup. - 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. - Fix hint: pass `disableImages={currentMailbox.role === 'junk' && !showAvatarsInJunk}` into `SenderAvatar`; contact photos optional.
- [ ] **Per-message actions from the list (mobile long-press menu)** — `P3` — `partial` - [x] **Per-message actions from the list (mobile long-press menu)** — `P3` — `partial` — fixed in 3194b5e
- What WEB does: long-press on mobile opens the context menu (reply/forward/move/tag/pin/spam/mark read/copy link) (`components/email/thread-list-item.tsx:181-190`, `components/email/email-context-menu.tsx`). - What WEB does: long-press on mobile opens the context menu (reply/forward/move/tag/pin/spam/mark read/copy link) (`components/email/thread-list-item.tsx:181-190`, `components/email/email-context-menu.tsx`).
- What RN does: long-press enters selection (`EmailListScreen.tsx:98,425`); single-message move/tag/spam need a swipe (two configurable directions) or opening the message. - What RN does: long-press enters selection (`EmailListScreen.tsx:98,425`); single-message move/tag/spam need a swipe (two configurable directions) or opening the message.
- Fix hint: optional — selection mode already exposes most actions; add "spam" to the selection header (see above) and this is close enough. - Fix hint: optional — selection mode already exposes most actions; add "spam" to the selection header (see above) and this is close enough.
- [ ] **Own → shared/group folder move (cross-account move, 1.7.2)** — `P3` — `missing` - [x] **Own → shared/group folder move (cross-account move, 1.7.2)** — `P3` — `missing` — fixed in PENDING-E
- What WEB does: copy+delete via `crossAccountMoveEmails`, drop into shared mailboxes allowed (`stores/email-store.ts:1980-2026`, `2169`). - What WEB does: copy+delete via `crossAccountMoveEmails`, drop into shared mailboxes allowed (`stores/email-store.ts:1980-2026`, `2169`).
- What RN does: refuses with "Messages can only be moved within the same account" (`src/stores/email-store.ts:1183-1186`, `1396-1399`); `MoveSheet` is scoped to the same account so the message is only reachable by a future picker. `MoveSheet` also allows Drafts as a target (`MoveSheet.tsx:102-103`) which WEB excludes (`email-context-menu.tsx:163`). - What RN does: refuses with "Messages can only be moved within the same account" (`src/stores/email-store.ts:1183-1186`, `1396-1399`); `MoveSheet` is scoped to the same account so the message is only reachable by a future picker. `MoveSheet` also allows Drafts as a target (`MoveSheet.tsx:102-103`) which WEB excludes (`email-context-menu.tsx:163`).
- Fix hint: exclude `role === 'drafts'` targets; cross-account move = `Email/get` blob → `Blob/upload` to the owner → `Email/import` → destroy (import helper already exists in `src/api/email.ts:423`). - Fix hint: exclude `role === 'drafts'` targets; cross-account move = `Email/get` blob → `Blob/upload` to the owner → `Email/import` → destroy (import helper already exists in `src/api/email.ts:423`).
@@ -258,29 +258,29 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
### Tags / keywords ### Tags / keywords
- [ ] **Tag definition features: visibility, nesting/parent, reorder, rename with migration, unknown tags** — `P3` — `partial` - [ ] **Tag definition features: visibility, nesting/parent, reorder, rename with migration, unknown tags** — `P3` — `partial` — partial in 3194b5e: unknown `$label:` ids listed/removable in TagSheet; deferred: visibility, nesting, reorder, rename migration (device-local tag model, see native issue #1)
- What WEB does: `visibility: show|unread|hide` (`stores/settings-store.ts:180-190`, `components/settings/keyword-settings.tsx:98-132`), nested ids `parent/child` (`lib/keyword-nesting.ts`, `keyword-settings.tsx:213-243`), drag reorder (atomic, changelog 1.9.0), rename keyword migrates messages (`renameKeyword`/`client.migrateKeyword` `lib/jmap/client.ts:1963`), tag picker lists unknown ids present on a message so they can be removed (`components/email/tag-picker.tsx:484-491`). - What WEB does: `visibility: show|unread|hide` (`stores/settings-store.ts:180-190`, `components/settings/keyword-settings.tsx:98-132`), nested ids `parent/child` (`lib/keyword-nesting.ts`, `keyword-settings.tsx:213-243`), drag reorder (atomic, changelog 1.9.0), rename keyword migrates messages (`renameKeyword`/`client.migrateKeyword` `lib/jmap/client.ts:1963`), tag picker lists unknown ids present on a message so they can be removed (`components/email/tag-picker.tsx:484-491`).
- 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). - 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. - 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.
- [x] **"Reset defaults" wipes the tag list with no confirmation (removed in WEB 1.8.1)** — `P3` — `bugfix-parity` — fixed in PENDING-D - [x] **"Reset defaults" wipes the tag list with no confirmation (removed in WEB 1.8.1)** — `P3` — `bugfix-parity` — fixed in 6ae21d5
- What WEB does: removed the button ("one stray click wiped a carefully built tag list", changelog 1.8.1 Changes). - 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`). - 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. - Fix hint: remove the button or guard with `Alert.alert` confirm.
- [x] **Scan for keywords no local tag explains (#658)** — `P3` — `missing` — fixed in PENDING-D - [x] **Scan for keywords no local tag explains (#658)** — `P3` — `missing` — fixed in 6ae21d5
- 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 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. - 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`. - Fix hint: port `discoverKeywords` (cap 5000 like offline sync) + `findUnrecognizedKeywords` (pure) and an "Add" list in `KeywordSettings`.
- [x] **Keyword palette parity** — `P3` — `partial` — fixed in PENDING-D - [x] **Keyword palette parity** — `P3` — `partial` — fixed in 6ae21d5
- 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 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. - 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. - Fix hint: verify the key sets match; consider aligning the default ids with WEB's.
### Offline cache (RN only, sanity check) ### Offline cache (RN only, sanity check)
- [ ] **Offline sync covers the primary account only; shared folders never cached** — `P3` — `rn-only-bug` - [x] **Offline sync covers the primary account only; shared folders never cached** — `P3` — `rn-only-bug` — fixed in PENDING-E
- What RN does: `runOfflineSync` discovers via `queryEmailsByFilter` which hard-codes `jmapClient.accountId` (`src/lib/offline-sync.ts:55`, `src/api/email.ts:733-747`), so opening a group-folder message offline always fails; `selectMailbox` seeding for a shared folder therefore always yields nothing (`src/stores/email-store.ts:647-666` looks up by raw id, which is correct). - What RN does: `runOfflineSync` discovers via `queryEmailsByFilter` which hard-codes `jmapClient.accountId` (`src/lib/offline-sync.ts:55`, `src/api/email.ts:733-747`), so opening a group-folder message offline always fails; `selectMailbox` seeding for a shared folder therefore always yields nothing (`src/stores/email-store.ts:647-666` looks up by raw id, which is correct).
- Fix hint: iterate `jmapClient.getSharedMailAccounts()` in the sync with `accountIdOverride`, storing the account id in the cache index (`getFullEmails(ids, accountId)` already exists). - Fix hint: iterate `jmapClient.getSharedMailAccounts()` in the sync with `accountIdOverride`, storing the account id in the cache index (`getFullEmails(ids, accountId)` already exists).
+19 -5
View File
@@ -14,9 +14,23 @@ import type { DateFormat, TimeFormat } from '../stores/settings-store';
* *
* `locale` is the language subtag from the locale store (e.g. "en", "de"). * `locale` is the language subtag from the locale store (e.g. "en", "de").
*/ */
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
// Relative strings ("Just now", "5m ago") through the locale catalog when a
// translate function is supplied; the compact English form otherwise.
function relativeLabel(
t: Translate | undefined,
unit: 'minute' | 'hour' | 'day',
count: number,
): string {
if (!t) return `${count}${unit[0]} ago`;
const key = `date.${unit}s_ago${count === 1 ? '' : '_plural'}`;
return t(key, `${count}${unit[0]} ago`, { count });
}
export function formatListDate( export function formatListDate(
date: Date | string, date: Date | string,
opts: { dateFormat: DateFormat; timeFormat: TimeFormat; locale: string }, opts: { dateFormat: DateFormat; timeFormat: TimeFormat; locale: string; t?: Translate },
): string { ): string {
const d = typeof date === 'string' ? new Date(date) : date; const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) return ''; if (isNaN(d.getTime())) return '';
@@ -35,10 +49,10 @@ export function formatListDate(
const minutes = Math.floor(diff / 60000); const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000); const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000); const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Just now'; if (minutes < 1) return opts.t ? opts.t('date.just_now', 'Just now') : 'Just now';
if (minutes < 60) return `${minutes}m ago`; if (minutes < 60) return relativeLabel(opts.t, 'minute', minutes);
if (hours < 24) return `${hours}h ago`; if (hours < 24) return relativeLabel(opts.t, 'hour', hours);
if (days < 7) return `${days}d ago`; if (days < 7) return relativeLabel(opts.t, 'day', days);
return d.toLocaleDateString(intlLocale, { return d.toLocaleDateString(intlLocale, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
+82 -48
View File
@@ -1,11 +1,12 @@
// Background sync that fills the offline mail cache. Runs the discovery // Background sync that fills the offline mail cache. Runs the discovery
// query (Email/query filtered by `after`), then fetches full bodies in // query (Email/query filtered by `after`) for the user's own account and
// every shared (group) account in the session, then fetches full bodies in
// batches, reporting progress to the offline-cache-store so the // batches, reporting progress to the offline-cache-store so the
// OfflineCacheBanner and the Settings screen can show live updates. // OfflineCacheBanner and the Settings screen can show live updates.
import { jmapClient } from '../api/jmap-client'; import { jmapClient } from '../api/jmap-client';
import { queryEmailsByFilter, getFullEmails } from '../api/email'; import { queryEmailsByFilter, getFullEmails } from '../api/email';
import { useOfflineCacheStore } from '../stores/offline-cache-store'; import { useOfflineCacheStore, cacheKey } from '../stores/offline-cache-store';
import type { Email } from '../api/types'; import type { Email } from '../api/types';
// Approximate the on-disk size of a serialised Email so we can show "Y MB // Approximate the on-disk size of a serialised Email so we can show "Y MB
@@ -21,7 +22,7 @@ function approxSize(email: Email): number {
} }
// Hard cap so a misconfigured "30 days" against a noisy account doesn't try // Hard cap so a misconfigured "30 days" against a noisy account doesn't try
// to enumerate 100k messages. // to enumerate 100k messages. Shared accounts share the budget.
const DISCOVERY_LIMIT = 5000; const DISCOVERY_LIMIT = 5000;
// Each Email/get request is bounded by the server's maxObjectsInGet. We // Each Email/get request is bounded by the server's maxObjectsInGet. We
// further chunk to keep individual responses small (a 50-message response // further chunk to keep individual responses small (a 50-message response
@@ -34,6 +35,12 @@ export interface RunOptions {
maxMB?: number; maxMB?: number;
} }
interface SyncTarget {
/** JMAP account override; undefined = the user's own account. */
accountId?: string;
ids: string[];
}
export async function runOfflineSync(opts: RunOptions): Promise<void> { export async function runOfflineSync(opts: RunOptions): Promise<void> {
const cache = useOfflineCacheStore.getState(); const cache = useOfflineCacheStore.getState();
// If a sync is in flight, request its abort and wait for the next caller // If a sync is in flight, request its abort and wait for the next caller
@@ -50,9 +57,12 @@ export async function runOfflineSync(opts: RunOptions): Promise<void> {
const since = new Date(startedAt - opts.days * 24 * 60 * 60 * 1000).toISOString(); const since = new Date(startedAt - opts.days * 24 * 60 * 60 * 1000).toISOString();
let ids: string[] = []; // Own mail first, then each shared/group account. A shared account that
// cannot be reached is skipped rather than failing the whole sync; the
// own-account discovery failing is fatal as before.
const targets: SyncTarget[] = [];
try { try {
ids = await queryEmailsByFilter({ after: since }, DISCOVERY_LIMIT); targets.push({ accountId: undefined, ids: await queryEmailsByFilter({ after: since }, DISCOVERY_LIMIT) });
} catch (err) { } catch (err) {
cache.setSyncState({ cache.setSyncState({
phase: 'error', phase: 'error',
@@ -61,24 +71,46 @@ export async function runOfflineSync(opts: RunOptions): Promise<void> {
}); });
return; return;
} }
let budget = Math.max(0, DISCOVERY_LIMIT - targets[0].ids.length);
for (const acc of jmapClient.getSharedMailAccounts()) {
if (budget <= 0) break;
try {
const ids = await queryEmailsByFilter({ after: since }, budget, acc.id);
targets.push({ accountId: acc.id, ids });
budget -= ids.length;
} catch (err) {
console.warn('[offline-sync] shared account discovery failed:', acc.id, err);
}
}
// Drop entries from the cache that fell out of the lookback window — the // Drop entries from the cache that fell out of the lookback window — the
// user expects the cache to track "the last X days", not grow forever. // user expects the cache to track "the last X days", not grow forever.
const keepSet = new Set(ids); const keepSet = new Set<string>();
const stale = Object.keys(cache.index.entries).filter((id) => !keepSet.has(id)); for (const tgt of targets) for (const id of tgt.ids) keepSet.add(cacheKey(id, tgt.accountId));
if (stale.length > 0) { const staleByAccount = new Map<string | undefined, string[]>();
await cache.remove(stale); for (const entry of Object.values(cache.index.entries)) {
const key = cacheKey(entry.id, entry.jmapAccountId);
if (keepSet.has(key)) continue;
const list = staleByAccount.get(entry.jmapAccountId);
if (list) list.push(entry.id); else staleByAccount.set(entry.jmapAccountId, [entry.id]);
}
for (const [accountId, ids] of staleByAccount) {
if (ids.length > 0) await cache.remove(ids, accountId);
} }
const total = ids.length; const total = targets.reduce((n, tgt) => n + tgt.ids.length, 0);
cache.setSyncState({ phase: 'fetching', total, completed: 0, fetched: 0, bytes: 0 }); cache.setSyncState({ phase: 'fetching', total, completed: 0, fetched: 0, bytes: 0 });
// Skip already-cached ids — bodies on disk are immutable per messageId. // Skip already-cached ids — bodies on disk are immutable per messageId.
const toFetch = ids.filter((id) => !cache.has(id)); const work = targets.map((tgt) => ({
const skipped = total - toFetch.length; accountId: tgt.accountId,
ids: tgt.ids.filter((id) => !cache.has(id, tgt.accountId)),
}));
const toFetchCount = work.reduce((n, tgt) => n + tgt.ids.length, 0);
const skipped = total - toFetchCount;
cache.setSyncState({ completed: skipped }); cache.setSyncState({ completed: skipped });
if (toFetch.length === 0) { if (toFetchCount === 0) {
if (opts.maxMB) await cache.evictToFit(opts.maxMB * 1024 * 1024); if (opts.maxMB) await cache.evictToFit(opts.maxMB * 1024 * 1024);
cache.setSyncState({ cache.setSyncState({
phase: 'done', phase: 'done',
@@ -96,42 +128,44 @@ export async function runOfflineSync(opts: RunOptions): Promise<void> {
let bytes = 0; let bytes = 0;
let completed = skipped; let completed = skipped;
for (let i = 0; i < toFetch.length; i += chunkSize) { for (const tgt of work) {
if (useOfflineCacheStore.getState().consumeAbort()) { for (let i = 0; i < tgt.ids.length; i += chunkSize) {
cache.setSyncState({ if (useOfflineCacheStore.getState().consumeAbort()) {
phase: 'cancelled', cache.setSyncState({
finishedAt: Date.now(), phase: 'cancelled',
}); finishedAt: Date.now(),
return; });
return;
}
const chunk = tgt.ids.slice(i, i + chunkSize);
let emails: Email[];
try {
emails = await getFullEmails(chunk, tgt.accountId);
} catch (err) {
cache.setSyncState({
phase: 'error',
message: err instanceof Error ? err.message : 'Fetch failed',
finishedAt: Date.now(),
});
return;
}
for (const email of emails) {
const size = approxSize(email);
await cache.put(email, size, tgt.accountId);
fetched += 1;
bytes += size;
completed += 1;
}
// Some chunked ids may have been deleted server-side between query and
// fetch — they don't come back. Account for that in the completed count
// so the progress bar still finishes at 100%.
completed += chunk.length - emails.length;
cache.setSyncState({ completed, fetched, bytes });
} }
const chunk = toFetch.slice(i, i + chunkSize);
let emails: Email[];
try {
emails = await getFullEmails(chunk);
} catch (err) {
cache.setSyncState({
phase: 'error',
message: err instanceof Error ? err.message : 'Fetch failed',
finishedAt: Date.now(),
});
return;
}
for (const email of emails) {
const size = approxSize(email);
await cache.put(email, size);
fetched += 1;
bytes += size;
completed += 1;
}
// Some chunked ids may have been deleted server-side between query and
// fetch — they don't come back. Account for that in the completed count
// so the progress bar still finishes at 100%.
completed += chunk.length - emails.length;
cache.setSyncState({ completed, fetched, bytes });
} }
// Trim the cache back under its size cap, shedding the oldest mail. Done // Trim the cache back under its size cap, shedding the oldest mail. Done
+30 -4
View File
@@ -28,6 +28,7 @@ import { useKeywordsStore, type KeywordDef } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store'; import { useLocaleStore } from '../stores/locale-store';
import { useSearchHistoryStore } from '../stores/search-history-store'; import { useSearchHistoryStore } from '../stores/search-history-store';
import { useContactsStore } from '../stores/contacts-store'; import { useContactsStore } from '../stores/contacts-store';
import { useOutboxStore } from '../stores/outbox-store';
import { getContactDisplayName } from '../lib/contact-utils'; import { getContactDisplayName } from '../lib/contact-utils';
import { formatListDate } from '../lib/date-format'; import { formatListDate } from '../lib/date-format';
import { import {
@@ -114,6 +115,7 @@ const EmailRow = React.memo(function EmailRow({
const dateFormat = useSettingsStore((s) => s.dateFormat); const dateFormat = useSettingsStore((s) => s.dateFormat);
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
const locale = useLocaleStore((s) => s.locale); const locale = useLocaleStore((s) => s.locale);
const tr = useLocaleStore((s) => s.t);
const { name: senderName, email: senderEmail } = getCounterpart(item, showRecipient); const { name: senderName, email: senderEmail } = getCounterpart(item, showRecipient);
const unread = isUnread(item); const unread = isUnread(item);
const starred = isStarred(item); const starred = isStarred(item);
@@ -201,7 +203,7 @@ const EmailRow = React.memo(function EmailRow({
<Text style={styles.threadBadgeText}>{threadCount}</Text> <Text style={styles.threadBadgeText}>{threadCount}</Text>
</View> </View>
)} )}
<Text style={[styles.emailDate, dyn.caption]}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text> <Text style={[styles.emailDate, dyn.caption]}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t: tr })}</Text>
</View> </View>
</View> </View>
@@ -290,6 +292,11 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const recentSearches = useSearchHistoryStore((s) => s.recentSearches); const recentSearches = useSearchHistoryStore((s) => s.recentSearches);
const removeRecentSearch = useSearchHistoryStore((s) => s.removeRecentSearch); const removeRecentSearch = useSearchHistoryStore((s) => s.removeRecentSearch);
const contacts = useContactsStore((s) => s.contacts); const contacts = useContactsStore((s) => s.contacts);
// Queued changes the server rejected repeatedly: surfaced with retry/discard
// instead of being dropped silently.
const failedOps = useOutboxStore((s) => s.failed);
const retryFailedOps = useOutboxStore((s) => s.retryFailed);
const discardFailedOps = useOutboxStore((s) => s.discardFailed);
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction); const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction); const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
@@ -1205,6 +1212,21 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<OfflineBanner hint={emails.length > 0 ? t('email_list.showing_cached', 'Showing cached mail') : undefined} /> <OfflineBanner hint={emails.length > 0 ? t('email_list.showing_cached', 'Showing cached mail') : undefined} />
{failedOps.length > 0 && (
<View style={[styles.emptyFolderBanner, { borderColor: c.error }]}>
<Text style={styles.emptyFolderHint} numberOfLines={2}>
{t('email_list.outbox_failed', `${failedOps.length} changes could not be saved to the server.`, { count: failedOps.length })}
{failedOps[0]?.lastError ? ` ${failedOps[0].lastError}` : ''}
</Text>
<Pressable onPress={() => { void retryFailedOps(); }} style={styles.emptyFolderButton} hitSlop={6}>
<Text style={[styles.emptyFolderButtonText, { color: c.primary }]}>{t('common.retry', 'Retry')}</Text>
</Pressable>
<Pressable onPress={discardFailedOps} style={styles.emptyFolderButton} hitSlop={6}>
<Text style={styles.emptyFolderButtonText}>{t('email_list.outbox_discard', 'Discard')}</Text>
</Pressable>
</View>
)}
{/* Email list */} {/* Email list */}
{loading && emails.length === 0 ? ( {loading && emails.length === 0 ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
@@ -1504,16 +1526,20 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
); );
})()} })()}
{/* Every account's folders are offered: a move into another account's
folder is a copy+delete through the blob (webmail 1.7.2). */}
<MoveSheet <MoveSheet
visible={pendingMoveId !== null} visible={pendingMoveId !== null}
onClose={() => setPendingMoveId(null)} onClose={() => setPendingMoveId(null)}
mailboxes={scopedMailboxes} mailboxes={mailboxes}
currentMailboxId={currentMailboxId} currentMailboxId={currentMailboxId}
onPick={(toId) => { onPick={(toId) => {
const id = pendingMoveId; const id = pendingMoveId;
setPendingMoveId(null); setPendingMoveId(null);
if (id && currentMailboxId && toId !== currentMailboxId) { if (id && currentMailboxId && toId !== currentMailboxId) {
void moveToMailboxAction(id, currentMailboxId, toId); const ids = idsForRow(id);
if (ids.length > 1) void moveEmailsToMailbox(ids, toId);
else void moveToMailboxAction(id, currentMailboxId, toId);
} }
}} }}
/> />
@@ -1521,7 +1547,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<MoveSheet <MoveSheet
visible={batchMoveOpen} visible={batchMoveOpen}
onClose={() => setBatchMoveOpen(false)} onClose={() => setBatchMoveOpen(false)}
mailboxes={scopedMailboxes} mailboxes={mailboxes}
currentMailboxId={currentMailboxId} currentMailboxId={currentMailboxId}
onPick={handleBatchMovePick} onPick={handleBatchMovePick}
/> />
+1 -1
View File
@@ -316,7 +316,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
</Text> </Text>
{starred && <Star size={12} color={c.starred} fill={c.starred} />} {starred && <Star size={12} color={c.starred} fill={c.starred} />}
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />} {item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text> <Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t })}</Text>
</View> </View>
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}> <Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
{item.subject || '(no subject)'} {item.subject || '(no subject)'}
+14 -4
View File
@@ -14,6 +14,7 @@ vi.mock('../../api/email', () => ({
getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })), getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })),
getEmailChanges: vi.fn(async () => null), getEmailChanges: vi.fn(async () => null),
getFullEmail: vi.fn(), getFullEmail: vi.fn(),
importEmailBlob: vi.fn(async () => 'imported-1'),
setEmailKeywords: vi.fn(), setEmailKeywords: vi.fn(),
setKeywordsForEmails: vi.fn(), setKeywordsForEmails: vi.fn(),
moveEmail: vi.fn(), moveEmail: vi.fn(),
@@ -33,6 +34,10 @@ vi.mock('../../api/email', () => ({
// locale-store pulls in expo-localization / react-native I18nManager; the // locale-store pulls in expo-localization / react-native I18nManager; the
// store only needs t() for toast labels. // store only needs t() for toast labels.
vi.mock('../../api/blob', () => ({
uploadBytes: vi.fn(async () => ({ blobId: 'blob-new', size: 3, type: 'message/rfc822' })),
}));
vi.mock('../locale-store', () => ({ vi.mock('../locale-store', () => ({
t: (_key: string, fallback?: string) => fallback ?? _key, t: (_key: string, fallback?: string) => fallback ?? _key,
useLocaleStore: { getState: () => ({ locale: 'en', t: (_k: string, f?: string) => f ?? _k }) }, useLocaleStore: { getState: () => ({ locale: 'en', t: (_k: string, f?: string) => f ?? _k }) },
@@ -128,6 +133,7 @@ vi.mock('../../api/jmap-client', () => ({
getMaxObjectsInGet: () => 500, getMaxObjectsInGet: () => 500,
getMaxCallsInRequest: () => 16, getMaxCallsInRequest: () => 16,
getSharedMailAccounts: () => [{ id: 'grp-1', name: 'Support' }], getSharedMailAccounts: () => [{ id: 'grp-1', name: 'Support' }],
fetchBlobArrayBuffer: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
// The keyword-sort polarity probe runs through this; answering // The keyword-sort polarity probe runs through this; answering
// unsupportedSort keeps the sort at the plain receivedAt comparator the // unsupportedSort keeps the sort at the plain receivedAt comparator the
// assertions below expect. // assertions below expect.
@@ -856,19 +862,23 @@ describe('email-store', () => {
expect(useEmailStore.getState().emails).toHaveLength(0); expect(useEmailStore.getState().emails).toHaveLength(0);
}); });
it('refuses to move a message between accounts', async () => { it('moves a message between accounts by copying the blob and destroying the original (1.7.2)', async () => {
const mockImport = emailApi.importEmailBlob as ReturnType<typeof vi.fn>;
const mockDestroy = emailApi.destroyEmails as ReturnType<typeof vi.fn>;
useEmailStore.setState({ useEmailStore.setState({
mailboxes: [ownInbox, sharedInbox], mailboxes: [ownInbox, sharedInbox],
currentMailboxId: 'grp-1:s-inbox', currentMailboxId: 'grp-1:s-inbox',
emails: [{ id: 'e1', keywords: {}, mailboxIds: { 's-inbox': true } } as any], emails: [{ id: 'e1', blobId: 'blob-1', keywords: { $seen: true, $flagged: false }, mailboxIds: { 's-inbox': true } } as any],
}); });
await useEmailStore.getState().moveToMailbox('e1', 'grp-1:s-inbox', 'mb-1'); await useEmailStore.getState().moveToMailbox('e1', 'grp-1:s-inbox', 'mb-1');
expect(mockMoveEmail).not.toHaveBeenCalled(); expect(mockMoveEmail).not.toHaveBeenCalled();
expect(mockImport).toHaveBeenCalledWith('blob-new', 'mb-1', { $seen: true }, undefined);
expect(mockDestroy).toHaveBeenCalledWith(['e1'], 'grp-1');
const state = useEmailStore.getState(); const state = useEmailStore.getState();
expect(state.error).toMatch(/same account/); expect(state.error).toBeNull();
expect(state.emails).toHaveLength(1); expect(state.emails).toHaveLength(0);
}); });
}); });
}); });
+62
View File
@@ -177,3 +177,65 @@ describe('flush', () => {
expect(useOutboxStore.getState().entries).toHaveLength(0); expect(useOutboxStore.getState().entries).toHaveLength(0);
}); });
}); });
describe('failed ops and archive replay', () => {
it('parks a poison op in `failed` instead of dropping it, and retryFailed re-queues it', async () => {
useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
useNetworkStore.setState({ online: true });
setEmailKeywords.mockRejectedValue(new Error('JMAP request failed: 400'));
for (let i = 0; i < 5; i++) await useOutboxStore.getState().flush();
expect(useOutboxStore.getState().entries).toHaveLength(0);
expect(useOutboxStore.getState().failed).toHaveLength(1);
expect(useOutboxStore.getState().failed[0].lastError).toContain('400');
setEmailKeywords.mockResolvedValue(undefined);
await useOutboxStore.getState().retryFailed();
expect(useOutboxStore.getState().failed).toHaveLength(0);
expect(useOutboxStore.getState().entries).toHaveLength(0);
expect(setEmailKeywords).toHaveBeenLastCalledWith('e1', { $seen: true }, undefined);
});
it('discardFailed forgets parked ops', async () => {
useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
useNetworkStore.setState({ online: true });
setEmailKeywords.mockRejectedValue(new Error('JMAP request failed: 400'));
for (let i = 0; i < 5; i++) await useOutboxStore.getState().flush();
expect(useOutboxStore.getState().failed).toHaveLength(1);
useOutboxStore.getState().discardFailed();
expect(useOutboxStore.getState().failed).toHaveLength(0);
});
it('pauses the queue on an authentication failure without counting an attempt', async () => {
useNetworkStore.setState({ online: false });
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
useNetworkStore.setState({ online: true });
const err = new Error('Session expired');
err.name = 'AuthenticationError';
setEmailKeywords.mockRejectedValueOnce(err);
await useOutboxStore.getState().flush();
expect(useOutboxStore.getState().paused).toBe(true);
expect(useOutboxStore.getState().entries).toHaveLength(1);
expect(useOutboxStore.getState().entries[0].attempts).toBe(0);
// A second flush is a no-op while paused; re-attaching the account lifts it.
setEmailKeywords.mockClear();
await useOutboxStore.getState().flush();
expect(setEmailKeywords).not.toHaveBeenCalled();
await useOutboxStore.getState().setAccount(ACCOUNT);
expect(useOutboxStore.getState().paused).toBe(false);
});
it('an archive op coalesces with a pending move of the same message', () => {
useNetworkStore.setState({ online: false });
const store = useOutboxStore.getState();
store.enqueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { trash: true } });
store.enqueue({ kind: 'archive', emailId: 'e1', archiveMailboxId: 'arch', mode: 'year', receivedAt: '2026-01-01T00:00:00Z' });
const entries = useOutboxStore.getState().entries;
expect(entries).toHaveLength(1);
expect(entries[0].op.kind).toBe('archive');
});
});
+86 -37
View File
@@ -27,8 +27,10 @@ import {
searchEmails as apiSearchEmails, searchEmails as apiSearchEmails,
markAsSpam as apiMarkAsSpam, markAsSpam as apiMarkAsSpam,
undoSpam as apiUndoSpam, undoSpam as apiUndoSpam,
destroyEmails as apiDestroyEmails,
unprefixMailboxId, unprefixMailboxId,
} from '../api/email'; } from '../api/email';
import { useNetworkStore } from './network-store';
import { JMAPMethodError } from '../api/jmap-result'; import { JMAPMethodError } from '../api/jmap-result';
import { mailboxesForSiblingOf, findJunkMailbox } from '../lib/mailbox-tree'; import { mailboxesForSiblingOf, findJunkMailbox } from '../lib/mailbox-tree';
import { toWildcardQuery } from '../lib/search-utils'; import { toWildcardQuery } from '../lib/search-utils';
@@ -74,11 +76,15 @@ const provisionRetried = new Set<string>();
// Keep the offline body cache consistent with an optimistic/queued mutation so // Keep the offline body cache consistent with an optimistic/queued mutation so
// re-opening a message while offline shows the change. Fire-and-forget. // re-opening a message while offline shows the change. Fire-and-forget.
function patchCache(id: string, changes: { keywords?: Record<string, boolean>; mailboxIds?: Record<string, boolean> }): void { function patchCache(
void useOfflineCacheStore.getState().patch(id, changes); id: string,
changes: { keywords?: Record<string, boolean>; mailboxIds?: Record<string, boolean> },
accountId?: string,
): void {
void useOfflineCacheStore.getState().patch(id, changes, accountId);
} }
function dropFromCache(ids: string[]): void { function dropFromCache(ids: string[], accountId?: string): void {
void useOfflineCacheStore.getState().remove(ids); void useOfflineCacheStore.getState().remove(ids, accountId);
} }
// Compute an email's full mailboxIds map after removing one mailbox and adding // Compute an email's full mailboxIds map after removing one mailbox and adding
// another — the idempotent target the outbox replays for a move/trash. // another — the idempotent target the outbox replays for a move/trash.
@@ -720,6 +726,7 @@ export const useEmailStore = create<EmailState>()(
seededEmails = await cacheStore.getEmailsInMailbox( seededEmails = await cacheStore.getEmailsInMailbox(
rawMailboxId(state.mailboxes, mailboxId), rawMailboxId(state.mailboxes, mailboxId),
Math.max(limit, 50), Math.max(limit, 50),
refFor(state.mailboxes, mailboxId).accountId,
); );
// The cache returns newest-first; flip for an ascending sort. // The cache returns newest-first; flip for an ascending sort.
if (useSettingsStore.getState().mailSortAscending) seededEmails.reverse(); if (useSettingsStore.getState().mailSortAscending) seededEmails.reverse();
@@ -939,15 +946,15 @@ export const useEmailStore = create<EmailState>()(
// Opportunistically refresh the cached copy so the next offline open // Opportunistically refresh the cached copy so the next offline open
// reflects the latest keywords without needing a full sync. // reflects the latest keywords without needing a full sync.
const cache = useOfflineCacheStore.getState(); const cache = useOfflineCacheStore.getState();
if (cache.has(id)) { if (cache.has(id, accountId)) {
try { try {
const size = JSON.stringify(fresh).length; const size = JSON.stringify(fresh).length;
await cache.put(fresh, size); await cache.put(fresh, size, accountId);
} catch { /* ignore — best-effort refresh */ } } catch { /* ignore — best-effort refresh */ }
} }
return fresh; return fresh;
} catch (err) { } catch (err) {
const cached = await useOfflineCacheStore.getState().get(id); const cached = await useOfflineCacheStore.getState().get(id, accountId);
if (cached) return cached; if (cached) return cached;
throw err; throw err;
} }
@@ -972,7 +979,7 @@ export const useEmailStore = create<EmailState>()(
), ),
...(state.filters.isUnread === true ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), ...(state.filters.isUnread === true ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}),
}); });
patchCache(emailId, { keywords: nextKeywords }); patchCache(emailId, { keywords: nextKeywords }, owner);
}, },
markUnread: async (emailId) => { markUnread: async (emailId) => {
@@ -992,7 +999,7 @@ export const useEmailStore = create<EmailState>()(
), ),
...(state.filters.isUnread === false ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), ...(state.filters.isUnread === false ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}),
}); });
patchCache(emailId, { keywords: rest }); patchCache(emailId, { keywords: rest }, currentAccountId(state));
}, },
toggleStar: async (emailId, starred) => { toggleStar: async (emailId, starred) => {
@@ -1019,7 +1026,7 @@ export const useEmailStore = create<EmailState>()(
? { retainedIds: retain(get().retainedIds, [emailId]) } ? { retainedIds: retain(get().retainedIds, [emailId]) }
: {}), : {}),
}); });
patchCache(emailId, { keywords }); patchCache(emailId, { keywords }, currentAccountId(state));
}, },
togglePin: async (emailId, pinned) => { togglePin: async (emailId, pinned) => {
@@ -1045,7 +1052,7 @@ export const useEmailStore = create<EmailState>()(
e.id === emailId ? { ...e, keywords } : e, e.id === emailId ? { ...e, keywords } : e,
), ),
}); });
patchCache(emailId, { keywords }); patchCache(emailId, { keywords }, currentAccountId(state));
}, },
markSpam: async (emailIds) => { markSpam: async (emailIds) => {
@@ -1092,7 +1099,7 @@ export const useEmailStore = create<EmailState>()(
items, items,
}, },
}); });
for (const e of targets) patchCache(e.id, { mailboxIds: junkTarget, keywords: nextKeywords.get(e.id) }); for (const e of targets) patchCache(e.id, { mailboxIds: junkTarget, keywords: nextKeywords.get(e.id) }, junk.accountId);
}, },
unmarkSpam: async (emailIds) => { unmarkSpam: async (emailIds) => {
@@ -1135,7 +1142,7 @@ export const useEmailStore = create<EmailState>()(
items, items,
}, },
}); });
for (const e of targets) patchCache(e.id, { mailboxIds: inboxTarget, keywords: nextKeywords.get(e.id) }); for (const e of targets) patchCache(e.id, { mailboxIds: inboxTarget, keywords: nextKeywords.get(e.id) }, inbox.accountId);
}, },
moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => { moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => {
@@ -1143,11 +1150,19 @@ export const useEmailStore = create<EmailState>()(
const email = state.emails.find((e) => e.id === emailId); const email = state.emails.find((e) => e.id === emailId);
const from = refFor(state.mailboxes, fromMailboxId); const from = refFor(state.mailboxes, fromMailboxId);
const to = refFor(state.mailboxes, toMailboxId); const to = refFor(state.mailboxes, toMailboxId);
// A single Email/set is scoped to one account, so there's no move between // A single Email/set is scoped to one account: a move between the user's
// the user's own folders and a shared account's (or between two shared // own folders and a shared account's (or between two shared accounts) is
// accounts) — that would be a copy-then-delete across accounts. // a copy-then-delete across accounts (webmail 1.7.2), online only.
if (from.accountId !== to.accountId) { if (from.accountId !== to.accountId) {
set({ error: t('email_list.move_same_account', 'Messages can only be moved within the same account') }); if (!email) return;
try {
await crossAccountMove([email], from, to);
} catch (err) {
set({ error: err instanceof Error ? err.message : t('notifications.move_failed', 'Move failed') });
return;
}
set({ emails: get().emails.filter((e) => e.id !== emailId) });
dropFromCache([emailId], from.accountId);
return; return;
} }
const original = email ? { ...email.mailboxIds } : null; const original = email ? { ...email.mailboxIds } : null;
@@ -1158,7 +1173,7 @@ export const useEmailStore = create<EmailState>()(
() => moveEmail(emailId, from.id, to.id, from.accountId), () => moveEmail(emailId, from.id, to.id, from.accountId),
); );
set({ emails: get().emails.filter((e) => e.id !== emailId) }); set({ emails: get().emails.filter((e) => e.id !== emailId) });
patchCache(emailId, { mailboxIds: target }); patchCache(emailId, { mailboxIds: target }, from.accountId);
if (email && original) { if (email && original) {
const targetName = mailboxPath(get().mailboxes, toMailboxId); const targetName = mailboxPath(get().mailboxes, toMailboxId);
@@ -1199,10 +1214,12 @@ export const useEmailStore = create<EmailState>()(
// op replays as a plain move into Archive. // op replays as a plain move into Archive.
const { queued } = await applyOrQueue( const { queued } = await applyOrQueue(
{ {
kind: 'mailboxes', kind: 'archive',
emailId, emailId,
accountId: archive.accountId, accountId: archive.accountId,
mailboxIds: { [archive.id]: true }, archiveMailboxId: archive.id,
mode,
receivedAt: email.receivedAt,
}, },
() => apiArchiveEmails( () => apiArchiveEmails(
[{ id: email.id, receivedAt: email.receivedAt }], [{ id: email.id, receivedAt: email.receivedAt }],
@@ -1223,7 +1240,7 @@ export const useEmailStore = create<EmailState>()(
items: [{ email, originalMailboxIds: original }], items: [{ email, originalMailboxIds: original }],
}, },
}); });
patchCache(emailId, { mailboxIds: { [archive.id]: true } }); patchCache(emailId, { mailboxIds: { [archive.id]: true } }, archive.accountId);
// Auto-sort modes may have created new year/month folders - refresh the // Auto-sort modes may have created new year/month folders - refresh the
// mailbox list so the sidebar picks them up on the next render. Skip when // mailbox list so the sidebar picks them up on the next render. Skip when
@@ -1263,7 +1280,7 @@ export const useEmailStore = create<EmailState>()(
{ kind: 'destroy', emailId, accountId: trash.accountId }, { kind: 'destroy', emailId, accountId: trash.accountId },
() => apiDeleteEmail(emailId, trash.id, trash.id, trash.accountId), () => apiDeleteEmail(emailId, trash.id, trash.id, trash.accountId),
); );
dropFromCache([emailId]); dropFromCache([emailId], trash.accountId);
} else { } else {
const target = mailboxesAfterMove(email?.mailboxIds, source.id, trash.id); const target = mailboxesAfterMove(email?.mailboxIds, source.id, trash.id);
await applyOrQueue( await applyOrQueue(
@@ -1280,9 +1297,9 @@ export const useEmailStore = create<EmailState>()(
accountId: source.accountId, accountId: source.accountId,
keywords: nextKeywords, keywords: nextKeywords,
}); });
patchCache(emailId, { mailboxIds: target, keywords: nextKeywords }); patchCache(emailId, { mailboxIds: target, keywords: nextKeywords }, source.accountId);
} else { } else {
patchCache(emailId, { mailboxIds: target }); patchCache(emailId, { mailboxIds: target }, source.accountId);
} }
} }
set({ emails: get().emails.filter((e) => e.id !== emailId) }); set({ emails: get().emails.filter((e) => e.id !== emailId) });
@@ -1325,10 +1342,12 @@ export const useEmailStore = create<EmailState>()(
const { queued } = await applyOrQueueBatch( const { queued } = await applyOrQueueBatch(
targets.map((e): OutboxOp => ({ targets.map((e): OutboxOp => ({
kind: 'mailboxes', kind: 'archive',
emailId: e.id, emailId: e.id,
accountId: archive.accountId, accountId: archive.accountId,
mailboxIds: archiveTarget, archiveMailboxId: archive.id,
mode,
receivedAt: e.receivedAt,
})), })),
() => apiArchiveEmails( () => apiArchiveEmails(
targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })), targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
@@ -1352,7 +1371,7 @@ export const useEmailStore = create<EmailState>()(
items, items,
}, },
}); });
for (const e of targets) patchCache(e.id, { mailboxIds: archiveTarget }); for (const e of targets) patchCache(e.id, { mailboxIds: archiveTarget }, archive.accountId);
if (mode !== 'single' && !queued) void get().fetchMailboxes(); if (mode !== 'single' && !queued) void get().fetchMailboxes();
}, },
@@ -1362,13 +1381,21 @@ export const useEmailStore = create<EmailState>()(
if (!currentMailboxId || toMailboxId === currentMailboxId) return; if (!currentMailboxId || toMailboxId === currentMailboxId) return;
const source = refFor(mailboxes, currentMailboxId); const source = refFor(mailboxes, currentMailboxId);
const to = refFor(mailboxes, toMailboxId); const to = refFor(mailboxes, toMailboxId);
// See moveToMailbox: one Email/set can't span two accounts.
if (source.accountId !== to.accountId) {
set({ error: t('email_list.move_same_account', 'Messages can only be moved within the same account') });
return;
}
const targets = emails.filter((e) => emailIds.includes(e.id)); const targets = emails.filter((e) => emailIds.includes(e.id));
if (targets.length === 0) return; if (targets.length === 0) return;
// See moveToMailbox: one Email/set can't span two accounts — copy+delete.
if (source.accountId !== to.accountId) {
try {
await crossAccountMove(targets, source, to);
} catch (err) {
set({ error: err instanceof Error ? err.message : t('notifications.move_failed', 'Move failed') });
return;
}
const moved = new Set(targets.map((e) => e.id));
set({ emails: get().emails.filter((e) => !moved.has(e.id)) });
dropFromCache(targets.map((e) => e.id), source.accountId);
return;
}
const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } })); const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
@@ -1384,7 +1411,7 @@ export const useEmailStore = create<EmailState>()(
const removed = new Set(targets.map((e) => e.id)); const removed = new Set(targets.map((e) => e.id));
for (const e of targets) { for (const e of targets) {
patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, to.id) }); patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, to.id) }, source.accountId);
} }
const targetName = mailboxPath(mailboxes, toMailboxId); const targetName = mailboxPath(mailboxes, toMailboxId);
set({ set({
@@ -1473,12 +1500,12 @@ export const useEmailStore = create<EmailState>()(
} }
}); });
if (toDestroy.length > 0) dropFromCache(toDestroy.map((e) => e.id)); if (toDestroy.length > 0) dropFromCache(toDestroy.map((e) => e.id), trash.accountId);
for (const e of toTrash) { for (const e of toTrash) {
patchCache(e.id, { patchCache(e.id, {
mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, trash.id), mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, trash.id),
...(markReadKeywords.has(e.id) ? { keywords: markReadKeywords.get(e.id) } : {}), ...(markReadKeywords.has(e.id) ? { keywords: markReadKeywords.get(e.id) } : {}),
}); }, source.accountId);
} }
const removed = new Set(targets.map((e) => e.id)); const removed = new Set(targets.map((e) => e.id));
@@ -1530,7 +1557,7 @@ export const useEmailStore = create<EmailState>()(
? { retainedIds: retain(get().retainedIds, updates.map((u) => u.id)) } ? { retainedIds: retain(get().retainedIds, updates.map((u) => u.id)) }
: {}), : {}),
}); });
for (const u of updates) patchCache(u.id, { keywords: u.keywords }); for (const u of updates) patchCache(u.id, { keywords: u.keywords }, owner);
}, },
undoLast: async () => { undoLast: async () => {
@@ -1572,7 +1599,7 @@ export const useEmailStore = create<EmailState>()(
patchCache(it.email.id, { patchCache(it.email.id, {
mailboxIds: it.originalMailboxIds, mailboxIds: it.originalMailboxIds,
...(it.originalKeywords ? { keywords: it.originalKeywords } : {}), ...(it.originalKeywords ? { keywords: it.originalKeywords } : {}),
}); }, entry.accountId);
} }
} catch (err) { } catch (err) {
set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') }); set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') });
@@ -1709,6 +1736,27 @@ export const useEmailStore = create<EmailState>()(
), ),
); );
// Copy-then-delete across accounts (webmail `crossAccountMoveEmails`, 1.7.2):
// download each message's blob from the source account, upload it to the
// target account, Email/import it into the target folder with its keywords,
// then destroy the original. Online only — there is no idempotent replay.
async function crossAccountMove(targets: Email[], from: MailboxRef, to: MailboxRef): Promise<void> {
if (!useNetworkStore.getState().online || !jmapClient.isConnected) {
throw new Error(t('email_list.cross_account_move_offline', 'Moving between accounts needs a connection'));
}
const { uploadBytes } = await import('../api/blob');
for (const e of targets) {
const full = e.blobId ? e : await getFullEmail(e.id, from.accountId);
if (!full.blobId) throw new Error('Message has no blob');
const bytes = await jmapClient.fetchBlobArrayBuffer(full.blobId, undefined, 'message/rfc822', from.accountId);
const { blobId } = await uploadBytes(new Uint8Array(bytes), 'message/rfc822', to.accountId);
const keywords: Record<string, boolean> = {};
for (const [k, v] of Object.entries(e.keywords ?? {})) if (v) keywords[k] = true;
await importEmailBlob(blobId, to.id, keywords, to.accountId);
await apiDestroyEmails([e.id], from.accountId);
}
}
// ── Refresh implementations (wrapped by coalesceRefresh above) ───────── // ── Refresh implementations (wrapped by coalesceRefresh above) ─────────
function retain(current: string[], ids: string[]): string[] { function retain(current: string[], ids: string[]): string[] {
@@ -2047,6 +2095,7 @@ async function refreshEmailsImpl(): Promise<void> {
const cached = await cacheStore.getEmailsInMailbox( const cached = await cacheStore.getEmailsInMailbox(
ref.id, ref.id,
Math.max(limit, 50), Math.max(limit, 50),
ref.accountId,
); );
if (sortAscending) cached.reverse(); if (sortAscending) cached.reverse();
if ( if (
+48 -27
View File
@@ -24,11 +24,20 @@ function entryKey(accountId: string, emailId: string): string {
return `${ENTRY_KEY_PREFIX}${accountId}:${emailId}`; return `${ENTRY_KEY_PREFIX}${accountId}:${emailId}`;
} }
// Index/entry key of a message. JMAP ids are only unique per account, so a
// message from a shared (group) account is namespaced by its owning JMAP
// account id; the user's own mail keeps the bare id (backwards compatible).
export function cacheKey(emailId: string, jmapAccountId?: string): string {
return jmapAccountId ? `${jmapAccountId}:${emailId}` : emailId;
}
export interface OfflineCacheIndexEntry { export interface OfflineCacheIndexEntry {
id: string; id: string;
receivedAt: string; receivedAt: string;
size: number; // approx bytes of cached payload size: number; // approx bytes of cached payload
cachedAt: number; cachedAt: number;
/** Owning JMAP account for a shared/group message; unset for own mail. */
jmapAccountId?: string;
} }
interface CacheIndex { interface CacheIndex {
@@ -66,14 +75,14 @@ interface OfflineCacheState {
// AsyncStorage. Pass null to detach (no reads/writes will hit storage). // AsyncStorage. Pass null to detach (no reads/writes will hit storage).
setAccount: (accountId: string | null) => Promise<void>; setAccount: (accountId: string | null) => Promise<void>;
hydrate: () => Promise<void>; hydrate: () => Promise<void>;
has: (id: string) => boolean; has: (id: string, jmapAccountId?: string) => boolean;
get: (id: string) => Promise<Email | null>; get: (id: string, jmapAccountId?: string) => Promise<Email | null>;
put: (email: Email, approxBytes: number) => Promise<void>; put: (email: Email, approxBytes: number, jmapAccountId?: string) => Promise<void>;
// Shallow-merge a change into a cached email (no-op when not cached). Used to // Shallow-merge a change into a cached email (no-op when not cached). Used to
// keep the cached body consistent with an optimistic/queued mutation so a // keep the cached body consistent with an optimistic/queued mutation so a
// re-open while offline reflects the new keywords / mailboxIds. // re-open while offline reflects the new keywords / mailboxIds.
patch: (id: string, changes: Partial<Pick<Email, 'keywords' | 'mailboxIds'>>) => Promise<void>; patch: (id: string, changes: Partial<Pick<Email, 'keywords' | 'mailboxIds'>>, jmapAccountId?: string) => Promise<void>;
remove: (ids: string[]) => Promise<void>; remove: (ids: string[], jmapAccountId?: string) => Promise<void>;
// Evict oldest-received entries until the cache fits within maxBytes. Keeps // Evict oldest-received entries until the cache fits within maxBytes. Keeps
// the persisted cache from growing without bound on a noisy account. // the persisted cache from growing without bound on a noisy account.
evictToFit: (maxBytes: number) => Promise<void>; evictToFit: (maxBytes: number) => Promise<void>;
@@ -83,7 +92,7 @@ interface OfflineCacheState {
// sorted by `receivedAt` descending. Used by selectMailbox to seed the // sorted by `receivedAt` descending. Used by selectMailbox to seed the
// list when the network is unavailable. Loads each entry from // list when the network is unavailable. Loads each entry from
// AsyncStorage; the index doesn't carry the mailbox set. // AsyncStorage; the index doesn't carry the mailbox set.
getEmailsInMailbox: (mailboxId: string, limit?: number) => Promise<Email[]>; getEmailsInMailbox: (mailboxId: string, limit?: number, jmapAccountId?: string) => Promise<Email[]>;
totalSize: () => number; totalSize: () => number;
totalCount: () => number; totalCount: () => number;
@@ -163,14 +172,15 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
set({ index, hydrated: true }); set({ index, hydrated: true });
}, },
has: (id) => Boolean(get().index.entries[id]), has: (id, jmapAccountId) => Boolean(get().index.entries[cacheKey(id, jmapAccountId)]),
get: async (id) => { get: async (id, jmapAccountId) => {
const state = get(); const state = get();
if (!state.activeAccountId) return null; if (!state.activeAccountId) return null;
if (!state.index.entries[id]) return null; const key = cacheKey(id, jmapAccountId);
if (!state.index.entries[key]) return null;
try { try {
const raw = await AsyncStorage.getItem(entryKey(state.activeAccountId, id)); const raw = await AsyncStorage.getItem(entryKey(state.activeAccountId, key));
if (!raw) return null; if (!raw) return null;
return JSON.parse(raw) as Email; return JSON.parse(raw) as Email;
} catch (err) { } catch (err) {
@@ -179,12 +189,13 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
} }
}, },
put: async (email, approxBytes) => { put: async (email, approxBytes, jmapAccountId) => {
const state = get(); const state = get();
const accountId = state.activeAccountId; const accountId = state.activeAccountId;
if (!accountId) return; if (!accountId) return;
const key = cacheKey(email.id, jmapAccountId);
try { try {
await AsyncStorage.setItem(entryKey(accountId, email.id), JSON.stringify(email)); await AsyncStorage.setItem(entryKey(accountId, key), JSON.stringify(email));
} catch (err) { } catch (err) {
console.warn('[offline-cache] put failed', email.id, err); console.warn('[offline-cache] put failed', email.id, err);
return; return;
@@ -195,11 +206,12 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
if (get().activeAccountId !== accountId) return; if (get().activeAccountId !== accountId) return;
const nextEntries = { const nextEntries = {
...get().index.entries, ...get().index.entries,
[email.id]: { [key]: {
id: email.id, id: email.id,
receivedAt: email.receivedAt, receivedAt: email.receivedAt,
size: approxBytes, size: approxBytes,
cachedAt: Date.now(), cachedAt: Date.now(),
...(jmapAccountId ? { jmapAccountId } : {}),
}, },
}; };
const index = { entries: nextEntries }; const index = { entries: nextEntries };
@@ -207,31 +219,33 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
persistIndex(accountId, index); persistIndex(accountId, index);
}, },
patch: async (id, changes) => { patch: async (id, changes, jmapAccountId) => {
const accountId = get().activeAccountId; const accountId = get().activeAccountId;
if (!accountId || !get().index.entries[id]) return; const key = cacheKey(id, jmapAccountId);
if (!accountId || !get().index.entries[key]) return;
try { try {
const raw = await AsyncStorage.getItem(entryKey(accountId, id)); const raw = await AsyncStorage.getItem(entryKey(accountId, key));
if (!raw) return; if (!raw) return;
const email = JSON.parse(raw) as Email; const email = JSON.parse(raw) as Email;
const updated: Email = { ...email, ...changes }; const updated: Email = { ...email, ...changes };
if (get().activeAccountId !== accountId) return; if (get().activeAccountId !== accountId) return;
await AsyncStorage.setItem(entryKey(accountId, id), JSON.stringify(updated)); await AsyncStorage.setItem(entryKey(accountId, key), JSON.stringify(updated));
} catch (err) { } catch (err) {
console.warn('[offline-cache] patch failed', id, err); console.warn('[offline-cache] patch failed', id, err);
} }
}, },
remove: async (ids) => { remove: async (ids, jmapAccountId) => {
if (ids.length === 0) return; if (ids.length === 0) return;
const accountId = get().activeAccountId; const accountId = get().activeAccountId;
if (!accountId) return; if (!accountId) return;
const keys = ids.map((id) => cacheKey(id, jmapAccountId));
await Promise.all( await Promise.all(
ids.map((id) => AsyncStorage.removeItem(entryKey(accountId, id)).catch(() => undefined)), keys.map((key) => AsyncStorage.removeItem(entryKey(accountId, key)).catch(() => undefined)),
); );
if (get().activeAccountId !== accountId) return; if (get().activeAccountId !== accountId) return;
const next = { ...get().index.entries }; const next = { ...get().index.entries };
for (const id of ids) delete next[id]; for (const key of keys) delete next[key];
const index = { entries: next }; const index = { entries: next };
set({ index }); set({ index });
persistIndex(accountId, index); persistIndex(accountId, index);
@@ -250,13 +264,19 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0; const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0;
return at - bt; return at - bt;
}); });
const toRemove: string[] = []; const toRemove: Array<{ id: string; jmapAccountId?: string }> = [];
for (const entry of sorted) { for (const entry of sorted) {
if (total <= maxBytes) break; if (total <= maxBytes) break;
toRemove.push(entry.id); toRemove.push({ id: entry.id, jmapAccountId: entry.jmapAccountId });
total -= entry.size; total -= entry.size;
} }
if (toRemove.length > 0) await get().remove(toRemove); // Group by namespace so each remove() call addresses the right keys.
const byAccount = new Map<string | undefined, string[]>();
for (const r of toRemove) {
const list = byAccount.get(r.jmapAccountId);
if (list) list.push(r.id); else byAccount.set(r.jmapAccountId, [r.id]);
}
for (const [jmapAccountId, ids] of byAccount) await get().remove(ids, jmapAccountId);
}, },
clearAll: async () => { clearAll: async () => {
@@ -272,13 +292,14 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
persistIndex(accountId, index); persistIndex(accountId, index);
}, },
getEmailsInMailbox: async (mailboxId, limit = 200) => { getEmailsInMailbox: async (mailboxId, limit = 200, jmapAccountId) => {
const state = get(); const state = get();
const accountId = state.activeAccountId; const accountId = state.activeAccountId;
if (!accountId) return []; if (!accountId) return [];
// Sort the index by receivedAt descending first so we don't have to read // Sort the index by receivedAt descending first so we don't have to read
// entries we won't return — the index already carries receivedAt. // entries we won't return — the index already carries receivedAt. Only
const sorted = Object.values(state.index.entries).sort((a, b) => { // the requested namespace (own mail, or one shared account) is scanned.
const sorted = Object.values(state.index.entries).filter((e) => e.jmapAccountId === jmapAccountId).sort((a, b) => {
const at = a.receivedAt ? new Date(a.receivedAt).getTime() : 0; const at = a.receivedAt ? new Date(a.receivedAt).getTime() : 0;
const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0; const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0;
return bt - at; return bt - at;
@@ -287,7 +308,7 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
for (const entry of sorted) { for (const entry of sorted) {
if (matches.length >= limit) break; if (matches.length >= limit) break;
try { try {
const raw = await AsyncStorage.getItem(entryKey(accountId, entry.id)); const raw = await AsyncStorage.getItem(entryKey(accountId, cacheKey(entry.id, entry.jmapAccountId)));
if (!raw) continue; if (!raw) continue;
const email = JSON.parse(raw) as Email; const email = JSON.parse(raw) as Email;
if (email.mailboxIds?.[mailboxId]) { if (email.mailboxIds?.[mailboxId]) {
+174 -24
View File
@@ -1,9 +1,10 @@
// Persistent offline action queue ("outbox") for mail mutations. // Persistent offline action queue ("outbox") for mail mutations.
// //
// Every mutation the app performs on a message reduces to one of three // Every mutation the app performs on a message reduces to one of four
// idempotent primitives: // idempotent primitives:
// - keywords: replace the full keyword map (read/unread, flag, pin, …) // - keywords: replace the full keyword map (read/unread, flag, pin, …)
// - mailboxes: replace the full mailboxIds map (move, archive, trash) // - mailboxes: replace the full mailboxIds map (move, trash)
// - archive: file into Archive with the user's year/month auto-foldering
// - destroy: permanently delete the message // - destroy: permanently delete the message
// //
// Because each primitive assigns the *whole* target state rather than a delta, // Because each primitive assigns the *whole* target state rather than a delta,
@@ -20,15 +21,23 @@
import { create } from 'zustand'; import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { generateUUID } from '../lib/uuid'; import { generateUUID } from '../lib/uuid';
import { isTransientNetworkError } from '../lib/network-error'; import { isTransientNetworkError, isAuthError } from '../lib/network-error';
import { useNetworkStore } from './network-store'; import { useNetworkStore } from './network-store';
import { jmapClient } from '../api/jmap-client'; import { jmapClient } from '../api/jmap-client';
import { setEmailKeywords, setEmailMailboxes, destroyEmails } from '../api/email'; import {
setEmailKeywords, setEmailMailboxes, destroyEmails, archiveEmails, unprefixMailboxId,
} from '../api/email';
import type { ArchiveMode } from './settings-store';
const KEY_PREFIX = 'webmail:outbox:v1:'; const KEY_PREFIX = 'webmail:outbox:v1:';
const FAILED_SUFFIX = ':failed';
// Give up on an op that the server keeps rejecting (a non-transient failure) // Give up on an op that the server keeps rejecting (a non-transient failure)
// after this many attempts so one poison entry can't wedge the whole queue. // after this many attempts so one poison entry can't wedge the whole queue.
const MAX_ATTEMPTS = 5; const MAX_ATTEMPTS = 5;
// Back-off after a transient break (a blip NetInfo never reports, a server
// hiccup): 5 s, 15 s, 45 s, … capped at 5 minutes.
const RETRY_BASE_MS = 5_000;
const RETRY_MAX_MS = 5 * 60_000;
function storageKey(accountId: string): string { function storageKey(accountId: string): string {
return `${KEY_PREFIX}${accountId}`; return `${KEY_PREFIX}${accountId}`;
@@ -40,6 +49,19 @@ function storageKey(accountId: string): string {
export type OutboxOp = export type OutboxOp =
| { kind: 'keywords'; emailId: string; accountId?: string; keywords: Record<string, boolean> } | { kind: 'keywords'; emailId: string; accountId?: string; keywords: Record<string, boolean> }
| { kind: 'mailboxes'; emailId: string; accountId?: string; mailboxIds: Record<string, boolean> } | { kind: 'mailboxes'; emailId: string; accountId?: string; mailboxIds: Record<string, boolean> }
| {
/**
* Archive with the configured year/month auto-foldering re-applied on
* replay (the plain `mailboxes` fallback used to land in the Archive
* root). `archiveMailboxId` is the raw id of the account's Archive.
*/
kind: 'archive';
emailId: string;
accountId?: string;
archiveMailboxId: string;
mode: ArchiveMode;
receivedAt: string;
}
| { kind: 'destroy'; emailId: string; accountId?: string }; | { kind: 'destroy'; emailId: string; accountId?: string };
export interface OutboxEntry { export interface OutboxEntry {
@@ -54,8 +76,16 @@ interface OutboxState {
// Currently-loaded account bucket. null = detached (no reads/writes). // Currently-loaded account bucket. null = detached (no reads/writes).
activeAccountId: string | null; activeAccountId: string | null;
entries: OutboxEntry[]; entries: OutboxEntry[];
/**
* Ops the server rejected repeatedly. They are kept (not dropped silently)
* so the user can retry or discard them; the list view is refreshed when an
* op lands here so the optimistic edit is reverted.
*/
failed: OutboxEntry[];
hydrated: boolean; hydrated: boolean;
flushing: boolean; flushing: boolean;
/** Set after an authentication failure; cleared by setAccount()/login. */
paused: boolean;
setAccount: (accountId: string | null) => Promise<void>; setAccount: (accountId: string | null) => Promise<void>;
hydrate: () => Promise<void>; hydrate: () => Promise<void>;
@@ -63,6 +93,9 @@ interface OutboxState {
count: () => number; count: () => number;
pendingForEmail: (emailId: string) => OutboxEntry[]; pendingForEmail: (emailId: string) => OutboxEntry[];
flush: () => Promise<void>; flush: () => Promise<void>;
/** Move every failed op back into the queue and flush. */
retryFailed: () => Promise<void>;
discardFailed: () => void;
clear: () => Promise<void>; clear: () => Promise<void>;
} }
@@ -72,9 +105,15 @@ function persist(accountId: string, entries: OutboxEntry[]): void {
}); });
} }
async function load(accountId: string): Promise<OutboxEntry[]> { function persistFailed(accountId: string, failed: OutboxEntry[]): void {
void AsyncStorage.setItem(storageKey(accountId) + FAILED_SUFFIX, JSON.stringify(failed)).catch((err) => {
console.warn('[outbox] persist failed-list failed', err);
});
}
async function load(accountId: string, suffix = ''): Promise<OutboxEntry[]> {
try { try {
const raw = await AsyncStorage.getItem(storageKey(accountId)); const raw = await AsyncStorage.getItem(storageKey(accountId) + suffix);
if (raw) { if (raw) {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed as OutboxEntry[]; if (Array.isArray(parsed)) return parsed as OutboxEntry[];
@@ -91,29 +130,88 @@ async function runOp(op: OutboxOp): Promise<void> {
return setEmailKeywords(op.emailId, op.keywords, op.accountId); return setEmailKeywords(op.emailId, op.keywords, op.accountId);
case 'mailboxes': case 'mailboxes':
return setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId); return setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId);
case 'archive': {
// The foldering needs the account's current folder list; read it from
// the email store lazily (it imports this module, so no static import).
const { useEmailStore } = await import('./email-store');
const all = useEmailStore.getState().mailboxes;
const scoped = all
.filter((m) => (op.accountId ? m.isShared && m.accountId === op.accountId : !m.isShared))
.map((m) => (m.isShared
? { ...m, id: m.originalId ?? m.id, parentId: m.parentId ? unprefixMailboxId(m.parentId, m.accountId) : m.parentId }
: m));
await archiveEmails(
[{ id: op.emailId, receivedAt: op.receivedAt }],
op.archiveMailboxId,
op.mode,
scoped,
op.accountId,
);
return;
}
case 'destroy': case 'destroy':
return destroyEmails([op.emailId], op.accountId); return destroyEmails([op.emailId], op.accountId);
} }
} }
// A move-like op replaces the whole mailboxIds map; `mailboxes` and `archive`
// therefore coalesce with each other (the latest wins).
function opFamily(op: OutboxOp): string {
return op.kind === 'archive' ? 'mailboxes' : op.kind;
}
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryAttempt = 0;
function clearRetry(): void {
if (retryTimer) clearTimeout(retryTimer);
retryTimer = null;
}
function scheduleRetry(): void {
clearRetry();
const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 3 ** retryAttempt);
retryAttempt += 1;
retryTimer = setTimeout(() => {
retryTimer = null;
void useOutboxStore.getState().flush();
}, delay);
}
// Revert the optimistic edit the dropped op stood for: re-read the list.
async function refreshAfterDrop(): Promise<void> {
try {
const { useEmailStore } = await import('./email-store');
const store = useEmailStore.getState();
await Promise.all([store.fetchMailboxes(), store.refreshEmails()]);
} catch (err) {
console.warn('[outbox] refresh after drop failed', err);
}
}
export const useOutboxStore = create<OutboxState>((set, get) => ({ export const useOutboxStore = create<OutboxState>((set, get) => ({
activeAccountId: null, activeAccountId: null,
entries: [], entries: [],
failed: [],
hydrated: false, hydrated: false,
flushing: false, flushing: false,
paused: false,
setAccount: async (accountId) => { setAccount: async (accountId) => {
const state = get(); const state = get();
if (state.activeAccountId === accountId) { if (state.activeAccountId === accountId) {
if (!state.hydrated) await get().hydrate(); if (!state.hydrated) await get().hydrate();
set({ paused: false });
return; return;
} }
set({ activeAccountId: accountId, entries: [], hydrated: false }); clearRetry();
retryAttempt = 0;
set({ activeAccountId: accountId, entries: [], failed: [], hydrated: false, paused: false });
if (accountId) { if (accountId) {
const entries = await load(accountId); const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]);
// Re-check in case another setAccount raced past us. // Re-check in case another setAccount raced past us.
if (get().activeAccountId !== accountId) return; if (get().activeAccountId !== accountId) return;
set({ entries, hydrated: true }); set({ entries, failed, hydrated: true });
} else { } else {
set({ hydrated: true }); set({ hydrated: true });
} }
@@ -127,9 +225,9 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
set({ hydrated: true }); set({ hydrated: true });
return; return;
} }
const entries = await load(accountId); const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]);
if (get().activeAccountId !== accountId) return; if (get().activeAccountId !== accountId) return;
set({ entries, hydrated: true }); set({ entries, failed, hydrated: true });
}, },
enqueue: (op) => { enqueue: (op) => {
@@ -152,10 +250,10 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
} else { } else {
// A queued destroy wins; further edits to a doomed message are pointless. // A queued destroy wins; further edits to a doomed message are pointless.
if (entries.some((e) => sameMessage(e.op) && e.op.kind === 'destroy')) return; if (entries.some((e) => sameMessage(e.op) && e.op.kind === 'destroy')) return;
// Coalesce: replace any pending op of the same kind for this message // Coalesce: replace any pending op of the same family for this message
// (full-state replace makes the latest one authoritative). Keep its // (full-state replace makes the latest one authoritative). Keep its
// position so creation order is preserved for replay. // position so creation order is preserved for replay.
const idx = entries.findIndex((e) => sameMessage(e.op) && e.op.kind === op.kind); const idx = entries.findIndex((e) => sameMessage(e.op) && opFamily(e.op) === opFamily(op));
if (idx >= 0) { if (idx >= 0) {
entries[idx] = { ...entries[idx], op, attempts: 0, lastError: undefined }; entries[idx] = { ...entries[idx], op, attempts: 0, lastError: undefined };
} else { } else {
@@ -172,7 +270,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
pendingForEmail: (emailId) => get().entries.filter((e) => e.op.emailId === emailId), pendingForEmail: (emailId) => get().entries.filter((e) => e.op.emailId === emailId),
flush: async () => { flush: async () => {
if (get().flushing) return; if (get().flushing || get().paused) return;
const accountId = get().activeAccountId; const accountId = get().activeAccountId;
if (!accountId) return; if (!accountId) return;
if (get().entries.length === 0) return; if (get().entries.length === 0) return;
@@ -181,6 +279,8 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
if (!useNetworkStore.getState().online) return; if (!useNetworkStore.getState().online) return;
set({ flushing: true }); set({ flushing: true });
let brokeTransient = false;
let dropped = false;
try { try {
// Process oldest-first so dependent moves replay in the order they were // Process oldest-first so dependent moves replay in the order they were
// made. We snapshot the order but re-read the live list each iteration, // made. We snapshot the order but re-read the live list each iteration,
@@ -198,19 +298,29 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
try { try {
await runOp(entry.op); await runOp(entry.op);
removeEntry(accountId, entry.id); removeEntry(accountId, entry.id);
retryAttempt = 0;
} catch (err) { } catch (err) {
if (get().activeAccountId !== accountId) break; if (get().activeAccountId !== accountId) break;
if (isTransientNetworkError(err)) { if (isAuthError(err)) {
// Connectivity blip — stop and let the next flush retry from here. // Revoked password / expired session: nothing will succeed until
// the user signs in again. Keep the queue and stop trying.
recordError(accountId, entry.id, err, false); recordError(accountId, entry.id, err, false);
set({ paused: true });
break; break;
} }
// Server rejected it. Count the attempt; drop once we've given up so if (isTransientNetworkError(err)) {
// one bad op can't block everything behind it. // Connectivity blip — stop and retry with back-off.
recordError(accountId, entry.id, err, false);
brokeTransient = true;
break;
}
// Server rejected it. Count the attempt; park it in `failed` once
// we've given up so one bad op can't block everything behind it.
const attempts = (entry.attempts ?? 0) + 1; const attempts = (entry.attempts ?? 0) + 1;
if (attempts >= MAX_ATTEMPTS) { if (attempts >= MAX_ATTEMPTS) {
console.warn('[outbox] dropping op after repeated failures', entry.op.kind, err); console.warn('[outbox] parking op after repeated failures', entry.op.kind, err);
removeEntry(accountId, entry.id); moveToFailed(accountId, entry.id, err);
dropped = true;
} else { } else {
recordError(accountId, entry.id, err, true); recordError(accountId, entry.id, err, true);
} }
@@ -219,13 +329,38 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
} finally { } finally {
set({ flushing: false }); set({ flushing: false });
} }
if (brokeTransient && get().entries.length > 0) scheduleRetry();
if (dropped) void refreshAfterDrop();
},
retryFailed: async () => {
const accountId = get().activeAccountId;
if (!accountId) return;
const failed = get().failed;
if (failed.length === 0) return;
const entries = [
...get().entries,
...failed.map((e) => ({ ...e, attempts: 0, lastError: undefined })),
];
set({ entries, failed: [], paused: false });
persist(accountId, entries);
persistFailed(accountId, []);
await get().flush();
},
discardFailed: () => {
const accountId = get().activeAccountId;
set({ failed: [] });
if (accountId) persistFailed(accountId, []);
}, },
clear: async () => { clear: async () => {
const accountId = get().activeAccountId; const accountId = get().activeAccountId;
set({ entries: [] }); clearRetry();
set({ entries: [], failed: [] });
if (accountId) { if (accountId) {
await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined); await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined);
await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined);
} }
}, },
})); }));
@@ -240,6 +375,20 @@ function removeEntry(accountId: string, id: string): void {
persist(accountId, entries); persist(accountId, entries);
} }
function moveToFailed(accountId: string, id: string, err: unknown): void {
const store = useOutboxStore.getState();
if (store.activeAccountId !== accountId) return;
const entry = store.entries.find((e) => e.id === id);
const entries = store.entries.filter((e) => e.id !== id);
const message = err instanceof Error ? err.message : String(err);
const failed = entry
? [...store.failed, { ...entry, attempts: (entry.attempts ?? 0) + 1, lastError: message }]
: store.failed;
useOutboxStore.setState({ entries, failed });
persist(accountId, entries);
persistFailed(accountId, failed);
}
function recordError(accountId: string, id: string, err: unknown, incrementAttempt: boolean): void { function recordError(accountId: string, id: string, err: unknown, incrementAttempt: boolean): void {
const store = useOutboxStore.getState(); const store = useOutboxStore.getState();
if (store.activeAccountId !== accountId) return; if (store.activeAccountId !== accountId) return;
@@ -263,15 +412,16 @@ export interface ApplyResult {
// Run a batch of ops now when we're online and nothing is already queued for // Run a batch of ops now when we're online and nothing is already queued for
// any of the affected messages; otherwise queue them for later replay. An // any of the affected messages; otherwise queue them for later replay. An
// optional `onlineRun` lets callers keep a richer single-round-trip online // optional `onlineRun` lets callers keep a richer single-round-trip online
// path (e.g. batch move, or archive's year/month auto-foldering) while still // path (e.g. batch move) while still degrading to the idempotent primitives
// degrading to the idempotent primitives offline. // offline — which replay with the same semantics (an `archive` op re-applies
// the year/month foldering on replay).
export async function applyOrQueueBatch( export async function applyOrQueueBatch(
ops: OutboxOp[], ops: OutboxOp[],
onlineRun?: () => Promise<void>, onlineRun?: () => Promise<void>,
): Promise<ApplyResult> { ): Promise<ApplyResult> {
if (ops.length === 0) return { queued: false }; if (ops.length === 0) return { queued: false };
const store = useOutboxStore.getState(); const store = useOutboxStore.getState();
const online = useNetworkStore.getState().online && jmapClient.isConnected; const online = useNetworkStore.getState().online && jmapClient.isConnected && !store.paused;
const hasQueued = ops.some((op) => const hasQueued = ops.some((op) =>
store.entries.some((e) => e.op.emailId === op.emailId), store.entries.some((e) => e.op.emailId === op.emailId),
); );