diff --git a/docs/parity/02-mail-list-folders.md b/docs/parity/02-mail-list-folders.md index da5e42ca..5aa262f0 100644 --- a/docs/parity/02-mail-list-folders.md +++ b/docs/parity/02-mail-list-folders.md @@ -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). - 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 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. -- [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 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`. -- [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 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`. -- [ ] **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 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`. -- [ ] **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 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). -- [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 RN does: drawer always renders the virtual "Scheduled" quick row (`src/components/SidebarDrawer.tsx:447-453`) and would also list a Stalwart `role: scheduled` mailbox as a plain folder. - Fix hint: skip nodes with `role === 'scheduled'` in `buildMailboxTree`/drawer when `jmapClient.hasDelayedSend()`. @@ -47,7 +47,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: nothing — the only way is select-all-on-page + delete, page by page. - Fix hint: add `emptyMailbox(mailboxId, accountId?)` to `src/api/email.ts` mirroring the WEB loop (stop when `found.length === 0 || destroyed === 0 || found.length < batch`), a confirm `Alert`, a button in the list header when `currentMailbox.role` is trash/junk, then `refreshEmails()` + `fetchMailboxes()` and `dropFromCache`. -- [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 RN does: only per-message and batch-on-loaded-page mark read (`EmailListScreen.tsx:456-464`). - Fix hint: long-press on a drawer folder row → action sheet (Mark all read / Empty / Rename / Delete / New subfolder); api `markMailboxAsRead(rawId, accountId)` with the paged loop; then `fetchMailboxes()` and, if current, `refreshEmails()`. @@ -62,49 +62,49 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: every push event awaits `fetchMailboxes()` then `refreshEmails()` (`src/stores/email-store.ts:981-1015`); overlapping pushes, the mount effects (`EmailListScreen.tsx:554-567`) and `archiveEmail`'s follow-up `fetchMailboxes` can all run in parallel; a `RateLimitError` (`src/api/jmap-client.ts:436-440`) is only logged. The tree itself is preserved on failure (`email-store.ts:604-612`), so this is perf/noise rather than data loss. - Fix hint: wrap `fetchMailboxes`/`refreshEmails` bodies in a small in-flight map keyed by `activeAccountId` with a single queued re-run, like WEB's `coalesceRefresh`. -- [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 RN does: counts are static text (`SidebarDrawer.tsx:68-79`). - 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 RN does: `FolderSettings` manages own folders only (`src/components/settings/FolderSettings.tsx:47-50`); the account node in the drawer only expands/collapses (`SidebarDrawer.tsx:499-503`). - Fix hint: after the subfolder/move work, add an account picker (own + shared accounts the user `mayCreateChild` in) to the folder editor and pass `accountIdOverride`. ### Unified mailbox / cross-account views -- [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 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`). -- [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 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. -- [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 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. -- [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 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. -- [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 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. -- [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 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. -- [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 RN does: `UnifiedInboxScreen` loads once per mount/`includeGroup` change (`:57-59`); after opening (mark read) or deleting in the thread screen and going back, the unread dot/row is stale until pull-to-refresh. No push hookup. - Fix hint: `useFocusEffect` → reload, or patch the local list from the thread screen result; when Unread/Starred views are added, keep the WEB retain semantics. @@ -114,7 +114,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: `UnifiedInboxScreen.onOpen` switches account then navigates to `EmailThread` (`src/screens/UnifiedInboxScreen.tsx:61-86`). `EmailThreadScreen` pages over the *active folder's* `emails` (`src/screens/EmailThreadScreen.tsx:131-133`, FlatList `data={emails}` `:538`, `initialScrollIndex={Math.max(0, findIndex)}`). A group-inbox message (or any message not in the first page of the user's own inbox snapshot) is not in `emails`, so index 0 is shown: the pane renders `emails[0]` while the toolbar/`activeEmailId` (and delete/archive/spam) refer to the message that was tapped; with an empty snapshot the body area is blank. Bare-id `findIndex` also collides across accounts (Stalwart reuses id ranges, #847). - Fix hint: when `route.params.emailId` is not found in `emails` (or `jmapAccountId` is set), page over a one-element list `[{ id, threadId }]` instead of `emails`; compare ids together with the owning account. -- [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. - Fix hint: cache `apiUrl`/`primaryJmapId`/inbox id per account (keyed by serverUrl+username) for the session; use the live `jmapClient` + existing inbox snapshot for the active account. @@ -130,7 +130,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: `EmailRow` shows no tags; `tagPill/tagDot/tagText` styles exist but are unused (`src/screens/EmailListScreen.tsx:1481-1495`). Tags are only visible inside the thread screen tag menu. - Fix hint: compute tag ids from `item.keywords` (`$label:*`, `$color:*`), look up `useKeywordsStore().keywords` for label/colour (unknown ids → grey with raw id), render pills on the subject row; add a `tintListRowsByTag` setting if desired. -- [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:')` across all folders (`stores/email-store.ts:1343-1420`, changelog 1.4.13 #175), counts kept in step with read/unread (1.7.8). - What RN does: no tag section in `SidebarDrawer`; `EmailFilters` has no keyword field (`src/stores/email-store.ts:140-149`). - Fix hint: add `keyword?: string` to `EmailFilters` and a "Tags" section in the drawer; when set, `buildJmapFilter` should omit `inMailbox` (search across folders) and add `{ hasKeyword: '$label:' }`; counts via one `Email/query` (calculateTotal) per tag, optional. @@ -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. - 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 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`. @@ -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. - 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 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. @@ -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. - 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 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. -- [ ] **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 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`). @@ -258,29 +258,29 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou ### 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 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. -- [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 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. -- [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 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`. -- [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 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. ### 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). - Fix hint: iterate `jmapClient.getSharedMailAccounts()` in the sync with `accountIdOverride`, storing the account id in the cache index (`getFullEmails(ids, accountId)` already exists). diff --git a/src/lib/date-format.ts b/src/lib/date-format.ts index 7db262c2..e6dc6c7f 100644 --- a/src/lib/date-format.ts +++ b/src/lib/date-format.ts @@ -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"). */ +type Translate = (key: string, fallback?: string, params?: Record) => 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( date: Date | string, - opts: { dateFormat: DateFormat; timeFormat: TimeFormat; locale: string }, + opts: { dateFormat: DateFormat; timeFormat: TimeFormat; locale: string; t?: Translate }, ): string { const d = typeof date === 'string' ? new Date(date) : date; if (isNaN(d.getTime())) return ''; @@ -35,10 +49,10 @@ export function formatListDate( const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); - if (minutes < 1) return 'Just now'; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) return `${hours}h ago`; - if (days < 7) return `${days}d ago`; + if (minutes < 1) return opts.t ? opts.t('date.just_now', 'Just now') : 'Just now'; + if (minutes < 60) return relativeLabel(opts.t, 'minute', minutes); + if (hours < 24) return relativeLabel(opts.t, 'hour', hours); + if (days < 7) return relativeLabel(opts.t, 'day', days); return d.toLocaleDateString(intlLocale, { month: 'short', day: 'numeric', diff --git a/src/lib/offline-sync.ts b/src/lib/offline-sync.ts index a675382f..b8d3868a 100644 --- a/src/lib/offline-sync.ts +++ b/src/lib/offline-sync.ts @@ -1,11 +1,12 @@ // 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 // OfflineCacheBanner and the Settings screen can show live updates. import { jmapClient } from '../api/jmap-client'; 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'; // 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 -// to enumerate 100k messages. +// to enumerate 100k messages. Shared accounts share the budget. const DISCOVERY_LIMIT = 5000; // Each Email/get request is bounded by the server's maxObjectsInGet. We // further chunk to keep individual responses small (a 50-message response @@ -34,6 +35,12 @@ export interface RunOptions { maxMB?: number; } +interface SyncTarget { + /** JMAP account override; undefined = the user's own account. */ + accountId?: string; + ids: string[]; +} + export async function runOfflineSync(opts: RunOptions): Promise { const cache = useOfflineCacheStore.getState(); // 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 { 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 { - ids = await queryEmailsByFilter({ after: since }, DISCOVERY_LIMIT); + targets.push({ accountId: undefined, ids: await queryEmailsByFilter({ after: since }, DISCOVERY_LIMIT) }); } catch (err) { cache.setSyncState({ phase: 'error', @@ -61,24 +71,46 @@ export async function runOfflineSync(opts: RunOptions): Promise { }); 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 // user expects the cache to track "the last X days", not grow forever. - const keepSet = new Set(ids); - const stale = Object.keys(cache.index.entries).filter((id) => !keepSet.has(id)); - if (stale.length > 0) { - await cache.remove(stale); + const keepSet = new Set(); + for (const tgt of targets) for (const id of tgt.ids) keepSet.add(cacheKey(id, tgt.accountId)); + const staleByAccount = new Map(); + 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 }); // Skip already-cached ids — bodies on disk are immutable per messageId. - const toFetch = ids.filter((id) => !cache.has(id)); - const skipped = total - toFetch.length; + const work = targets.map((tgt) => ({ + 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 }); - if (toFetch.length === 0) { + if (toFetchCount === 0) { if (opts.maxMB) await cache.evictToFit(opts.maxMB * 1024 * 1024); cache.setSyncState({ phase: 'done', @@ -96,42 +128,44 @@ export async function runOfflineSync(opts: RunOptions): Promise { let bytes = 0; let completed = skipped; - for (let i = 0; i < toFetch.length; i += chunkSize) { - if (useOfflineCacheStore.getState().consumeAbort()) { - cache.setSyncState({ - phase: 'cancelled', - finishedAt: Date.now(), - }); - return; + for (const tgt of work) { + for (let i = 0; i < tgt.ids.length; i += chunkSize) { + if (useOfflineCacheStore.getState().consumeAbort()) { + cache.setSyncState({ + phase: 'cancelled', + finishedAt: Date.now(), + }); + 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 diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index ef556ca9..ac36be12 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -28,6 +28,7 @@ import { useKeywordsStore, type KeywordDef } from '../stores/keywords-store'; import { useLocaleStore } from '../stores/locale-store'; import { useSearchHistoryStore } from '../stores/search-history-store'; import { useContactsStore } from '../stores/contacts-store'; +import { useOutboxStore } from '../stores/outbox-store'; import { getContactDisplayName } from '../lib/contact-utils'; import { formatListDate } from '../lib/date-format'; import { @@ -114,6 +115,7 @@ const EmailRow = React.memo(function EmailRow({ const dateFormat = useSettingsStore((s) => s.dateFormat); const timeFormat = useSettingsStore((s) => s.timeFormat); const locale = useLocaleStore((s) => s.locale); + const tr = useLocaleStore((s) => s.t); const { name: senderName, email: senderEmail } = getCounterpart(item, showRecipient); const unread = isUnread(item); const starred = isStarred(item); @@ -201,7 +203,7 @@ const EmailRow = React.memo(function EmailRow({ {threadCount} )} - {formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })} + {formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t: tr })} @@ -290,6 +292,11 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL const recentSearches = useSearchHistoryStore((s) => s.recentSearches); const removeRecentSearch = useSearchHistoryStore((s) => s.removeRecentSearch); 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 swipeRightAction = useSettingsStore((s) => s.swipeRightAction); @@ -1205,6 +1212,21 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL 0 ? t('email_list.showing_cached', 'Showing cached mail') : undefined} /> + {failedOps.length > 0 && ( + + + {t('email_list.outbox_failed', `${failedOps.length} changes could not be saved to the server.`, { count: failedOps.length })} + {failedOps[0]?.lastError ? ` ${failedOps[0].lastError}` : ''} + + { void retryFailedOps(); }} style={styles.emptyFolderButton} hitSlop={6}> + {t('common.retry', 'Retry')} + + + {t('email_list.outbox_discard', 'Discard')} + + + )} + {/* Email list */} {loading && emails.length === 0 ? ( @@ -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). */} setPendingMoveId(null)} - mailboxes={scopedMailboxes} + mailboxes={mailboxes} currentMailboxId={currentMailboxId} onPick={(toId) => { const id = pendingMoveId; setPendingMoveId(null); 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 setBatchMoveOpen(false)} - mailboxes={scopedMailboxes} + mailboxes={mailboxes} currentMailboxId={currentMailboxId} onPick={handleBatchMovePick} /> diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index 069e49af..6576fd59 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -316,7 +316,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { {starred && } {item.hasAttachment && } - {formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })} + {formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t })} {item.subject || '(no subject)'} diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 2763bec0..2887d16a 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -14,6 +14,7 @@ vi.mock('../../api/email', () => ({ getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })), getEmailChanges: vi.fn(async () => null), getFullEmail: vi.fn(), + importEmailBlob: vi.fn(async () => 'imported-1'), setEmailKeywords: vi.fn(), setKeywordsForEmails: vi.fn(), moveEmail: vi.fn(), @@ -33,6 +34,10 @@ vi.mock('../../api/email', () => ({ // locale-store pulls in expo-localization / react-native I18nManager; the // 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', () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, useLocaleStore: { getState: () => ({ locale: 'en', t: (_k: string, f?: string) => f ?? _k }) }, @@ -128,6 +133,7 @@ vi.mock('../../api/jmap-client', () => ({ getMaxObjectsInGet: () => 500, getMaxCallsInRequest: () => 16, 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 // unsupportedSort keeps the sort at the plain receivedAt comparator the // assertions below expect. @@ -856,19 +862,23 @@ describe('email-store', () => { 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; + const mockDestroy = emailApi.destroyEmails as ReturnType; useEmailStore.setState({ mailboxes: [ownInbox, sharedInbox], 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'); expect(mockMoveEmail).not.toHaveBeenCalled(); + expect(mockImport).toHaveBeenCalledWith('blob-new', 'mb-1', { $seen: true }, undefined); + expect(mockDestroy).toHaveBeenCalledWith(['e1'], 'grp-1'); const state = useEmailStore.getState(); - expect(state.error).toMatch(/same account/); - expect(state.emails).toHaveLength(1); + expect(state.error).toBeNull(); + expect(state.emails).toHaveLength(0); }); }); }); diff --git a/src/stores/__tests__/outbox-store.test.ts b/src/stores/__tests__/outbox-store.test.ts index 0b7b8984..2add515e 100644 --- a/src/stores/__tests__/outbox-store.test.ts +++ b/src/stores/__tests__/outbox-store.test.ts @@ -177,3 +177,65 @@ describe('flush', () => { 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'); + }); +}); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 7e9db7a1..21b6cee6 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -27,8 +27,10 @@ import { searchEmails as apiSearchEmails, markAsSpam as apiMarkAsSpam, undoSpam as apiUndoSpam, + destroyEmails as apiDestroyEmails, unprefixMailboxId, } from '../api/email'; +import { useNetworkStore } from './network-store'; import { JMAPMethodError } from '../api/jmap-result'; import { mailboxesForSiblingOf, findJunkMailbox } from '../lib/mailbox-tree'; import { toWildcardQuery } from '../lib/search-utils'; @@ -74,11 +76,15 @@ const provisionRetried = new Set(); // Keep the offline body cache consistent with an optimistic/queued mutation so // re-opening a message while offline shows the change. Fire-and-forget. -function patchCache(id: string, changes: { keywords?: Record; mailboxIds?: Record }): void { - void useOfflineCacheStore.getState().patch(id, changes); +function patchCache( + id: string, + changes: { keywords?: Record; mailboxIds?: Record }, + accountId?: string, +): void { + void useOfflineCacheStore.getState().patch(id, changes, accountId); } -function dropFromCache(ids: string[]): void { - void useOfflineCacheStore.getState().remove(ids); +function dropFromCache(ids: string[], accountId?: string): void { + void useOfflineCacheStore.getState().remove(ids, accountId); } // Compute an email's full mailboxIds map after removing one mailbox and adding // another — the idempotent target the outbox replays for a move/trash. @@ -720,6 +726,7 @@ export const useEmailStore = create()( seededEmails = await cacheStore.getEmailsInMailbox( rawMailboxId(state.mailboxes, mailboxId), Math.max(limit, 50), + refFor(state.mailboxes, mailboxId).accountId, ); // The cache returns newest-first; flip for an ascending sort. if (useSettingsStore.getState().mailSortAscending) seededEmails.reverse(); @@ -939,15 +946,15 @@ export const useEmailStore = create()( // Opportunistically refresh the cached copy so the next offline open // reflects the latest keywords without needing a full sync. const cache = useOfflineCacheStore.getState(); - if (cache.has(id)) { + if (cache.has(id, accountId)) { try { const size = JSON.stringify(fresh).length; - await cache.put(fresh, size); + await cache.put(fresh, size, accountId); } catch { /* ignore — best-effort refresh */ } } return fresh; } catch (err) { - const cached = await useOfflineCacheStore.getState().get(id); + const cached = await useOfflineCacheStore.getState().get(id, accountId); if (cached) return cached; throw err; } @@ -972,7 +979,7 @@ export const useEmailStore = create()( ), ...(state.filters.isUnread === true ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), }); - patchCache(emailId, { keywords: nextKeywords }); + patchCache(emailId, { keywords: nextKeywords }, owner); }, markUnread: async (emailId) => { @@ -992,7 +999,7 @@ export const useEmailStore = create()( ), ...(state.filters.isUnread === false ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), }); - patchCache(emailId, { keywords: rest }); + patchCache(emailId, { keywords: rest }, currentAccountId(state)); }, toggleStar: async (emailId, starred) => { @@ -1019,7 +1026,7 @@ export const useEmailStore = create()( ? { retainedIds: retain(get().retainedIds, [emailId]) } : {}), }); - patchCache(emailId, { keywords }); + patchCache(emailId, { keywords }, currentAccountId(state)); }, togglePin: async (emailId, pinned) => { @@ -1045,7 +1052,7 @@ export const useEmailStore = create()( e.id === emailId ? { ...e, keywords } : e, ), }); - patchCache(emailId, { keywords }); + patchCache(emailId, { keywords }, currentAccountId(state)); }, markSpam: async (emailIds) => { @@ -1092,7 +1099,7 @@ export const useEmailStore = create()( 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) => { @@ -1135,7 +1142,7 @@ export const useEmailStore = create()( 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) => { @@ -1143,11 +1150,19 @@ export const useEmailStore = create()( const email = state.emails.find((e) => e.id === emailId); const from = refFor(state.mailboxes, fromMailboxId); const to = refFor(state.mailboxes, toMailboxId); - // A single Email/set is scoped to one account, so there's no move between - // the user's own folders and a shared account's (or between two shared - // accounts) — that would be a copy-then-delete across accounts. + // A single Email/set is scoped to one account: a move between the user's + // own folders and a shared account's (or between two shared accounts) is + // a copy-then-delete across accounts (webmail 1.7.2), online only. 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; } const original = email ? { ...email.mailboxIds } : null; @@ -1158,7 +1173,7 @@ export const useEmailStore = create()( () => moveEmail(emailId, from.id, to.id, from.accountId), ); set({ emails: get().emails.filter((e) => e.id !== emailId) }); - patchCache(emailId, { mailboxIds: target }); + patchCache(emailId, { mailboxIds: target }, from.accountId); if (email && original) { const targetName = mailboxPath(get().mailboxes, toMailboxId); @@ -1199,10 +1214,12 @@ export const useEmailStore = create()( // op replays as a plain move into Archive. const { queued } = await applyOrQueue( { - kind: 'mailboxes', + kind: 'archive', emailId, accountId: archive.accountId, - mailboxIds: { [archive.id]: true }, + archiveMailboxId: archive.id, + mode, + receivedAt: email.receivedAt, }, () => apiArchiveEmails( [{ id: email.id, receivedAt: email.receivedAt }], @@ -1223,7 +1240,7 @@ export const useEmailStore = create()( 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 // mailbox list so the sidebar picks them up on the next render. Skip when @@ -1263,7 +1280,7 @@ export const useEmailStore = create()( { kind: 'destroy', emailId, accountId: trash.accountId }, () => apiDeleteEmail(emailId, trash.id, trash.id, trash.accountId), ); - dropFromCache([emailId]); + dropFromCache([emailId], trash.accountId); } else { const target = mailboxesAfterMove(email?.mailboxIds, source.id, trash.id); await applyOrQueue( @@ -1280,9 +1297,9 @@ export const useEmailStore = create()( accountId: source.accountId, keywords: nextKeywords, }); - patchCache(emailId, { mailboxIds: target, keywords: nextKeywords }); + patchCache(emailId, { mailboxIds: target, keywords: nextKeywords }, source.accountId); } else { - patchCache(emailId, { mailboxIds: target }); + patchCache(emailId, { mailboxIds: target }, source.accountId); } } set({ emails: get().emails.filter((e) => e.id !== emailId) }); @@ -1325,10 +1342,12 @@ export const useEmailStore = create()( const { queued } = await applyOrQueueBatch( targets.map((e): OutboxOp => ({ - kind: 'mailboxes', + kind: 'archive', emailId: e.id, accountId: archive.accountId, - mailboxIds: archiveTarget, + archiveMailboxId: archive.id, + mode, + receivedAt: e.receivedAt, })), () => apiArchiveEmails( targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })), @@ -1352,7 +1371,7 @@ export const useEmailStore = create()( 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(); }, @@ -1362,13 +1381,21 @@ export const useEmailStore = create()( if (!currentMailboxId || toMailboxId === currentMailboxId) return; const source = refFor(mailboxes, currentMailboxId); 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)); 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 } })); @@ -1384,7 +1411,7 @@ export const useEmailStore = create()( const removed = new Set(targets.map((e) => e.id)); 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); set({ @@ -1473,12 +1500,12 @@ export const useEmailStore = create()( } }); - 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) { patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, trash.id), ...(markReadKeywords.has(e.id) ? { keywords: markReadKeywords.get(e.id) } : {}), - }); + }, source.accountId); } const removed = new Set(targets.map((e) => e.id)); @@ -1530,7 +1557,7 @@ export const useEmailStore = create()( ? { 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 () => { @@ -1572,7 +1599,7 @@ export const useEmailStore = create()( patchCache(it.email.id, { mailboxIds: it.originalMailboxIds, ...(it.originalKeywords ? { keywords: it.originalKeywords } : {}), - }); + }, entry.accountId); } } catch (err) { set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') }); @@ -1709,6 +1736,27 @@ export const useEmailStore = create()( ), ); +// 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 { + 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 = {}; + 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) ───────── function retain(current: string[], ids: string[]): string[] { @@ -2047,6 +2095,7 @@ async function refreshEmailsImpl(): Promise { const cached = await cacheStore.getEmailsInMailbox( ref.id, Math.max(limit, 50), + ref.accountId, ); if (sortAscending) cached.reverse(); if ( diff --git a/src/stores/offline-cache-store.ts b/src/stores/offline-cache-store.ts index 84694640..db71c062 100644 --- a/src/stores/offline-cache-store.ts +++ b/src/stores/offline-cache-store.ts @@ -24,11 +24,20 @@ function entryKey(accountId: string, emailId: string): string { 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 { id: string; receivedAt: string; size: number; // approx bytes of cached payload cachedAt: number; + /** Owning JMAP account for a shared/group message; unset for own mail. */ + jmapAccountId?: string; } interface CacheIndex { @@ -66,14 +75,14 @@ interface OfflineCacheState { // AsyncStorage. Pass null to detach (no reads/writes will hit storage). setAccount: (accountId: string | null) => Promise; hydrate: () => Promise; - has: (id: string) => boolean; - get: (id: string) => Promise; - put: (email: Email, approxBytes: number) => Promise; + has: (id: string, jmapAccountId?: string) => boolean; + get: (id: string, jmapAccountId?: string) => Promise; + put: (email: Email, approxBytes: number, jmapAccountId?: string) => Promise; // 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 // re-open while offline reflects the new keywords / mailboxIds. - patch: (id: string, changes: Partial>) => Promise; - remove: (ids: string[]) => Promise; + patch: (id: string, changes: Partial>, jmapAccountId?: string) => Promise; + remove: (ids: string[], jmapAccountId?: string) => Promise; // Evict oldest-received entries until the cache fits within maxBytes. Keeps // the persisted cache from growing without bound on a noisy account. evictToFit: (maxBytes: number) => Promise; @@ -83,7 +92,7 @@ interface OfflineCacheState { // sorted by `receivedAt` descending. Used by selectMailbox to seed the // list when the network is unavailable. Loads each entry from // AsyncStorage; the index doesn't carry the mailbox set. - getEmailsInMailbox: (mailboxId: string, limit?: number) => Promise; + getEmailsInMailbox: (mailboxId: string, limit?: number, jmapAccountId?: string) => Promise; totalSize: () => number; totalCount: () => number; @@ -163,14 +172,15 @@ export const useOfflineCacheStore = create((set, get) => ({ 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(); 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 { - const raw = await AsyncStorage.getItem(entryKey(state.activeAccountId, id)); + const raw = await AsyncStorage.getItem(entryKey(state.activeAccountId, key)); if (!raw) return null; return JSON.parse(raw) as Email; } catch (err) { @@ -179,12 +189,13 @@ export const useOfflineCacheStore = create((set, get) => ({ } }, - put: async (email, approxBytes) => { + put: async (email, approxBytes, jmapAccountId) => { const state = get(); const accountId = state.activeAccountId; if (!accountId) return; + const key = cacheKey(email.id, jmapAccountId); try { - await AsyncStorage.setItem(entryKey(accountId, email.id), JSON.stringify(email)); + await AsyncStorage.setItem(entryKey(accountId, key), JSON.stringify(email)); } catch (err) { console.warn('[offline-cache] put failed', email.id, err); return; @@ -195,11 +206,12 @@ export const useOfflineCacheStore = create((set, get) => ({ if (get().activeAccountId !== accountId) return; const nextEntries = { ...get().index.entries, - [email.id]: { + [key]: { id: email.id, receivedAt: email.receivedAt, size: approxBytes, cachedAt: Date.now(), + ...(jmapAccountId ? { jmapAccountId } : {}), }, }; const index = { entries: nextEntries }; @@ -207,31 +219,33 @@ export const useOfflineCacheStore = create((set, get) => ({ persistIndex(accountId, index); }, - patch: async (id, changes) => { + patch: async (id, changes, jmapAccountId) => { const accountId = get().activeAccountId; - if (!accountId || !get().index.entries[id]) return; + const key = cacheKey(id, jmapAccountId); + if (!accountId || !get().index.entries[key]) return; try { - const raw = await AsyncStorage.getItem(entryKey(accountId, id)); + const raw = await AsyncStorage.getItem(entryKey(accountId, key)); if (!raw) return; const email = JSON.parse(raw) as Email; const updated: Email = { ...email, ...changes }; 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) { console.warn('[offline-cache] patch failed', id, err); } }, - remove: async (ids) => { + remove: async (ids, jmapAccountId) => { if (ids.length === 0) return; const accountId = get().activeAccountId; if (!accountId) return; + const keys = ids.map((id) => cacheKey(id, jmapAccountId)); 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; 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 }; set({ index }); persistIndex(accountId, index); @@ -250,13 +264,19 @@ export const useOfflineCacheStore = create((set, get) => ({ const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0; return at - bt; }); - const toRemove: string[] = []; + const toRemove: Array<{ id: string; jmapAccountId?: string }> = []; for (const entry of sorted) { if (total <= maxBytes) break; - toRemove.push(entry.id); + toRemove.push({ id: entry.id, jmapAccountId: entry.jmapAccountId }); 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(); + 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 () => { @@ -272,13 +292,14 @@ export const useOfflineCacheStore = create((set, get) => ({ persistIndex(accountId, index); }, - getEmailsInMailbox: async (mailboxId, limit = 200) => { + getEmailsInMailbox: async (mailboxId, limit = 200, jmapAccountId) => { const state = get(); const accountId = state.activeAccountId; if (!accountId) return []; // Sort the index by receivedAt descending first so we don't have to read - // entries we won't return — the index already carries receivedAt. - const sorted = Object.values(state.index.entries).sort((a, b) => { + // entries we won't return — the index already carries receivedAt. Only + // 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 bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0; return bt - at; @@ -287,7 +308,7 @@ export const useOfflineCacheStore = create((set, get) => ({ for (const entry of sorted) { if (matches.length >= limit) break; 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; const email = JSON.parse(raw) as Email; if (email.mailboxIds?.[mailboxId]) { diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts index 2906e2f7..2acc4b57 100644 --- a/src/stores/outbox-store.ts +++ b/src/stores/outbox-store.ts @@ -1,9 +1,10 @@ // 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: // - 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 // // Because each primitive assigns the *whole* target state rather than a delta, @@ -20,15 +21,23 @@ import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { generateUUID } from '../lib/uuid'; -import { isTransientNetworkError } from '../lib/network-error'; +import { isTransientNetworkError, isAuthError } from '../lib/network-error'; import { useNetworkStore } from './network-store'; 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 FAILED_SUFFIX = ':failed'; // 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. 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 { return `${KEY_PREFIX}${accountId}`; @@ -40,6 +49,19 @@ function storageKey(accountId: string): string { export type OutboxOp = | { kind: 'keywords'; emailId: string; accountId?: string; keywords: Record } | { kind: 'mailboxes'; emailId: string; accountId?: string; mailboxIds: Record } + | { + /** + * 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 }; export interface OutboxEntry { @@ -54,8 +76,16 @@ interface OutboxState { // Currently-loaded account bucket. null = detached (no reads/writes). activeAccountId: string | null; 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; flushing: boolean; + /** Set after an authentication failure; cleared by setAccount()/login. */ + paused: boolean; setAccount: (accountId: string | null) => Promise; hydrate: () => Promise; @@ -63,6 +93,9 @@ interface OutboxState { count: () => number; pendingForEmail: (emailId: string) => OutboxEntry[]; flush: () => Promise; + /** Move every failed op back into the queue and flush. */ + retryFailed: () => Promise; + discardFailed: () => void; clear: () => Promise; } @@ -72,9 +105,15 @@ function persist(accountId: string, entries: OutboxEntry[]): void { }); } -async function load(accountId: string): Promise { +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 { try { - const raw = await AsyncStorage.getItem(storageKey(accountId)); + const raw = await AsyncStorage.getItem(storageKey(accountId) + suffix); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed as OutboxEntry[]; @@ -91,29 +130,88 @@ async function runOp(op: OutboxOp): Promise { return setEmailKeywords(op.emailId, op.keywords, op.accountId); case 'mailboxes': 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': 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 | 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 { + 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((set, get) => ({ activeAccountId: null, entries: [], + failed: [], hydrated: false, flushing: false, + paused: false, setAccount: async (accountId) => { const state = get(); if (state.activeAccountId === accountId) { if (!state.hydrated) await get().hydrate(); + set({ paused: false }); return; } - set({ activeAccountId: accountId, entries: [], hydrated: false }); + clearRetry(); + retryAttempt = 0; + set({ activeAccountId: accountId, entries: [], failed: [], hydrated: false, paused: false }); 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. if (get().activeAccountId !== accountId) return; - set({ entries, hydrated: true }); + set({ entries, failed, hydrated: true }); } else { set({ hydrated: true }); } @@ -127,9 +225,9 @@ export const useOutboxStore = create((set, get) => ({ set({ hydrated: true }); return; } - const entries = await load(accountId); + const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]); if (get().activeAccountId !== accountId) return; - set({ entries, hydrated: true }); + set({ entries, failed, hydrated: true }); }, enqueue: (op) => { @@ -152,10 +250,10 @@ export const useOutboxStore = create((set, get) => ({ } else { // A queued destroy wins; further edits to a doomed message are pointless. 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 // 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) { entries[idx] = { ...entries[idx], op, attempts: 0, lastError: undefined }; } else { @@ -172,7 +270,7 @@ export const useOutboxStore = create((set, get) => ({ pendingForEmail: (emailId) => get().entries.filter((e) => e.op.emailId === emailId), flush: async () => { - if (get().flushing) return; + if (get().flushing || get().paused) return; const accountId = get().activeAccountId; if (!accountId) return; if (get().entries.length === 0) return; @@ -181,6 +279,8 @@ export const useOutboxStore = create((set, get) => ({ if (!useNetworkStore.getState().online) return; set({ flushing: true }); + let brokeTransient = false; + let dropped = false; try { // 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, @@ -198,19 +298,29 @@ export const useOutboxStore = create((set, get) => ({ try { await runOp(entry.op); removeEntry(accountId, entry.id); + retryAttempt = 0; } catch (err) { if (get().activeAccountId !== accountId) break; - if (isTransientNetworkError(err)) { - // Connectivity blip — stop and let the next flush retry from here. + if (isAuthError(err)) { + // 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); + set({ paused: true }); break; } - // Server rejected it. Count the attempt; drop once we've given up so - // one bad op can't block everything behind it. + if (isTransientNetworkError(err)) { + // 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; if (attempts >= MAX_ATTEMPTS) { - console.warn('[outbox] dropping op after repeated failures', entry.op.kind, err); - removeEntry(accountId, entry.id); + console.warn('[outbox] parking op after repeated failures', entry.op.kind, err); + moveToFailed(accountId, entry.id, err); + dropped = true; } else { recordError(accountId, entry.id, err, true); } @@ -219,13 +329,38 @@ export const useOutboxStore = create((set, get) => ({ } finally { 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 () => { const accountId = get().activeAccountId; - set({ entries: [] }); + clearRetry(); + set({ entries: [], failed: [] }); if (accountId) { 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); } +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 { const store = useOutboxStore.getState(); 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 // any of the affected messages; otherwise queue them for later replay. An // 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 -// degrading to the idempotent primitives offline. +// path (e.g. batch move) while still degrading to the idempotent primitives +// offline — which replay with the same semantics (an `archive` op re-applies +// the year/month foldering on replay). export async function applyOrQueueBatch( ops: OutboxOp[], onlineRun?: () => Promise, ): Promise { if (ops.length === 0) return { queued: false }; 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) => store.entries.some((e) => e.op.emailId === op.emailId), );