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

This commit is contained in:
Linus Rath
2026-08-29 18:47:12 +02:00
parent 09c24fa805
commit 0205aa0e1c
10 changed files with 543 additions and 177 deletions
+27 -27
View File
@@ -12,32 +12,32 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
- What RN does: `deduplicate()` in `src/lib/mailbox-tree.ts:49-68` uses `lower.includes(rn) || rn.includes(lower)` — "Old Inbox", "2025 Archive", "Sent to Accounting" vanish from the drawer and the move sheet (also from `findTrashMailbox` etc. only indirectly).
- 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
View File
@@ -14,9 +14,23 @@ import type { DateFormat, TimeFormat } from '../stores/settings-store';
*
* `locale` is the language subtag from the locale store (e.g. "en", "de").
*/
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
View File
@@ -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
+30 -4
View File
@@ -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}
/>
+1 -1
View File
@@ -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 -4
View File
@@ -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);
});
});
});
+62
View File
@@ -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
View File
@@ -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 (
+48 -27
View File
@@ -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
View File
@@ -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),
);