mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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:<id>')` across all folders (`stores/email-store.ts:1343-1420`, changelog 1.4.13 #175), counts kept in step with read/unread (1.7.8).
|
||||
- What RN does: no tag section in `SidebarDrawer`; `EmailFilters` has no keyword field (`src/stores/email-store.ts:140-149`).
|
||||
- Fix hint: add `keyword?: string` to `EmailFilters` and a "Tags" section in the drawer; when set, `buildJmapFilter` should omit `inMailbox` (search across folders) and add `{ hasKeyword: '$label:<id>' }`; counts via one `Email/query` (calculateTotal) per tag, optional.
|
||||
@@ -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).
|
||||
|
||||
|
||||
+19
-5
@@ -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, 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(
|
||||
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',
|
||||
|
||||
+82
-48
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
});
|
||||
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<string>();
|
||||
for (const tgt of targets) for (const id of tgt.ids) keepSet.add(cacheKey(id, tgt.accountId));
|
||||
const staleByAccount = new Map<string | undefined, string[]>();
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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({
|
||||
<Text style={styles.threadBadgeText}>{threadCount}</Text>
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
<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 */}
|
||||
{loading && emails.length === 0 ? (
|
||||
<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
|
||||
visible={pendingMoveId !== null}
|
||||
onClose={() => 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
|
||||
<MoveSheet
|
||||
visible={batchMoveOpen}
|
||||
onClose={() => setBatchMoveOpen(false)}
|
||||
mailboxes={scopedMailboxes}
|
||||
mailboxes={mailboxes}
|
||||
currentMailboxId={currentMailboxId}
|
||||
onPick={handleBatchMovePick}
|
||||
/>
|
||||
|
||||
@@ -316,7 +316,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
</Text>
|
||||
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
|
||||
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
||||
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
|
||||
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t })}</Text>
|
||||
</View>
|
||||
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
|
||||
{item.subject || '(no subject)'}
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
const mockDestroy = emailApi.destroyEmails as ReturnType<typeof vi.fn>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+86
-37
@@ -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<string>();
|
||||
|
||||
// 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<string, boolean>; mailboxIds?: Record<string, boolean> }): void {
|
||||
void useOfflineCacheStore.getState().patch(id, changes);
|
||||
function patchCache(
|
||||
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 {
|
||||
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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
// 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<EmailState>()(
|
||||
),
|
||||
...(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<EmailState>()(
|
||||
),
|
||||
...(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<EmailState>()(
|
||||
? { 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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
() => 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<EmailState>()(
|
||||
// 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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
{ 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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
|
||||
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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
|
||||
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<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) {
|
||||
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<EmailState>()(
|
||||
? { 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<EmailState>()(
|
||||
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<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) ─────────
|
||||
|
||||
function retain(current: string[], ids: string[]): string[] {
|
||||
@@ -2047,6 +2095,7 @@ async function refreshEmailsImpl(): Promise<void> {
|
||||
const cached = await cacheStore.getEmailsInMailbox(
|
||||
ref.id,
|
||||
Math.max(limit, 50),
|
||||
ref.accountId,
|
||||
);
|
||||
if (sortAscending) cached.reverse();
|
||||
if (
|
||||
|
||||
@@ -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<void>;
|
||||
hydrate: () => Promise<void>;
|
||||
has: (id: string) => boolean;
|
||||
get: (id: string) => Promise<Email | null>;
|
||||
put: (email: Email, approxBytes: number) => Promise<void>;
|
||||
has: (id: string, jmapAccountId?: string) => boolean;
|
||||
get: (id: string, jmapAccountId?: string) => Promise<Email | null>;
|
||||
put: (email: Email, approxBytes: number, jmapAccountId?: string) => Promise<void>;
|
||||
// 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<Pick<Email, 'keywords' | 'mailboxIds'>>) => Promise<void>;
|
||||
remove: (ids: string[]) => Promise<void>;
|
||||
patch: (id: string, changes: Partial<Pick<Email, 'keywords' | 'mailboxIds'>>, jmapAccountId?: string) => Promise<void>;
|
||||
remove: (ids: string[], jmapAccountId?: string) => Promise<void>;
|
||||
// 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<void>;
|
||||
@@ -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<Email[]>;
|
||||
getEmailsInMailbox: (mailboxId: string, limit?: number, jmapAccountId?: string) => Promise<Email[]>;
|
||||
|
||||
totalSize: () => number;
|
||||
totalCount: () => number;
|
||||
@@ -163,14 +172,15 @@ export const useOfflineCacheStore = create<OfflineCacheState>((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<OfflineCacheState>((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<OfflineCacheState>((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<OfflineCacheState>((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<OfflineCacheState>((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<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 () => {
|
||||
@@ -272,13 +292,14 @@ export const useOfflineCacheStore = create<OfflineCacheState>((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<OfflineCacheState>((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]) {
|
||||
|
||||
+174
-24
@@ -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<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 };
|
||||
|
||||
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<void>;
|
||||
hydrate: () => Promise<void>;
|
||||
@@ -63,6 +93,9 @@ interface OutboxState {
|
||||
count: () => number;
|
||||
pendingForEmail: (emailId: string) => OutboxEntry[];
|
||||
flush: () => Promise<void>;
|
||||
/** Move every failed op back into the queue and flush. */
|
||||
retryFailed: () => Promise<void>;
|
||||
discardFailed: () => 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 {
|
||||
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<void> {
|
||||
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<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) => ({
|
||||
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<OutboxState>((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<OutboxState>((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<OutboxState>((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<OutboxState>((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<OutboxState>((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<OutboxState>((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<void>,
|
||||
): Promise<ApplyResult> {
|
||||
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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user