Tick the parity items the fix pass finished (server-side recurrence, filters and vacation for shared accounts, in-app file preview, the trusted-senders toggle, UnifiedPush, sender taps in the viewer), and note on the already ticked ones what this pass changed, such as the tag view and search reaching shared accounts or contact photos as avatars. Items that only moved forward say what is left. Fix hints and "verified" lines that named removed functions (`setEmailKeywords`, `getEmailDetail`, `fetchEmailsChunked`, `buildRecurrenceOverridePatch`) or the old whole-map keyword writes now point at what replaced them. PARITY_CHECKLIST.md counts done and open items per area: 379 of 415 are done, 36 open.
46 KiB
Mail list, mailboxes/folders, unified views, search, tags, batch actions
Summary
RN covers the core single-folder loop well (folder tree incl. shared/group accounts with owner-routed mutations, incremental Email/queryChanges refresh, offline seed, swipe actions with reveal mode, multi-select with archive/move/delete/tag and an undo snackbar WEB does not even have). The biggest gaps are: (1) tags are invisible in the list and there is no tag view/tag counts; (2) spam/not-spam only moves the message and never flips $junk/$notjunk (#850), and there is no not-spam in the list at all; (3) search is always folder-scoped (WEB defaults to all folders, #788) with no history/suggestions; (4) the unified inbox is a one-shot, inbox-only, cross-account-only screen with no actions, paging, counts, Unread/Starred/All-mail views or per-role views; (5) folder management is create/rename/delete only (no subfolders, move, reorder, empty folder, mark folder read); (6) several WEB bugfixes are still live in RN: #771 substring dedup hides folders, permanent delete has no confirmation, Sent/Drafts show sender instead of recipient, "Never" mark-as-read marks read instantly, pins are written as $important instead of $pinned. One P1 RN-only bug: opening a message from the unified inbox that is not in the active folder page renders another message's body in the pager.
Findings
Folder tree / mailboxes
-
Folders whose name contains a system folder name disappear (#771) —
P2—bugfix-parity— fixed in516c9c9- What WEB does: dedup only drops a root folder whose name is an exact (trimmed, case-insensitive) match of a role folder in the same account (
lib/utils.ts:270-333, comment cites #771; changelog 1.9.0). - What RN does:
deduplicate()insrc/lib/mailbox-tree.ts:49-68useslower.includes(rn) || rn.includes(lower)— "Old Inbox", "2025 Archive", "Sent to Accounting" vanish from the drawer and the move sheet (also fromfindTrashMailboxetc. only indirectly). - Fix hint: replace the substring test with
r.name.trim().toLowerCase() === lower; keep the nested/parent guards. Add a case tosrc/lib/__tests__/mailbox-tree.test.ts.
- What WEB does: dedup only drops a root folder whose name is an exact (trimmed, case-insensitive) match of a role folder in the same account (
-
Role folders not localized (#404) —
P3—missing— fixed in6ae21d5- 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 headersrc/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) usinguseLocaleStore().tkeyssidebar.mailboxes.*(already present inlocales/*/common.json), apply in the four places.
- What WEB does:
-
Setting to hide the total message count on folders (#498) —
P3—missing— fixed in6ae21d5- What WEB does:
showFolderTotalCountsetting (stores/settings-store.ts,components/settings/layout-settings.tsx:236) hides the/ totalpart inSidebarRowCounts(components/layout/sidebar.tsx:207-209). - What RN does:
RowCountsalways shows unread/total (src/components/SidebarDrawer.tsx:68-79). - Fix hint: add
showFolderTotalCountto settings-store + LayoutSettings toggle; gatetotalinRowCounts.
- What WEB does:
-
Create subfolder / choose parent when creating —
P3—partial— fixed in6ae21d5- What WEB does: "New subfolder" in the folder context menu and
subfolder_ofin settings (components/layout/mailbox-context-menu.tsx:187-193,components/settings/folder-settings.tsx:408-423); storecreateMailbox(client, name, parentId, accountId)(stores/email-store.ts:3753), incl. creating in a shared account. - What RN does:
FolderSettings.saveDraftalways callscreateMailbox({ name })(src/components/settings/FolderSettings.tsx:96) althoughcreateMailboxinsrc/api/email.ts:156already acceptsparentIdandaccountIdOverride. - Fix hint: add a "Parent folder" select (own folders, or the shared account when creating there) to the editor modal and pass
parentId.
- What WEB does: "New subfolder" in the folder context menu and
-
Move folder to another parent (#855) and reorder folders (sortOrder) —
P3—missing— partial in6ae21d5: "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 (
moveMailboxstores/email-store.ts:3898-3956, refetches after reparent per #855) and reorders viasortOrder(reorderMailboxes:3859); folder-settings DnD plancomponents/settings/folder-settings.tsx:241-262. - What RN does: no UI;
updateMailboxinsrc/api/email.ts:185acceptsparentIdbut notsortOrder. Tree sorts bysortOrderalready (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 })thenfetchMailboxes(); optionally up/down buttons writingsortOrder.
- What WEB does: drag & drop in folder settings and sidebar reparents (
-
Assign/clear a folder role, custom folder icons, colorful-icon toggle —
P3—missing— partial in6ae21d5: 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, storesetMailboxRolestores/email-store.ts:3830), per-folder icon picker (folder-settings.tsx:77,folderIconssetting),colorfulSidebarIconstoggle (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,
iconForknows only inbox/sent/drafts/trash/junk/archive/star (src/components/SidebarDrawer.tsx:36-66); no role assignment. - Fix hint: low priority; at least extend
iconForwith the #288 roles (important/memos/scheduled/snoozed/shared) and ideally a role picker in FolderSettings usingupdateMailbox(id, { role })(needsroleadded to thechangestype).
- What WEB does: role dropdown per folder (
-
Hide the server "Scheduled" role folder when the virtual Scheduled row is shown (#495) —
P3—bugfix-parity— fixed in6ae21d5- 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 Stalwartrole: scheduledmailbox as a plain folder. - Fix hint: skip nodes with
role === 'scheduled'inbuildMailboxTree/drawer whenjmapClient.hasDelayedSend().
- What WEB does: filters
-
Empty folder (Trash/Junk) — with #711 pagination —
P2—missing— fixed in3194b5e- What WEB does: "Empty folder" banner/button in Trash & Junk and in the folder context menu (
components/email/email-list.tsx:457-479,components/layout/mailbox-context-menu.tsx:213-219);client.emptyMailboxloopsEmail/query+ back-referencedEmail/set destroyin batches ofmin(500, maxObjectsInSet)and never gates ontotal(lib/jmap/client.ts:2288-2325, changelog 1.8.1 #711); store zeroes counters (stores/email-store.ts:3958). - What RN does: nothing — the only way is select-all-on-page + delete, page by page.
- Fix hint: add
emptyMailbox(mailboxId, accountId?)tosrc/api/email.tsmirroring the WEB loop (stop whenfound.length === 0 || destroyed === 0 || found.length < batch), a confirmAlert, a button in the list header whencurrentMailbox.roleis trash/junk, thenrefreshEmails()+fetchMailboxes()anddropFromCache.
- What WEB does: "Empty folder" banner/button in Trash & Junk and in the folder context menu (
-
Mark folder / folder tree / all folders as read —
P2—missing— fixed in6ae21d5- What WEB does: folder context menu →
markMailboxAsRead(pagedEmail/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; thenfetchMailboxes()and, if current,refreshEmails().
- What WEB does: folder context menu →
-
Retry button after a failed first load does not retry the mailbox fetch —
P2—rn-only-bug— fixed in3194b5e- What WEB does: retries mailbox fetch on first login for lazy provisioning (#217) and keeps the tree on later failures (#780).
- What RN does: when
fetchMailboxesfails with zero mailboxes it setserror(src/stores/email-store.ts:610-612); the list shows the error with a "Retry" that callsrefreshEmails()(src/screens/EmailListScreen.tsx:812), which returns immediately becausecurrentMailboxIdis null (email-store.ts:773). The mount effect only re-runs whenmailboxes.lengthchanges, so the user is stuck until an app restart. - Fix hint: Retry should call
fetchMailboxes()whenmailboxes.length === 0(then the inbox-select effect kicks in); consider one automatic retry after ~2 s on first login.
-
No coalescing of concurrent refreshes (maxConcurrentRequests / 429) —
P3—partial— fixed inf07e039- What WEB does:
coalesceRefreshshares an in-flightfetchMailboxes/refreshCurrentMailbox/tag-count run and queues at most one re-run (stores/email-store.ts:383-425, #780). - What RN does: every push event awaits
fetchMailboxes()thenrefreshEmails()(src/stores/email-store.ts:981-1015); overlapping pushes, the mount effects (EmailListScreen.tsx:554-567) andarchiveEmail's follow-upfetchMailboxescan all run in parallel; aRateLimitError(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/refreshEmailsbodies in a small in-flight map keyed byactiveAccountIdwith a single queued re-run, like WEB'scoalesceRefresh.
- What WEB does:
-
Tap the unread count of a folder to open it filtered to unread —
P3—missing— fixed in6ae21d5- What WEB does: unread badge is a button that toggles
isUnread: trueon 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)thensetFilters({ isUnread: true }).
- What WEB does: unread badge is a button that toggles
-
Shared-account header has no folder actions —
P3—missing— fixed in6ae21d5- 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:
FolderSettingsmanages 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
mayCreateChildin) to the folder editor and passaccountIdOverride.
- What WEB does: right-click on a shared account header offers "New folder" in that account (
Unified mailbox / cross-account views
-
Unified scope: account-bounded by default, cross-account opt-in; single-account + group inboxes case —
P2—partial— fixed in 6ae21d5; the drawer only shows the Unified section when it can hold more than one account's mail sinceaf6290f(#843)- What WEB does: unified views stay inside the active account (own + shared/group folders) unless
unifiedCrossAccountis 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 asincludeGroupInUnified && 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 aunifiedCrossAccounttoggle (default off, like WEB) and pass only the active account id when it is off. Note WEB's default forincludeGroupInUnifiedistrue(settings-store.ts:639) while RN defaults tofalse(src/stores/settings-store.ts:235).
- What WEB does: unified views stay inside the active account (own + shared/group folders) unless
-
Unified per-role views (All Sent/Drafts/Junk/Archive/Trash) with live counts —
P3—missing— fixed in6ae21d5- What WEB does: one row per role present in any account with summed unread/total (
lib/unified-mailbox.ts:295-320, sidebar rowscomponents/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
roleinstead of hard-coding inbox; sumunreadEmailsof each account's inbox for the drawer badge.
- What WEB does: one row per role present in any account with summed unread/total (
-
All mail / Unread / Starred cross views + per-account folder selection —
P2—missing— fixed in6ae21d5- What WEB does:
buildCrossFilter(OR of included mailboxes ANDnotKeyword $seen/hasKeyword $flagged) fanned out per account (lib/unified-mailbox.ts:338-492); default = inbox + custom folders (CROSS_EXCLUDED_ROLES), narrowed byallMailFolderIds[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,resolveSourceFolderNamelib/unified-mailbox.ts:63). - What RN does: none.
- Fix hint: reuse
fetchInboxEmailsForJmapAccountwith a filter built likebuildCrossFilteroverMailbox/getresults minus excluded roles; add asourceFolderlabel toUnifiedEmailrows; folder picker can come later.
- What WEB does:
-
Unified list has no pagination ("load more") —
P2—missing— fixed in6ae21d5- What WEB does:
loadMoreUnifiedEmails/ cross-view load-more withpositionper account (stores/email-store.ts:4079-4107,1473-1515). - What RN does: fixed
perAccountLimit = 25, noposition, noonEndReached(src/api/unified-inbox.ts:236-241,src/screens/UnifiedInboxScreen.tsx:175-182). - Fix hint: keep a per-account
position, addonEndReachedthat re-queries each account at its own offset and merges.
- What WEB does:
-
Unified search —
P3—missing— fixed in6ae21d5- 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
EmailListScreenand AND{ text: toWildcardQuery(q) }into each account's query.
- What WEB does: text + advanced filter fan-out in all unified/cross views (
-
No actions on unified rows (swipe, star, read, delete, archive, spam, selection) —
P2—missing— fixed in6ae21d5- 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 bysourceAccountId(: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
Pressables (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 detachedjmapPostpath (unified-inbox.ts:116-131) withEmail/setagainstemail.jmapAccountId; for the active account and its group accounts route throughsetEmailKeywords(..., email.jmapAccountId)/moveEmail(...)overrides. (setEmailKeywordsis gone since 5041897; keyword changes now go throughpatchKeywordsForEmailsinsrc/api/email.tsor the store'ssetKeywordForEmails.)
- What WEB does: every list action resolves the email's own client + owner account via
-
Unified list stays stale after acting on a message; just-read mail handling —
P3—partial— fixed in6ae21d5- 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:
UnifiedInboxScreenloads once per mount/includeGroupchange (: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.
- What WEB does: aggregate views refresh through the fan-out on push (
-
Opening a unified-inbox message that is not in the active folder page shows another message's body —
P1—rn-only-bug— fixed in c8be383; the viewer's actions still hit the open folder's account and followed the live list until5c7301band9c31de2(audit B3, B5)- What WEB does: opens the clicked row object itself and fetches by source client/account (#847,
components/mail/mail-app.tsx:3111-3190). - What RN does:
UnifiedInboxScreen.onOpenswitches account then navigates toEmailThread(src/screens/UnifiedInboxScreen.tsx:61-86).EmailThreadScreenpages over the active folder'semails(src/screens/EmailThreadScreen.tsx:131-133, FlatListdata={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 inemails, so index 0 is shown: the pane rendersemails[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-idfindIndexalso collides across accounts (Stalwart reuses id ranges, #847). - Fix hint: when
route.params.emailIdis not found inemails(orjmapAccountIdis set), page over a one-element list[{ id, threadId }]instead ofemails; compare ids together with the owning account.
- What WEB does: opens the clicked row object itself and fetches by source client/account (#847,
-
Unified fetch re-discovers the session and mailboxes on every refresh —
P3—rn-only-bug— fixed in6ae21d5- What RN does:
fetchInboxForAccountdoes/.well-known/jmap+Mailbox/get+Email/query+Email/getper 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 livejmapClient+ existing inbox snapshot for the active account.
- What RN does:
List rendering and behaviour
-
Sent/Drafts rows show the sender ("me") instead of the recipient (1.4.12) —
P2—bugfix-parity— fixed in3194b5e- What WEB does:
showRecipient = role === 'sent' || role === 'drafts'→ usesemail.to[0]for name/avatar (components/email/thread-list-item.tsx:128-131,579-583). - What RN does:
getSenderNamealways readsfrom(src/screens/EmailListScreen.tsx:31-37, row:80-81, avatar:118). - Fix hint: pass
currentMailbox?.roleintoEmailRowand pickto[0] ?? from[0]for sent/drafts (also inUnifiedInboxScreenif role views are added).
- What WEB does:
-
Tags are not rendered on list rows (and no tag row tint) —
P2—missing— fixed in3194b5e- What WEB does:
TagBadges per row fromgetEmailTagIds(both$label:and legacy$color:), thread rows union all messages' tags, optional row tint by first tag (components/email/thread-list-item.tsx:152-155,609-611,lib/thread-utils.ts:187-244,hooks/use-tag-display.ts). - What RN does:
EmailRowshows no tags;tagPill/tagDot/tagTextstyles 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 upuseKeywordsStore().keywordsfor label/colour (unknown ids → grey with raw id), render pills on the subject row; add atintListRowsByTagsetting if desired.
- What WEB does:
-
Tag view (tap a tag → cross-folder
hasKeywordlist, #175) and tag counts in the drawer —P2—missing— fixed in 6ae21d5; the view and the badges cover shared accounts since8d5bced(#1038), and the counts are no longer re-fetched on every drawer open sinced097ae9- 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;EmailFiltershas no keyword field (src/stores/email-store.ts:140-149). - Fix hint: add
keyword?: stringtoEmailFiltersand a "Tags" section in the drawer; when set,buildJmapFiltershould omitinMailbox(search across folders) and add{ hasKeyword: '$label:<id>' }; counts via oneEmail/query(calculateTotal) per tag, optional.
- What WEB does: "Tags" sidebar section with unread/total per tag (
-
Answered / forwarded status icons (1.4.8) —
P3—missing— fixed in3194b5e- What WEB does:
Reply/Forwardicons from$answered/$forwarded(components/email/thread-list-item.tsx:120-121,396-412). - What RN does: none (no reference to
$answeredanywhere insrc/). - Fix hint: add the two icons next to the star/paperclip in
EmailRow.
- What WEB does:
-
Pin uses
$importantinstead of$pinned; no pinned-first order or pin icon —P2—bug— fixed in3194b5e- What WEB does: pin toggles the
$pinnedkeyword (components/mail/mail-app.tsx:2274-2300), rows show aPinicon (thread-list-item.tsx:119), and the query puts$pinnedfirst (lib/jmap/client.ts:1433-1444,buildEmailSortlib/message-list-order.ts:165-195), thread groups too (lib/thread-utils.ts:82-100). - What RN does:
togglePinwrites$important(src/stores/email-store.ts:1151-1173),isPinnedreads$important(src/screens/EmailListScreen.tsx:47-49); no icon, no ordering. A pin set in RN is invisible in WEB and vice versa. - Fix hint: switch the keyword to
$pinned; show a pin icon; optionally sort pinned to the top client-side (server sort needs the polarity probe, see next item).
- What WEB does: pin toggles the
-
Configurable message-list order presets/custom levels (#718) incl. Stalwart
hasKeywordpolarity probe; native issue #5 —P2—partial— fixed inbea3fc4- What WEB does: presets (unread/starred/tagged first) or up to 3 levels mapped to the JMAP sort, Inbox-only or all folders (
lib/message-list-order.ts,components/settings/message-list-order-settings.tsx:52-62,getMessageListOrderForstores/settings-store.ts:1396); polarity probe per account because Stalwart invertsisAscendingon keyword comparators (lib/jmap/client.ts:1376-1431),unsupportedSortfallback (:1490-1500); thread grouping mirrors the order (lib/thread-utils.ts:82). - What RN does: only
receivedAtasc/desc viamailSortAscending(persisted, header togglesrc/screens/EmailListScreen.tsx:699-711, storesetSortAscendingsrc/stores/email-store.ts:1039-1053). This satisfies the "per default" part of native issue #5 but nothing else. - Fix hint: port
message-list-order.ts(pure, no deps) and passbuildEmailSort(levels, …)assorttoqueryEmails/getEmailQueryChanges; probe polarity once per account likeprobeKeywordSortPolarity; add a settings screen with the presets. KeepsetSortAscending's snapshot invalidation for any order change.
- What WEB does: presets (unread/starred/tagged first) or up to 3 levels mapped to the JMAP sort, Inbox-only or all folders (
-
Threading: counts only within the loaded page, no Thread/get counts, no conversation open, representative message wrong in ascending sort —
P2—partial— fixed in3194b5e- What WEB does: groups by
threadId,emailCountfromThread/getacross folders (fetchThreadEmailCountsstores/email-store.ts:3731-3751,lib/thread-utils.ts:14-72), representative = latest email, mobile tap opens the whole conversation viagetThreadEmailsrouted to the owner (#814,components/mail/mail-app.tsx:3240-3275), "collapse all threads". - What RN does: collapses same-
threadIdrows within the page and counts within the page (src/screens/EmailListScreen.tsx:224-248); keeps the first row encountered, i.e. the oldest whenmailSortAscendingis on; tapping opens only that single message (EmailThreadScreenpages overemails, noThread/getanywhere). - Fix hint:
Thread/getfor the visible thread ids (chunked) to get real counts; pick the newest message as representative; a conversation screen belongs to the viewer area but the list should hand overthreadId+ owner account.
- What WEB does: groups by
-
Drafts do not open in the composer / no "Edit draft" —
P2—missing— fixed in3194b5e- What WEB does: a
$draftmessage opens the composer (handleEditDraftcomponents/mail/mail-app.tsx:1812, listonEditDraftcomponents/email/email-list.tsx:47,616, context menucomponents/email/email-context-menu.tsx:225-233); viewer shows a draft banner. - What RN does: tapping a draft opens the read-only
EmailThreadScreen;Composeparams have no draft/edit mode (src/navigation/types.ts:7-24), locale keysemail_viewer.draft_banner/edit_draftexist but are unused. - Fix hint: in
handleRowPress, ifitem.keywords.$draft(or folder role is drafts) navigate toComposewith adraft: { id, to, cc, bcc, subject, body, blobId }param; on send, destroy the old draft (composer area).
- What WEB does: a
-
Permanent delete without confirmation (Trash,
deleteAction: permanent, junk auto-permanent) —P2—bugfix-parity— list side fixed in3194b5e(single, batch, swipe viasrc/lib/delete-confirm.ts); viewer side done ind2ed27f(src/screens/EmailThreadScreen.tsx)- 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/deleteEmailsBatchdestroy immediately when in Trash, whendeleteAction === '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
destroyin the screen before calling the store (same rule) and showAlert.alertwith Cancel/Delete; same for the thread screenonDelete.
- What WEB does: confirm dialog before any permanent destroy, single and batch (
-
"Never" mark-as-read (
markAsReadDelay === -1) marks read instantly —P2—rn-only-bug— fixed ind2ed27f- What WEB does:
-1→ never,0→ instant, else timer (components/mail/mail-app.tsx:1511-1528). - What RN does:
if (markAsReadDelay > 0) { timer } else { markRead() }(src/screens/EmailThreadScreen.tsx:236-245) so the "Never" option inReadingSettings.tsx:171behaves like "Instant". - Fix hint: add
if (markAsReadDelay === -1) return;before the branch.
- What WEB does:
-
Load-more appends duplicates when new mail shifts positions —
P3—rn-only-bug— fixed inf07e039- What WEB does: filters
newEmailsby existing ids before appending (stores/email-store.ts:1620-1625). - What RN does:
merged = [...emails, ...newEmails]with no dedup (src/stores/email-store.ts:714-716); a message that arrived between pages appears twice (duplicate FlatList keys, double rows). - Fix hint:
newEmails.filter(e => !existingIds.has(e.id)).
- What WEB does: filters
-
Incremental-refresh window "cap" is a no-op —
P3—rn-only-bug— fixed inf07e039- What RN does:
out.slice(0, Math.max(limit, out.length))(src/stores/email-store.ts:885) never trims although the comment says it caps the window to the page size; harmless but misleading (the snapshot can grow pastemailsPerPage). - Fix hint: either drop the line and comment, or use
Math.max(limit, baseEmails.length)if the intent is "at least the previous window".
- What RN does:
-
Swipe "spam" available in Sent/Drafts and no "not spam" in Junk —
P3—rn-only-bug— fixed in3194b5e- What WEB does: spam action hidden for sent/drafts/scheduled and flips to "not spam" inside Junk (
components/email/thread-list-item.tsx:523,components/email/email-hover-actions.tsx:117-140, changelog 1.7.7). - What RN does:
handleSwipeAction 'spam'only checkscurrentMailboxId !== junkMailboxId(src/screens/EmailListScreen.tsx:379-388), so a swipe in Sent moves your own mail to Junk; in Junk the swipe silently does nothing. - Fix hint: skip when
currentMailbox.roleis sent/drafts; in junk call anundoSpam(see next item) and change the band label to "Not spam".
- What WEB does: spam action hidden for sent/drafts/scheduled and flips to "not spam" inside Junk (
-
Spam / not-spam do not flip
$junk/$notjunk(#850) nor honour "trash-and-read" —P2—bugfix-parity— fixed in 3194b5e; the viewer's Spam and Not spam stayed a plain move until5e42c67(#695)- What WEB does:
markAsSpampatchesmailboxIds+keywords/$junk: true+keywords/$notjunk: null(+$seenwhendeleteAction === 'trash-and-read');undoSpamrestores the original mailbox with$junk: null,$notjunk: true(lib/jmap/client.ts:2422-2475, storestores/email-store.ts:2947-3100, undo toast incomponents/mail/mail-app.tsx:2228-2270). - What RN does: swipe and thread-screen spam are plain
moveToMailboxcalls (src/screens/EmailListScreen.tsx:381,src/screens/EmailThreadScreen.tsx:344-356); the keywords stay untouched, so other clients/Stalwart's classifier never learn; undo label reads "Email moved to Junk". - Fix hint: add
markAsSpam(ids, junkRawId, accountId, alsoMarkRead)/undoSpam(ids, targetRawId, accountId)tosrc/api/email.tswriting the keyword pointers; store actions withpendingUndo.kind = 'spam'(type already exists,src/stores/email-store.ts:155) whoseundoLastalso restores the keywords; queue-safe via an outboxkeywordsop.
- What WEB does:
-
Batch spam / batch not-spam —
P3—missing— fixed in3194b5e- What WEB does:
batchMarkAsSpam/batchUndoSpam(stores/email-store.ts:3104-3236), "Not spam" button in the Junk selection toolbar (components/email/email-list.tsx:396-411, changelog 1.7.8). - What RN does: selection header has star/read/tag/move/archive/delete only (
src/screens/EmailListScreen.tsx:572-630). - Fix hint: add a shield button that calls the batch spam/undo-spam from the previous item depending on
currentMailbox.role === 'junk'.
- What WEB does:
-
Selecting a collapsed thread row selects/acts on only its newest message —
P2—bug— fixed in3194b5e- What WEB does: the thread checkbox toggles every message of the thread (
toggleThreadSelection,components/email/thread-list-item.tsx:640-660); single delete/archive on a thread row usesmoveThreadToMailboxfor archive (components/mail/mail-app.tsx:2141-2180). - What RN does:
selectedIdsholds the representative id only (src/screens/EmailListScreen.tsx:336-352,436-442), so "3 selected" may be 3 conversations but batch delete/move/archive/tag touch one message each (deleteEmailsBatchfiltersemailsby id,email-store.ts:1443); swipe actions on a thread row likewise act on one message. - Fix hint: when threading is on, expand each selected representative to all
emailswith the samethreadIdbefore calling the batch store actions (and for swipe archive/delete/move on rows withthreadCount > 1).
- What WEB does: the thread checkbox toggles every message of the thread (
-
Keep search/filters when switching folders (#553) —
P3—bugfix-parity— fixed inf07e039- What WEB does: re-runs the active text/advanced search in the newly selected folder (
components/mail/mail-app.tsx:2501-2512). - What RN does:
selectMailboxresetssearchQueryandfilters(src/stores/email-store.ts:676-677) and the list input follows (EmailListScreen.tsx:504-511). - Fix hint: keep
searchQuery/filtersinselectMailbox(seed the base snapshot only when they are empty) so the search re-runs in the new folder; offer "Clear" as today.
- What WEB does: re-runs the active text/advanced search in the newly selected folder (
-
Just-read mail dropped from an open Unread-filtered list on the next push refresh —
P3—bugfix-parity— fixed inf07e039- What WEB does: retains rows the user just read/unstarred in the Unread/Starred views until the view is re-opened (
stores/email-store.ts:1007-1050,mergeRetainedRows; changelog 1.9.0). - What RN does:
markReadpatches the row in place, but the nextrefreshEmails(push or pull) re-runs thenotKeyword $seenquery (src/stores/email-store.ts:279,917-944) and the row vanishes while the user is looking at it. - Fix hint: keep a
retainedIdsset per filter session; after a full re-query, splice retained rows (from the previousemails) back at their old index.
- What WEB does: retains rows the user just read/unstarred in the Unread/Starred views until the view is re-opened (
-
Search runs on every keystroke —
P3—rn-only-bug— fixed in3194b5e- What WEB does: searches on submit / suggestion pick (
components/search/search-box.tsx:108-115). - What RN does: 300 ms debounce into
setSearchQuery→ fullEmail/query+Email/getper 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.
- What WEB does: searches on submit / suggestion pick (
-
Date format: no regional date-locale setting; relative strings hard-coded —
P3—partial— partly:0205aa0localized the relative strings; there is still nodateLocalesetting (see area 08, "Language list not localized")- What WEB does: user-selectable regional format (
dateLocale, changelog 1.7.7) and a preset picker (#331); strings localized. - What RN does:
formatListDatefollows 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 adateLocaleselect if parity is wanted.
- What WEB does: user-selectable regional format (
-
Show avatars in Junk (off by default, 1.5.1); avatar priority —
P3—partial— fixed in bea3fc4; contact photos win over favicons and initials sinceeca3871(#54)- What WEB does:
showAvatarsInJunkhides favicon/photo images in Junk (components/email/thread-list-item.tsx:137); avatar priority contact photo > plugin (Gravatar) > favicon > initials (components/ui/avatar.tsx:236). - What RN does: favicons always load, including in Junk (
src/components/SenderAvatar.tsx:37); no contact-photo lookup. - Fix hint: pass
disableImages={currentMailbox.role === 'junk' && !showAvatarsInJunk}intoSenderAvatar; contact photos optional.
- What WEB does:
-
Per-message actions from the list (mobile long-press menu) —
P3—partial— fixed in3194b5e- 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.
- What WEB does: long-press on mobile opens the context menu (reply/forward/move/tag/pin/spam/mark read/copy link) (
-
Own → shared/group folder move (cross-account move, 1.7.2) —
P3—missing— fixed in0205aa0- 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);MoveSheetis scoped to the same account so the message is only reachable by a future picker.MoveSheetalso 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/getblob →Blob/uploadto the owner →Email/import→ destroy (import helper already exists insrc/api/email.ts:423).
- What WEB does: copy+delete via
-
Undo snackbar / toast strings not localized; move toast lacks folder path —
P3—partial— fixed in3194b5e- What WEB does: toasts localized, full folder path in move toast (changelog 1.5.0).
- What RN does: hard-coded English labels in the store (
src/stores/email-store.ts:1202, 1252, 1328, 1379, 1425, 1520), "UNDO" (UndoSnackbar.tsx:73), list empty/loading strings (EmailListScreen.tsx:805-830), filter modal labels,MoveSheet/TagSheettitles. - Fix hint: route through
useLocaleStore().t(keys already exist for many:email_list.*,notifications.*).
Search
-
Search folder scope: always the current folder; no all-folders default (#788) and no folder picker —
P2—bugfix-parity— fixed in 3194b5e; "All folders" only searched the open folder's account untilee50418(#1082)- What WEB does:
searchMailboxIddefaults to""= all folders, is a separate, persisted choice in the filter panel (stores/email-store.ts:95-101,2344-2378, changelog 1.9.0 #788); cross-mailbox queries. - What RN does:
buildJmapFilteralways starts with{ inMailbox: current }(src/stores/email-store.ts:255); the filter modal has no folder field. - Fix hint: add
folder?: string | 'all'toEmailFilters(default'all'while a text query or filter is active, i.e. omitinMailbox); expose a folder select in the modal; the base-view snapshot logic already keys offisBaseView.
- What WEB does:
-
Body filter, per-chip removal —
P3—partial— fixed in3194b5e- What WEB does:
bodycondition (lib/jmap/search-utils.ts:62-64); each chip has an X (components/search/search-chips.tsx:260-266). - What RN does: no
bodyfield; chips are read-only, only "Clear" all (src/screens/EmailListScreen.tsx:725-796). - Fix hint: add
bodytoEmailFilters/buildJmapFilter; make each chip's X callsetFilterField(key, undefined).
- What WEB does:
-
Search suggestions and recent-search history (#845) —
P3—missing— fixed in3194b5e- What WEB does: recent searches (max 10, persisted) + contact/sender matches under the search box, keyboard navigable, pick a contact →
from:/to:filter (stores/search-history-store.ts,lib/search-suggestions.ts:183,components/search/search-box.tsx:34,components/mail/mail-app.tsx:2807-2826). - What RN does: plain
TextInput(EmailListScreen.tsx:677-698). - Fix hint: small zustand store persisted in AsyncStorage; render a dropdown under the input when focused (recent +
useContactsStorematches); tapping a contact setsfilters.from.
- What WEB does: recent searches (max 10, persisted) + contact/sender matches under the search box, keyboard navigable, pick a contact →
Tags / keywords
-
Tag definition features: visibility, nesting/parent, reorder, rename with migration, unknown tags —
P3—partial— partial in3194b5e: 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 idsparent/child(lib/keyword-nesting.ts,keyword-settings.tsx:213-243), drag reorder (atomic, changelog 1.9.0), rename keyword migrates messages (renameKeyword/client.migrateKeywordlib/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);TagSheetlists defined tags only (src/components/TagSheet.tsx:76-95), so a$label:work/clientsset 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 ofTagSheet; addvisibilitywhen the drawer tag section lands; nesting/reorder are lower priority.
- What WEB does:
-
"Reset defaults" wipes the tag list with no confirmation (removed in WEB 1.8.1) —
P3—bugfix-parity— fixed in6ae21d5- 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.alertconfirm.
-
Scan for keywords no local tag explains (#658) —
P3—missing— fixed in6ae21d5- What WEB does:
discoverKeywordspagesEmail/query+Email/get keywordsand 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 inKeywordSettings.
- What WEB does:
-
Keyword palette parity —
P3—partial— fixed in6ae21d5- What WEB does:
KEYWORD_PALETTEkeys (stores/settings-store.ts:226-280) are the tag ids of the default tags and the colour names used bysuggestKeywordColor. - What RN does:
colors.tagskeys insrc/theme/tokens.ts; defaults areimportant/work/personal/todo(src/stores/keywords-store.ts:15-20) whereas WEB'sDEFAULT_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.
- What WEB does:
Offline cache (RN only, sanity check)
- Offline sync covers the primary account only; shared folders never cached —
P3—rn-only-bug— fixed in0205aa0- What RN does:
runOfflineSyncdiscovers viaqueryEmailsByFilterwhich hard-codesjmapClient.accountId(src/lib/offline-sync.ts:55,src/api/email.ts:733-747), so opening a group-folder message offline always fails;selectMailboxseeding for a shared folder therefore always yields nothing (src/stores/email-store.ts:647-666looks up by raw id, which is correct). - Fix hint: iterate
jmapClient.getSharedMailAccounts()in the sync withaccountIdOverride, storing the account id in the cache index (getFullEmails(ids, accountId)already exists).
- What RN does:
Verified at parity (do not redo)
- Folder tree: nesting, per-account grouping of shared/group accounts with unread roll-up, expanded state persisted, role priority sort (
src/lib/mailbox-tree.ts,SidebarDrawer.tsx); create/rename/delete own folders with server error surfaced (FolderSettings.tsx); delete-with-emails confirm. - Shared/group folders: queries and every mutation (read/star/pin/tag/move/archive/delete/undo/import) routed to the owner account with the unprefixed id (
refFor,src/stores/email-store.ts:64-99); thread screen fetches by owner (EmailThreadScreen.tsx:90-96); state-change handling per account (email-store.ts:981-1015). - Folder tree preserved when a refresh fails (#780 equivalent,
email-store.ts:604-612), shared-account failure isolated (:595-601); Mailbox/changes incremental sync with drain. - Missing Archive/Trash/Junk → error alert (
EmailListScreen.tsx:363-387), never silently destroys when Trash is missing (#195 equivalent). - Delete semantics: trash / trash-and-read (#323) / permanent / junk auto-permanent, single and batch (
email-store.ts:1268-1335,1434-1527); undo snackbar for archive/delete/move (single + batch) — RN is ahead of WEB here. - Archive single / by year / by month with folder creation in one request, batch archive, "Reorganize existing archive" (
src/api/email.ts:579-692,ReadingSettings.tsx:105-159). - Swipe actions: configurable per direction, instant and reveal modes, pure gesture module with tests, stale-props fix (
SwipeableRow.tsx,swipe-gesture.ts); more actions than WEB (pin, move). RTL: ar, he and fa ship since990cd84, but swipe directions are not mirrored yet (area 08, RTL finding). - Multi-select: long-press, select-all-visible/indeterminate, batch star/read/tag/move/archive/delete (
EmailListScreen.tsx:320-500). - Pagination via
onEndReached, pull-to-refresh, incrementalEmail/queryChanges+Email/changesrefresh with per-account state tokens, base-view restore after search (#10), sort change invalidates snapshots (#5), offline seed and fallback. - Search: full-text with wildcard suffix (
src/lib/search-utils.ts= WEBtoWildcardQuery), from/to/subject/date/attachment/unread/starred tri-state filters, chips row, search inside a shared folder routed to its owner. - Keyword writes send RFC 6901-escaped
keywords/<name>patch pointers since5041897(src/api/patch-pointer.ts; the whole-map writes erased other keywords, audit B4), withnullinstead offalse— not affected by 5c484af1 / be97c8bf; tag ids normalized exactly like WEBnormalizeKeywordLevel(KeywordSettings.tsx:118-123); batch tag toggle viaTagSheetwith "all have it" semantics. - Unread dot (#27), date formats smart/relative/full with 12/24h, preview toggle, density-aware rows, sender favicon avatars with failure cache,
.eml/.zipimport into the open folder, Scheduled quick view, "Include group inboxes" setting and shared badge/account dot in the unified list. - Mark-as-read delay (0/3s/5s) in the thread screen; both clients still display
receivedAtin the list (WEBlib/email-date.tsis not wired into the list yet).
N/A on mobile
- Category tabs / message-list tabs (
stores/message-list-tabs-store.ts) — plugin-registered; RN has no plugin runtime. - Hover quick actions, keyboard navigation/shortcuts, right-click context menus, drag-and-drop of mail onto folders/tags and folder-tab drag (Pro) — desktop interactions; RN has swipe + selection instead.
- Admin policy gates for unified/cross views (
usePolicyStorefeature flags) — RN has no admin policy channel; only the user-side toggles apply. - Return-to-list-after-action setting — the RN thread screen always returns after delete/archive/spam.
- "Refresh" toolbar button and F5/Ctrl+R interception — pull-to-refresh covers it.
- Favicon unread badge (its native counterpart, the app-icon badge, landed in
25c0883, iOS only), sidebar collapse/resize, Pro multi-pane shell, a folder subscription UI (neither client showsisSubscribed; both subscribe the folders they create, native since30aea63, #951). - Plugin search hooks / external search results,
onEmailsFetchedtransforms. - Source-folder chip on rows is N/A until All-mail/cross views exist in RN.