@rapidmx/web-client 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +373 -373
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -480
  4. package/apps/admin/domains/[uid].tsx +164 -164
  5. package/apps/admin/escrow-scopes/[uid].tsx +348 -348
  6. package/apps/admin/index.tsx +127 -124
  7. package/apps/admin/mailboxes/[uid].tsx +269 -224
  8. package/apps/admin/mailboxes/new/index.tsx +28 -28
  9. package/apps/admin/plugins/index.tsx +15 -15
  10. package/apps/admin/retention-policy/index.tsx +39 -39
  11. package/apps/admin/signing-certificates/index.tsx +343 -343
  12. package/apps/escrow/audit-log/index.tsx +196 -196
  13. package/apps/escrow/matters/[uid].tsx +621 -621
  14. package/apps/shared/auth/adminAccess.ts +99 -99
  15. package/apps/shared/components/admin/layout/AdminShell.tsx +378 -378
  16. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
  17. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  18. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
  19. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  20. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  21. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
  22. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  23. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
  24. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  25. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  26. package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
  27. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  28. package/apps/shared/components/calendar/EventEditor.tsx +5 -10
  29. package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
  30. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  31. package/apps/shared/components/calendar/GuestInput.tsx +100 -0
  32. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  33. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  34. package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
  35. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  36. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  37. package/apps/shared/components/calendar/allDay.ts +124 -124
  38. package/apps/shared/components/calendar/eventForm.ts +2 -2
  39. package/apps/shared/components/calendar/eventFormat.ts +49 -22
  40. package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
  41. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
  43. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
  44. package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  46. package/apps/shared/components/layout/AppShell.tsx +480 -480
  47. package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  50. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  51. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  52. package/apps/shared/components/layout/UserMenu.tsx +439 -424
  53. package/apps/shared/components/mail/ConversationList.tsx +323 -323
  54. package/apps/shared/components/mail/ConversationThreadPane.tsx +612 -491
  55. package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
  56. package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1559
  57. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  58. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  59. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
  60. package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
  61. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  62. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  63. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  64. package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
  65. package/apps/shared/components/mail/layout/MailShell.tsx +137 -113
  66. package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
  67. package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
  68. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  69. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  70. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  71. package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -254
  72. package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
  73. package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
  74. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  75. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  76. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  77. package/apps/shared/keyboard/dispatch.ts +124 -124
  78. package/apps/shared/keyboard/format.ts +89 -89
  79. package/apps/shared/keyboard/keymap.ts +114 -114
  80. package/apps/shared/keyboard/registry.ts +65 -65
  81. package/apps/shared/keyboard/targets.ts +79 -79
  82. package/apps/shared/mail/folderCounts.ts +17 -0
  83. package/apps/shared/mail/folderTree.ts +143 -143
  84. package/apps/shared/mail/listAllPages.ts +39 -39
  85. package/apps/shared/mail/newMailNotifications.ts +171 -171
  86. package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
  87. package/apps/shared/mail/outbox/sendJob.ts +19 -1
  88. package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
  89. package/apps/shared/mail/primaryMailbox.ts +71 -0
  90. package/apps/shared/mail/useCollapsedSections.ts +146 -0
  91. package/apps/shared/mail/useMailConnection.ts +205 -203
  92. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  93. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  94. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  95. package/apps/shared/navigation/AppRouter.tsx +300 -300
  96. package/apps/shared/navigation/routerContext.tsx +83 -83
  97. package/apps/shared/notifications/store.ts +560 -560
  98. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  99. package/apps/shared/search/crossMailboxSearch.ts +169 -0
  100. package/apps/shared/search/localIndexBuilder.ts +481 -481
  101. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  102. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  103. package/apps/shared/signing/enrollmentView.ts +251 -251
  104. package/apps/shared/signing/useNow.ts +19 -19
  105. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  106. package/apps/shared/styles/app.css +396 -386
  107. package/apps/www/calendar/index.tsx +578 -571
  108. package/apps/www/contacts/[uid].tsx +109 -109
  109. package/apps/www/contacts/index.tsx +6 -0
  110. package/apps/www/index.tsx +2847 -2479
  111. package/apps/www/messages/[uid].tsx +135 -135
  112. package/apps/www/settings/auto-reply/index.tsx +133 -133
  113. package/apps/www/settings/encryption/index.tsx +1287 -1287
  114. package/apps/www/settings/filters/[uid].tsx +176 -176
  115. package/apps/www/settings/filters/index.tsx +102 -102
  116. package/apps/www/settings/filters/new/index.tsx +140 -140
  117. package/apps/www/settings/labels/index.tsx +204 -204
  118. package/apps/www/settings/privacy/index.tsx +492 -492
  119. package/apps/www/settings/profile/index.tsx +248 -248
  120. package/apps/www/settings/read-receipts/index.tsx +147 -147
  121. package/apps/www/settings/sharing/index.tsx +256 -256
  122. package/apps/www/settings/signatures/[uid].tsx +172 -172
  123. package/apps/www/settings/signatures/index.tsx +88 -88
  124. package/apps/www/settings/signatures/new/index.tsx +135 -135
  125. package/apps/www/tasks/index.tsx +649 -649
  126. package/dist/apps/admin/data-requests/index.js +1 -1
  127. package/dist/apps/admin/index.js +2 -1
  128. package/dist/apps/admin/mailboxes/[uid].js +12 -2
  129. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
  130. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
  131. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
  132. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
  133. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
  134. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
  136. package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
  137. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
  138. package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
  139. package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
  140. package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
  141. package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
  142. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
  143. package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
  144. package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
  145. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
  146. package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
  147. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  148. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
  149. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
  150. package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
  151. package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
  152. package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
  153. package/dist/apps/shared/components/layout/UserMenu.js +6 -5
  154. package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
  155. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
  156. package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
  157. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
  158. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
  159. package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
  160. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
  161. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
  162. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  163. package/dist/apps/shared/components/mail/layout/MailShell.js +64 -45
  164. package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
  165. package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
  166. package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
  167. package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
  168. package/dist/apps/shared/components/settings/layout/SettingsShell.js +6 -5
  169. package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
  170. package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
  171. package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
  172. package/dist/apps/shared/mail/folderCounts.js +12 -0
  173. package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
  174. package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
  175. package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
  176. package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
  177. package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
  178. package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
  179. package/dist/apps/shared/mail/primaryMailbox.js +61 -0
  180. package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
  181. package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
  182. package/dist/apps/shared/mail/useMailConnection.js +3 -1
  183. package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
  184. package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
  185. package/dist/apps/shared/styles/app.css +396 -386
  186. package/dist/apps/www/calendar/index.js +3 -2
  187. package/dist/apps/www/contacts/index.js +3 -1
  188. package/dist/apps/www/index.js +426 -189
  189. package/package.json +2 -2
@@ -16,8 +16,8 @@ import { listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
16
16
  import { listConversationMessages, listConversations, } from "@rapidmx/react-shared/mail/conversationsApi.js";
17
17
  import { search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
18
18
  import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
19
- import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
20
19
  import { searchLocalIndex } from "../shared/search/searchTier2.js";
20
+ import { SEARCH_LIMITS, classifyFailure, describeFailures, hitKey, mergeSearchResults, runLimited, searchPageSize, tagHits, withTimeout, } from "../shared/search/crossMailboxSearch.js";
21
21
  import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
22
22
  import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
23
23
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
@@ -25,6 +25,7 @@ import MailShell, { useMailShell, } from "../shared/components/mail/layout/MailS
25
25
  import MailAddress from "../shared/components/mail/MailAddress.js";
26
26
  import OutboxRowStatus from "../shared/components/mail/OutboxRowStatus.js";
27
27
  import { mergeFirstPage } from "../shared/mail/mergeFirstPage.js";
28
+ import { isOwnMailbox, orderMailboxes, primaryMailboxUid } from "../shared/mail/primaryMailbox.js";
28
29
  import { listSnapshotKey, readListSnapshot, saveListScroll, writeListSnapshot } from "../shared/mail/listSnapshots.js";
29
30
  import { setReadStateMany } from "../shared/mail/messageReadState.js";
30
31
  import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
@@ -82,12 +83,14 @@ function stripHtmlToText(html) {
82
83
  */
83
84
  async function decryptEncryptedRows(messages, unlocked) {
84
85
  const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
86
+ // Imported once for all the rows, by whichever needs it first.
87
+ let securityModule;
85
88
  const entries = await Promise.all(encrypted.map(async (message) => {
86
89
  try {
87
90
  const rawMime = await getMessageRawContent(message.uid);
88
91
  // Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
89
92
  // no encrypted mail in it never needs it.
90
- const { evaluateMessageSecurity } = await import("@rapidmx/react-shared/crypto/messageSecurity.js");
93
+ const { evaluateMessageSecurity } = await (securityModule ?? (securityModule = import("@rapidmx/react-shared/crypto/messageSecurity.js")));
91
94
  const security = await evaluateMessageSecurity(rawMime, unlocked);
92
95
  if (!security.subject && !security.html) {
93
96
  return null;
@@ -107,53 +110,20 @@ async function decryptEncryptedRows(messages, unlocked) {
107
110
  }
108
111
  return result;
109
112
  }
110
- /** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message), Tier 2 (local index, fully
111
- * decrypted and re-scored), and Tier 3 (server-narrowed candidates, decrypted and re-scored) results into
112
- * one ranked list, per `specs/search.md` §7's "client MUST re-score all results it can see... normalise
113
- * into the same space rather than interleaving raw scores": each tier is normalized independently via
114
- * `normalizeServerScores()` before merging, since a Postgres/OpenSearch score, a local `bm25()` score,
115
- * and this module's own Tier 3 term-count score all occupy unrelated ranges. A uid present in more than
116
- * one list keeps only the last-inserted entry (Tier 2 wins over Tier 3 wins over Tier 1) - Tier 2 and
117
- * Tier 3 both represent genuine, content-verified scores for the same message, so which one "wins" on
118
- * overlap doesn't change correctness, only which of two equally-valid scores is shown; either supersedes
119
- * Tier 1's metadata-only guess for the same uid.
120
- *
121
- * Called progressively - once per tier as it resolves, each time with whatever tiers have reported so
122
- * far (an empty array for the rest) - by `InboxContent`'s own search orchestration below, per §_Progressive
123
- * Results_' "reordering is permitted and preferred over appending." This function itself stays pure and
124
- * stateless; it has no notion of "in progress" versus "final." */
125
- function mergeSearchResults(tier1, tier2, tier3) {
126
- const normalizedTier1 = normalizeServerScores(tier1);
127
- const normalizedTier2 = normalizeServerScores(tier2);
128
- const normalizedTier3 = normalizeServerScores(tier3);
129
- const merged = new Map();
130
- for (const entry of normalizedTier1) {
131
- merged.set(entry.result.entityUid, entry);
132
- }
133
- for (const entry of normalizedTier3) {
134
- merged.set(entry.result.entityUid, entry);
135
- }
136
- for (const entry of normalizedTier2) {
137
- merged.set(entry.result.entityUid, entry);
138
- }
139
- return Array.from(merged.values())
140
- .sort((a, b) => b.normalizedScore - a.normalizedScore)
141
- .map((entry) => entry.result);
142
- }
143
113
  /** `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
144
114
  * (contact/calendarEvent/note/task) has no `Message` to resolve via `getMessage()` below and is simply
145
115
  * dropped by the same eventually-consistent-index fallback that already existed, rather than rendered
146
116
  * (this inbox list only ever shows message rows; a real multi-entity-type results view is a separate,
147
117
  * larger UI project outside this pass). Shared by both the fresh-search orchestration and `loadMore()`
148
118
  * below, which each build this from the same `ParsedSearchQuery` differently only in `cursor`. */
149
- function tier1SearchParams(parsed, cursor, mailboxUid) {
119
+ function tier1SearchParams(parsed, cursor, mailboxUid, limit) {
150
120
  return {
151
- // The mailbox actually open - omitted, the server searches the caller's *own* mailbox, which is the
152
- // wrong one whenever a shared mailbox's folder is being viewed.
121
+ // The mailbox being searched - omitted, the server searches the caller's *own* mailbox, which is the
122
+ // wrong one whenever a shared mailbox's folder is being viewed (or several mailboxes are searched at once).
153
123
  mailboxUid,
154
124
  types: parsed.entityTypes ?? ["message"],
155
125
  cursor,
156
- limit: MESSAGE_PAGE_SIZE,
126
+ limit,
157
127
  from: parsed.from,
158
128
  to: parsed.to,
159
129
  cc: parsed.cc,
@@ -239,9 +209,10 @@ function tier3Windows(parsed, coverage, searchAllMail) {
239
209
  }
240
210
  /** Runs Tier 3 over each window and merges the candidates (a uid can't match in two disjoint windows, but a
241
211
  * message whose date sits exactly on a boundary may come back from both). */
242
- async function searchTier3Windows(windows, unlocked, mailboxUid) {
243
- // Loaded on first use, with the S/MIME code it decrypts through (see `decryptEncryptedRows()`).
244
- const { searchEncryptedCandidates } = await import("@rapidmx/react-shared/search/searchTier3.js");
212
+ async function searchTier3Windows(windows, unlocked, mailboxUid, loadTier3) {
213
+ // Loaded on first use, with the S/MIME code it decrypts through (see `decryptEncryptedRows()`) - once for however many mailboxes a search
214
+ // covers: `loadTier3` hands every one of them the same import.
215
+ const { searchEncryptedCandidates } = await loadTier3();
245
216
  const pages = await Promise.all(windows.map((window) => searchEncryptedCandidates(window, unlocked, TIER3_CANDIDATE_LIMIT, { mailboxUid })));
246
217
  const merged = new Map();
247
218
  for (const result of pages.flat()) {
@@ -256,6 +227,10 @@ async function searchTier3Windows(windows, unlocked, mailboxUid) {
256
227
  function decodeCursor(raw, fingerprint) {
257
228
  return raw?.fingerprint === fingerprint ? raw : undefined;
258
229
  }
230
+ /** The mailboxes a search's `cursor` can still be paged for. */
231
+ function mailboxesWithMore(cursor) {
232
+ return [...cursor.perMailbox].filter(([, mailboxCursor]) => mailboxCursor.more).map(([uid]) => uid);
233
+ }
259
234
  /** Resolves every hit's `entityUid` to a full `Message` via `getMessage()`, reusing `cache` across
260
235
  * repeated calls within the same search pass - `InboxContent`'s own search orchestration below re-merges
261
236
  * and re-resolves the *entire* current hit set each time a tier resolves, so without this cache every
@@ -370,17 +345,18 @@ async function fetchAggregateMessages(mailboxFolders, type, filter) {
370
345
  }));
371
346
  return mergeInboxMessages(perMailbox);
372
347
  }
373
- /** Groups search results by the conversation each message belongs to, the groups in the order of their first result. */
348
+ /** Groups search results by the conversation each message belongs to - within its own mailbox - the groups in the order of their first result. */
374
349
  function groupByConversation(results) {
375
350
  const groups = new Map();
376
351
  for (const message of results) {
377
352
  const id = message.conversationId ?? message.uid;
378
- const group = groups.get(id);
353
+ const key = `${message.mailboxUid}\u0000${id}`;
354
+ const group = groups.get(key);
379
355
  if (group) {
380
356
  group.messages.push(message);
381
357
  }
382
358
  else {
383
- groups.set(id, { id, messages: [message] });
359
+ groups.set(key, { id, mailboxUid: message.mailboxUid, messages: [message] });
384
360
  }
385
361
  }
386
362
  return [...groups.values()];
@@ -413,6 +389,7 @@ function InboxContent({ userUid }) {
413
389
  const [selectedUid, setSelectedUid] = useState(null);
414
390
  // The thread the conversation list opened, and which of its messages was picked - the reading pane
415
391
  // shows the whole conversation, positioned at that message (see `ConversationThreadPane`).
392
+ // The mailbox is the open thread's own: a search over several mailboxes can open one that is not the page's mailbox.
416
393
  const [openThread, setOpenThread] = useState(null);
417
394
  // Messages the page has a newer copy of than `ConversationList` fetched (so far only the one the reading
418
395
  // pane just marked read), applied over its own child rows so they don't stay bold after being read.
@@ -445,9 +422,15 @@ function InboxContent({ userUid }) {
445
422
  // search and aggregate views it can be, and its labels must not be offered as a filter for this one.
446
423
  // Empty (and never fetched) otherwise - see `labels` below.
447
424
  const [otherMailboxLabels, setOtherMailboxLabels] = useState([]);
448
- // Tier 2's own reported window coverage for the current search - undefined outside a search, or
449
- // before Tier 2 has resolved yet for this search pass.
450
- const [coverage, setCoverage] = useState(undefined);
425
+ // Tier 2's own reported window coverage for each mailbox the current search covers - empty outside a search, and
426
+ // holding nothing for a mailbox until Tier 2 has resolved for it in this search pass (or at all, for one without a local index).
427
+ const [coverages, setCoverages] = useState({});
428
+ // The mailboxes the current search could not (fully) search, and why - only a search over several mailboxes carries on past a
429
+ // failed one (a search of one mailbox fails as a whole, as it always has).
430
+ const [searchFailures, setSearchFailures] = useState([]);
431
+ // "Search all mailboxes" from a mailbox's own folder: the search is widened to every mailbox the reader can read. Reset with the search
432
+ // (see the effects below), and by leaving the folder.
433
+ const [searchAllMailboxes, setSearchAllMailboxes] = useState(false);
451
434
  // Keyed by message uid - see decryptEncryptedRows(). Never cleared on folder/search switches (a
452
435
  // decrypted row stays decrypted while its keys stay unlocked; re-decrypting on every navigation would
453
436
  // waste work for no benefit) - only cleared when the mailbox's keys are locked (see the
@@ -481,7 +464,8 @@ function InboxContent({ userUid }) {
481
464
  // aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
482
465
  // own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
483
466
  // comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
484
- const activeMailboxUid = mailboxUid ?? mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
467
+ // Always set once this page renders: a caller with no mailbox at all gets `MailboxProvisioning` from the shell instead.
468
+ const activeMailboxUid = (mailboxUid ?? primaryMailboxUid(mailboxes, userUid));
485
469
  const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
486
470
  // The Sort/Filter menus' and "Show as conversations"' current settings, remembered per mailbox across
487
471
  // reloads (`listPreferences.ts`). Read during render, not in an effect, so the very first listing
@@ -496,14 +480,32 @@ function InboxContent({ userUid }) {
496
480
  setPreferencesByMailbox((prev) => ({ ...prev, [activeMailboxUid]: next }));
497
481
  setMailListPreferences(activeMailboxUid, next);
498
482
  }
499
- // Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
500
- // to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
501
- // branch below, which the search effect never reaches while `folderUid` is unset).
483
+ // What a search covers. In a mailbox's own folder it is that mailbox (all of its folders, as it always was); in an "All mailboxes" view - or
484
+ // once the reader has widened it from a folder - it is every mailbox they can read, the very set those views aggregate: the ones whose
485
+ // folders could be listed (a mailbox whose listing failed, say a delegate grant without mail access, is not readable). Tier 1/2/3 are all
486
+ // mailbox-scoped, so a search over several is the same per-mailbox search once for each, merged (see the search effect below). Own
487
+ // mailboxes come first, so the cap on how many are searched at once leaves out shared ones before the reader's own.
488
+ const readableMailboxes = mailboxFolders.filter((mf) => !mf.error).map((mf) => mf.mailbox);
489
+ const crossMailbox = !!aggregateFolderType || searchAllMailboxes;
490
+ // `in:<folder>` names a folder, and a folder belongs to one mailbox: only that mailbox can have results, so only it is searched (a folder none of
491
+ // them has is left to the server to say so of every mailbox).
492
+ const inFolderUid = crossMailbox ? parseSearchQuery(searchQuery).folderUid : undefined;
493
+ const mailboxesWithFolder = inFolderUid
494
+ ? mailboxFolders.filter((mf) => !mf.error && mf.folders.some((f) => f.uid === inFolderUid)).map((mf) => mf.mailbox)
495
+ : [];
496
+ const searchableMailboxes = mailboxesWithFolder.length > 0 ? mailboxesWithFolder : readableMailboxes;
497
+ const searchedMailboxes = crossMailbox ? orderMailboxes(searchableMailboxes, userUid).slice(0, SEARCH_LIMITS.maxMailboxes) : [];
498
+ // A single mailbox's own folder always has one; a cross-mailbox search has whichever it covers.
499
+ const searchTargetUids = crossMailbox ? searchedMailboxes.map((mb) => mb.uid) : [mailboxUid];
500
+ // A dependency of the search effect, which can't take the array itself.
501
+ const searchTargetKey = searchTargetUids.join(",");
502
502
  // Search works whichever way the list is arranged. Its results are messages, so while a query is held the list is fetched as
503
503
  // messages (`asConversations` is about the list's data and is false then), and when the reader arranges by conversation they are
504
504
  // shown grouped under their conversation - only the messages that match - with the whole thread in the reading pane
505
505
  // (`threadPane`, which follows the arrangement the reader chose and not the query).
506
- const isSearching = searchQuery.length > 0 && !aggregateFolderType;
506
+ const isSearching = searchQuery.length > 0;
507
+ /** How many rows the list on screen keeps: the same 500 for a search as for a listing (`SEARCH_LIMITS.maxRows`, which a test can shrink). */
508
+ const rowCap = isSearching ? SEARCH_LIMITS.maxRows : MAX_LOADED_ROWS;
507
509
  const asConversations = preferences.showAsConversations && !isSearching;
508
510
  const threadPane = preferences.showAsConversations;
509
511
  const groupedResults = isSearching && preferences.showAsConversations;
@@ -512,7 +514,8 @@ function InboxContent({ userUid }) {
512
514
  // search results are ranked across folders rather than listed from one, so neither tab is offered
513
515
  // there. Not offered for an aggregate view either (each mailbox classifies independently; merging
514
516
  // that is out of scope).
515
- const currentFolders = mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [];
517
+ const foldersOf = (ofMailbox) => mailboxFolders.find((mf) => mf.mailbox.uid === ofMailbox)?.folders ?? [];
518
+ const currentFolders = foldersOf(activeMailboxUid);
516
519
  const currentFolderIsInbox = currentFolders.find((f) => f.uid === folderUid)?.type === "inbox";
517
520
  const offerClassificationFilters = currentFolderIsInbox && !isSearching && !aggregateFolderType;
518
521
  // A remembered Focused/Other filter must not silently narrow a folder that has no Focused Inbox to
@@ -581,7 +584,7 @@ function InboxContent({ userUid }) {
581
584
  const resolvedMessageCacheRef = useRef(new Map());
582
585
  // Session-scoped, never explicitly cleared - see Tier3Cache's own doc comment above.
583
586
  const tier3CacheRef = useRef(new Map());
584
- // The latest composite cursor this search pass has reached - read by loadMore(), written at the end
587
+ // The latest cursor this search pass has reached (one composite cursor per mailbox searched) - read by loadMore(), written at the end
585
588
  // of both the fresh-search orchestration and loadMore() itself. Not React state: it never drives a
586
589
  // render on its own, only what loadMore() does with it later.
587
590
  const compositeCursorRef = useRef(undefined);
@@ -599,33 +602,34 @@ function InboxContent({ userUid }) {
599
602
  // state update elsewhere). New rows (a fresh page load, load-more, or a completed search) each
600
603
  // re-trigger this the normal way, by changing `messages` itself.
601
604
  //
602
- // Scoped to `activeMailboxUid`'s own rows only - in aggregate mode `messages` can span several
603
- // mailboxes, but only one mailbox's keys are ever being unlocked/tracked here (see `activeMailboxUid`'s
604
- // own doc comment above); an encrypted row from any other mailbox simply isn't a candidate for this
605
- // auto-decrypt or the manual unlock banner below.
606
- const undecryptedEncryptedUids = messages
607
- .filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid] && m.mailboxUid === activeMailboxUid)
608
- .map((m) => m.uid);
605
+ // The auto-decrypt below covers every mailbox whose keys are unlocked - in aggregate mode and in a search over several mailboxes `messages`
606
+ // spans them, and a mailbox unlocked from the search's own banner has rows to show. The manual unlock banner (`handleUnlockList()`) is
607
+ // still about `activeMailboxUid` only (see its own doc comment above): a row of a locked mailbox stays "Encrypted message".
608
+ const undecryptedEncrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid]);
609
+ const undecryptedEncryptedUids = undecryptedEncrypted.filter((m) => m.mailboxUid === activeMailboxUid).map((m) => m.uid);
609
610
  // Once unlocked, silently decrypt this page's own encrypted rows to show their real subject/preview -
610
611
  // no prompt needed here, the same way searchEncryptedCandidates() already auto-includes decrypted
611
612
  // matches once unlocked without asking again. Only the *first* unlock (or a fresh page of messages
612
613
  // arriving) needs this; `handleUnlockList()` below covers the not-yet-unlocked case explicitly.
613
614
  useEffect(() => {
614
- if (undecryptedEncryptedUids.length === 0 || !activeMailboxUid) {
615
- return;
616
- }
617
- const unlocked = getUnlockedKeys(activeMailboxUid);
618
- if (!unlocked) {
619
- return;
620
- }
621
615
  let cancelled = false;
622
616
  const lockGeneration = lockGenerationRef.current;
623
- void decryptEncryptedRows(messages.filter((m) => undecryptedEncryptedUids.includes(m.uid)), unlocked).then((decrypted) => {
624
- // A lock while this was in flight already cleared `decryptedRows` - never put them back.
625
- if (!cancelled && lockGeneration === lockGenerationRef.current && Object.keys(decrypted).length > 0) {
626
- setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
617
+ const rowsByMailbox = new Map();
618
+ for (const message of undecryptedEncrypted) {
619
+ rowsByMailbox.set(message.mailboxUid, [...(rowsByMailbox.get(message.mailboxUid) ?? []), message]);
620
+ }
621
+ for (const [rowsMailboxUid, rows] of rowsByMailbox) {
622
+ const unlocked = getUnlockedKeys(rowsMailboxUid);
623
+ if (!unlocked) {
624
+ continue;
627
625
  }
628
- });
626
+ void decryptEncryptedRows(rows, unlocked).then((decrypted) => {
627
+ // A lock while this was in flight already cleared `decryptedRows` - never put them back.
628
+ if (!cancelled && lockGeneration === lockGenerationRef.current && Object.keys(decrypted).length > 0) {
629
+ setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
630
+ }
631
+ });
632
+ }
629
633
  return () => {
630
634
  cancelled = true;
631
635
  };
@@ -635,11 +639,13 @@ function InboxContent({ userUid }) {
635
639
  // snippets (Tier 2/3 snippets are decrypted content), and the already-decrypted Tier 3 candidates. A
636
640
  // live search re-runs, so Tier 2/3 contribute nothing again until the user unlocks. Read through a ref
637
641
  // so the subscription itself doesn't churn on every render.
638
- const keyLockStateRef = useRef({ activeMailboxUid, isSearching });
639
- keyLockStateRef.current = { activeMailboxUid, isSearching };
642
+ const keyLockStateRef = useRef({ activeMailboxUid, isSearching, searchTargetUids });
643
+ keyLockStateRef.current = { activeMailboxUid, isSearching, searchTargetUids };
640
644
  useEffect(() => subscribeKeySession(({ mailboxUid: changedMailboxUid, state }) => {
641
645
  const current = keyLockStateRef.current;
642
- if (state !== "locked" || changedMailboxUid !== current.activeMailboxUid) {
646
+ // The open mailbox's, or - while searching - any mailbox the search covers: their decrypted hits are on screen too.
647
+ const relevant = changedMailboxUid === current.activeMailboxUid || (current.isSearching && current.searchTargetUids.includes(changedMailboxUid));
648
+ if (state !== "locked" || !relevant) {
643
649
  return;
644
650
  }
645
651
  lockGenerationRef.current += 1;
@@ -663,8 +669,30 @@ function InboxContent({ userUid }) {
663
669
  // User dismissed the unlock dialog - rows stay exactly as they were.
664
670
  }
665
671
  }
672
+ /** The mailboxes a search over several could include encrypted mail from if they were unlocked: the ones with keys that are locked right now.
673
+ * `unlockable` are the reader's own - a shared mailbox's keys belong to its owner, and unlocking them is not something this page can ask for. */
674
+ const lockedSearchMailboxes = crossMailbox ? searchedMailboxes.filter((mb) => (mb.keys?.length ?? 0) > 0 && !getUnlockedKeys(mb.uid)) : [];
675
+ const unlockableSearchMailboxes = lockedSearchMailboxes.filter((mb) => isOwnMailbox(mb, userUid));
676
+ const sharedLockedSearchMailboxes = lockedSearchMailboxes.filter((mb) => !isOwnMailbox(mb, userUid));
677
+ /** Unlocked mailboxes with encrypted mail to search that have no local index on this device (the index is only built for the open mailbox), so
678
+ * only Tier 3 - server-narrowed, bounded - reaches their encrypted mail. */
679
+ const serverOnlyMailboxes = crossMailbox
680
+ ? searchedMailboxes.filter((mb) => (mb.keys?.length ?? 0) > 0 && !!getUnlockedKeys(mb.uid) && !coverages[mb.uid]?.indexedFrom)
681
+ : [];
682
+ /** The one mailbox's own coverage, for a search of one. */
683
+ const coverage = coverages[mailboxUid ?? ""];
684
+ /** A folder's search can be widened to every mailbox, when there is more than one to widen it to. */
685
+ const widenable = !aggregateFolderType && readableMailboxes.length > 1;
666
686
  async function handleUnlockSearch() {
667
687
  try {
688
+ if (crossMailbox) {
689
+ // One prompt per locked mailbox of the reader's own, in turn; a dismissed one stops the rest.
690
+ for (const mailbox of unlockableSearchMailboxes) {
691
+ await requestUnlock(mailbox.uid, mailbox.keys);
692
+ setUnlockRefresh((n) => n + 1);
693
+ }
694
+ return;
695
+ }
668
696
  await requestUnlock(activeMailboxUid, mailboxKeys);
669
697
  setUnlockRefresh((n) => n + 1);
670
698
  }
@@ -679,7 +707,7 @@ function InboxContent({ userUid }) {
679
707
  // `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
680
708
  // applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
681
709
  const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
682
- const labelsMailboxUid = threadPane ? activeMailboxUid : (selectedMessageMailboxUid ?? activeMailboxUid);
710
+ const labelsMailboxUid = threadPane ? (openThread?.mailboxUid ?? activeMailboxUid) : (selectedMessageMailboxUid ?? activeMailboxUid);
683
711
  // Almost always the open mailbox's own labels, already fetched below - so `labels` reuses that list
684
712
  // rather than asking for the identical one a second time (which is what this page did on every single
685
713
  // view). Only a selected message from *another* mailbox - a search hit or an aggregate-view row - needs
@@ -735,11 +763,24 @@ function InboxContent({ userUid }) {
735
763
  if (shown !== null && shown !== selectionKey) {
736
764
  setSearchInput("");
737
765
  setSearchQuery("");
766
+ setSearchAllMailboxes(false);
738
767
  }
739
768
  if (folderUid || aggregateFolderType) {
740
769
  shownSelectionRef.current = selectionKey;
741
770
  }
742
771
  }, [selectionKey]);
772
+ // A search widened to every mailbox lasts for that search: once the box is emptied the next one starts in the open folder's mailbox again.
773
+ useEffect(() => {
774
+ if (searchQuery === "") {
775
+ setSearchAllMailboxes(false);
776
+ }
777
+ }, [searchQuery]);
778
+ // Rows can be ticked in the results of a search over every mailbox, but not in the listing they came out of: clearing the search leaves select mode.
779
+ useEffect(() => {
780
+ if (aggregateFolderType && !isSearching) {
781
+ setSelectMode(false);
782
+ }
783
+ }, [aggregateFolderType, isSearching]);
743
784
  // Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
744
785
  // `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
745
786
  const listKey = listSnapshotKey({
@@ -860,7 +901,7 @@ function InboxContent({ userUid }) {
860
901
  });
861
902
  return invalidate;
862
903
  }
863
- if (aggregateFolderType) {
904
+ if (aggregateFolderType && !isSearching) {
864
905
  setLoading(true);
865
906
  setError(null);
866
907
  // hasMore stays false (set above) - see fetchAggregateMessages()'s own pagination scope trim.
@@ -880,82 +921,183 @@ function InboxContent({ userUid }) {
880
921
  setLoading(!snapshot);
881
922
  setError(null);
882
923
  if (isSearching) {
883
- setCoverage(undefined);
924
+ setCoverages({});
925
+ setSearchFailures([]);
884
926
  setPendingUids(new Set());
885
927
  setTier1Done(false);
886
928
  setTier2Done(false);
887
929
  setTier3Done(false);
888
930
  resolvedMessageCacheRef.current = new Map();
889
931
  const parsed = parseSearchQuery(searchQuery);
890
- const unlocked = getUnlockedKeys(mailboxUid);
891
932
  const fingerprint = queryFingerprint(parsed);
892
- // Recomputes and re-renders the merged list from whatever tiers have reported so far -
933
+ // What each mailbox is asked for per page: the whole page for one mailbox, a share of it for several.
934
+ const pageSize = searchPageSize(searchTargetUids.length, MESSAGE_PAGE_SIZE);
935
+ // Only a search over several mailboxes gives up on a slow one - a search of one is left to take as long as it takes.
936
+ const tier1TimeoutMs = crossMailbox ? SEARCH_LIMITS.tier1TimeoutMs : Infinity;
937
+ const tier3TimeoutMs = crossMailbox ? SEARCH_LIMITS.tier3TimeoutMs : Infinity;
938
+ const mailboxNames = new Map(searchedMailboxes.map((mb) => [mb.uid, mb.displayName]));
939
+ const isSuperseded = () => searchRunIdRef.current !== myRunId;
940
+ // Tier 3's code, imported when the first mailbox needs it and shared by the rest.
941
+ let tier3Module;
942
+ const loadTier3 = () => (tier3Module ?? (tier3Module = import("@rapidmx/react-shared/search/searchTier3.js")));
943
+ const failures = [];
944
+ /** The failed mailbox's notice entry. A search of one mailbox has none: its failure fails the search. */
945
+ const noteFailure = (uid, encrypted, err) => {
946
+ failures.push({ mailboxUid: uid, mailboxName: mailboxNames.get(uid), encrypted, reason: classifyFailure(err) });
947
+ };
948
+ // One entry per mailbox searched, in the order they are shown in - which is also the order ties are ranked in.
949
+ const runs = new Map(searchTargetUids.map((uid) => [
950
+ uid,
951
+ {
952
+ mailboxUid: uid,
953
+ unlocked: getUnlockedKeys(uid),
954
+ tier1: [],
955
+ tier2: [],
956
+ tier2More: false,
957
+ tier3: [],
958
+ tier3Total: 0,
959
+ tier3Key: "",
960
+ tier1Settled: false,
961
+ tier3Settled: false,
962
+ failed: false,
963
+ },
964
+ ]));
965
+ const runList = [...runs.values()];
966
+ // A reveal that started before another one but finished after it must not put its older, smaller picture back.
967
+ let revealsStarted = 0;
968
+ let revealsApplied = 0;
969
+ // Recomputes and re-renders the merged list from whatever tiers have reported so far, across every mailbox -
893
970
  // called once after Tier 1+2 (interim: unconfirmed Tier 1 metadataOnly hits render as
894
971
  // skeletons) and again after Tier 3 (final: anything still unconfirmed is pruned instead -
895
- // §_Progressive Results_' "Skeletons resolve or disappear"). Bails out via `myRunId` if a
972
+ // §_Progressive Results_' "Skeletons resolve or disappear"); a search over several mailboxes also calls it as each one
973
+ // reports, and a mailbox is "final" for its own hits as soon as its own Tier 3 has. Bails out if a
896
974
  // newer search pass has since started.
897
- async function reveal(tier1Hits, tier2Hits, tier3Hits, final) {
898
- const capped = capSkeletons(tier1Hits);
899
- const confirmed = new Set([...tier2Hits, ...tier3Hits].map((r) => r.entityUid));
900
- const effectiveTier1 = final ? capped.filter((hit) => !hit.metadataOnly || confirmed.has(hit.entityUid)) : capped;
901
- const merged = mergeSearchResults(effectiveTier1, tier2Hits, tier3Hits);
975
+ async function reveal() {
976
+ const revealId = ++revealsStarted;
977
+ const capped = capSkeletons(runList.flatMap((run) => run.tier1));
978
+ const tier2Hits = runList.flatMap((run) => run.tier2);
979
+ const tier3Hits = runList.flatMap((run) => run.tier3);
980
+ const confirmed = new Set([...tier2Hits, ...tier3Hits].map(hitKey));
981
+ const isUnconfirmed = (hit) => !!hit.metadataOnly && !confirmed.has(hitKey(hit));
982
+ const effectiveTier1 = capped.filter((hit) => !runs.get(hit.mailboxUid).tier3Settled || !isUnconfirmed(hit));
983
+ const merged = mergeSearchResults(effectiveTier1, tier2Hits, tier3Hits).slice(0, rowCap);
902
984
  const { messages: resolved, snippets: resolvedSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
903
- if (searchRunIdRef.current !== myRunId) {
985
+ if (isSuperseded() || revealId < revealsApplied) {
904
986
  return;
905
987
  }
988
+ revealsApplied = revealId;
906
989
  setMessages(resolved);
907
990
  setSnippets(resolvedSnippets);
908
- if (final) {
909
- setPendingUids(new Set());
991
+ if (resolved.length > 0) {
992
+ // The first mailbox to answer with anything replaces the loading skeleton.
993
+ setLoading(false);
910
994
  }
911
- else {
912
- const pending = new Set();
913
- for (const hit of capped) {
914
- if (hit.metadataOnly && !confirmed.has(hit.entityUid)) {
915
- pending.add(hit.entityUid);
916
- }
995
+ const pending = new Set();
996
+ for (const hit of capped) {
997
+ if (!runs.get(hit.mailboxUid).tier3Settled && isUnconfirmed(hit)) {
998
+ pending.add(hit.entityUid);
917
999
  }
918
- setPendingUids(pending);
919
1000
  }
1001
+ setPendingUids(pending);
920
1002
  }
921
1003
  void (async () => {
922
1004
  try {
923
- const [tier1Page, tier2Page] = await Promise.all([
924
- searchMailbox(parsed.text, tier1SearchParams(parsed, undefined, mailboxUid)),
925
- searchLocalIndex(mailboxUid, parsed, unlocked, MESSAGE_PAGE_SIZE, 0),
926
- ]);
927
- if (searchRunIdRef.current !== myRunId) {
1005
+ // Tier 1 (server) and Tier 2 (local index) of every mailbox, a few at a time.
1006
+ await runLimited(searchTargetUids, SEARCH_LIMITS.concurrency, async (uid) => {
1007
+ const run = runs.get(uid);
1008
+ try {
1009
+ const [tier1Page, tier2Page] = await Promise.all([
1010
+ withTimeout(searchMailbox(parsed.text, tier1SearchParams(parsed, undefined, uid, pageSize)), tier1TimeoutMs),
1011
+ searchLocalIndex(uid, parsed, run.unlocked, pageSize, 0),
1012
+ ]);
1013
+ if (isSuperseded()) {
1014
+ return;
1015
+ }
1016
+ run.tier1 = tagHits(uid, tier1Page.results);
1017
+ run.tier1Cursor = tier1Page.nextCursor;
1018
+ run.tier2 = tagHits(uid, tier2Page.results);
1019
+ run.tier2More = tier2Page.hasMore;
1020
+ run.coverage = tier2Page.coverage;
1021
+ }
1022
+ catch (err) {
1023
+ if (!crossMailbox) {
1024
+ throw err;
1025
+ }
1026
+ run.failed = true;
1027
+ noteFailure(uid, false, err);
1028
+ }
1029
+ run.tier1Settled = true;
1030
+ if (crossMailbox && runList.some((other) => !other.tier1Settled)) {
1031
+ void reveal();
1032
+ }
1033
+ }, isSuperseded);
1034
+ if (isSuperseded()) {
928
1035
  return;
929
1036
  }
930
1037
  setTier1Done(true);
931
1038
  setTier2Done(true);
932
- setCoverage(tier2Page.coverage);
1039
+ setCoverages(Object.fromEntries(runList.map((run) => [run.mailboxUid, run.coverage])));
1040
+ setSearchFailures([...failures]);
933
1041
  setLoading(false);
934
- await reveal(tier1Page.results, tier2Page.results, [], false);
935
- const windows = tier3Windows(parsed, tier2Page.coverage, searchAllMail);
936
- const cacheKey = tier3CacheKey(mailboxUid, windows, !!unlocked);
937
- let tier3Full = tier3CacheRef.current.get(cacheKey);
938
- if (!tier3Full) {
939
- tier3Full = await searchTier3Windows(windows, unlocked, mailboxUid);
940
- if (searchRunIdRef.current !== myRunId) {
941
- return;
1042
+ if (failures.length === runList.length) {
1043
+ // Not one mailbox could be searched (or there were none to search): that is a failed search, not missing results.
1044
+ setError("Search failed.");
1045
+ return;
1046
+ }
1047
+ await reveal();
1048
+ // Tier 3 (server-narrowed encrypted candidates) of every mailbox that answered, each over the window its own local index
1049
+ // leaves uncovered - a mailbox with no local index is searched over the query's whole range.
1050
+ await runLimited(runList.filter((run) => !run.failed), SEARCH_LIMITS.concurrency, async (run) => {
1051
+ try {
1052
+ const windows = tier3Windows(parsed, run.coverage, searchAllMail);
1053
+ const cacheKey = tier3CacheKey(run.mailboxUid, windows, !!run.unlocked);
1054
+ let tier3Full = tier3CacheRef.current.get(cacheKey);
1055
+ if (!tier3Full) {
1056
+ tier3Full = await withTimeout(searchTier3Windows(windows, run.unlocked, run.mailboxUid, loadTier3), tier3TimeoutMs);
1057
+ if (isSuperseded()) {
1058
+ return;
1059
+ }
1060
+ tier3CacheRef.current.set(cacheKey, tier3Full);
1061
+ }
1062
+ run.tier3 = tagHits(run.mailboxUid, tier3Full.slice(0, pageSize));
1063
+ run.tier3Total = tier3Full.length;
1064
+ run.tier3Key = cacheKey;
942
1065
  }
943
- tier3CacheRef.current.set(cacheKey, tier3Full);
1066
+ catch (err) {
1067
+ if (!crossMailbox) {
1068
+ throw err;
1069
+ }
1070
+ noteFailure(run.mailboxUid, true, err);
1071
+ }
1072
+ run.tier3Settled = true;
1073
+ if (crossMailbox && runList.some((other) => !other.failed && !other.tier3Settled)) {
1074
+ void reveal();
1075
+ }
1076
+ }, isSuperseded);
1077
+ if (isSuperseded()) {
1078
+ return;
944
1079
  }
945
- const tier3Page = tier3Full.slice(0, MESSAGE_PAGE_SIZE);
946
1080
  setTier3Done(true);
947
- compositeCursorRef.current = {
948
- tier1Cursor: tier1Page.nextCursor,
949
- tier2Offset: tier2Page.results.length,
950
- tier3Offset: tier3Page.length,
951
- tier3Key: cacheKey,
952
- fingerprint,
953
- };
954
- setHasMore(!!tier1Page.nextCursor || tier2Page.hasMore || tier3Page.length < tier3Full.length);
955
- await reveal(tier1Page.results, tier2Page.results, tier3Page, true);
1081
+ setSearchFailures([...failures]);
1082
+ const cursor = { fingerprint, pageSize, perMailbox: new Map() };
1083
+ for (const run of runList) {
1084
+ if (!run.failed) {
1085
+ cursor.perMailbox.set(run.mailboxUid, {
1086
+ tier1Cursor: run.tier1Cursor,
1087
+ tier2Offset: run.tier2.length,
1088
+ tier3Offset: run.tier3.length,
1089
+ tier3Total: run.tier3Total,
1090
+ tier3Key: run.tier3Key,
1091
+ more: !!run.tier1Cursor || run.tier2More || run.tier3.length < run.tier3Total,
1092
+ });
1093
+ }
1094
+ }
1095
+ compositeCursorRef.current = cursor;
1096
+ setHasMore(mailboxesWithMore(cursor).length > 0);
1097
+ await reveal();
956
1098
  }
957
1099
  catch (err) {
958
- if (searchRunIdRef.current === myRunId) {
1100
+ if (!isSuperseded()) {
959
1101
  setError(err instanceof ApiRequestError ? err.message : "Search failed.");
960
1102
  setLoading(false);
961
1103
  }
@@ -1024,9 +1166,12 @@ function InboxContent({ userUid }) {
1024
1166
  unlockRefresh,
1025
1167
  searchAllMail,
1026
1168
  aggregateFolderType,
1027
- // Only a merged view is built from the folder tree; for a single folder a folder appearing in the sidebar (the Outbox, Sent Items) must not reload
1028
- // the list on screen and forget its selection.
1029
- aggregateFolderType ? mailboxFolders : null,
1169
+ // Only a merged listing is built from the folder tree; for a single folder a folder appearing in the sidebar (the Outbox, Sent Items) must not reload
1170
+ // the list on screen and forget its selection. A search over several mailboxes is built from the *mailboxes* instead (`searchTargetKey`), so a
1171
+ // folder appearing while it runs does not restart it either.
1172
+ aggregateFolderType && !isSearching ? mailboxFolders : null,
1173
+ crossMailbox,
1174
+ searchTargetKey,
1030
1175
  activeMailboxUid,
1031
1176
  ]);
1032
1177
  // Which folders the messages on screen live in: one the sidebar does not know (the Outbox and Sent Items are made by the server on first use) is looked for -
@@ -1108,12 +1253,13 @@ function InboxContent({ userUid }) {
1108
1253
  // Reads the ref, not `conversations`/`messages` state, so this callback (and the observer effect
1109
1254
  // watching it) don't need to be recreated every time a page lands - the same reason every other
1110
1255
  // check below reads a ref rather than closing over state.
1111
- const atRowCap = (asConversations ? conversationsRef.current.length : messagesRef.current.length) >= MAX_LOADED_ROWS;
1256
+ const atRowCap = (asConversations ? conversationsRef.current.length : messagesRef.current.length) >= rowCap;
1112
1257
  if (loadMoreInFlightRef.current ||
1113
1258
  !hasMore ||
1114
1259
  loading ||
1115
1260
  atRowCap ||
1116
- (!asConversations && !folderUid) ||
1261
+ // A search over every mailbox has no folder of its own (nor does a listing that has not resolved one).
1262
+ (!asConversations && !folderUid && !isSearching) ||
1117
1263
  (isSearching && !searchCursor)) {
1118
1264
  return;
1119
1265
  }
@@ -1152,45 +1298,96 @@ function InboxContent({ userUid }) {
1152
1298
  return;
1153
1299
  }
1154
1300
  const addedRows = hasUnseenRows(conversationsRef.current, more, conversationKey);
1155
- setConversations((prev) => appendUnseenRows(prev, more, conversationKey, MAX_LOADED_ROWS));
1301
+ setConversations((prev) => appendUnseenRows(prev, more, conversationKey, rowCap));
1156
1302
  setHasMore(more.length === MESSAGE_PAGE_SIZE);
1157
1303
  listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1158
1304
  notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
1159
1305
  }
1160
1306
  else if (isSearching) {
1161
- const unlocked = getUnlockedKeys(mailboxUid);
1162
1307
  const cursor = searchCursor;
1163
- const [tier1Page, tier2Page] = await Promise.all([
1164
- searchMailbox(parsed.text, tier1SearchParams(parsed, cursor.tier1Cursor, mailboxUid)),
1165
- searchLocalIndex(mailboxUid, parsed, unlocked, MESSAGE_PAGE_SIZE, cursor.tier2Offset),
1166
- ]);
1167
- // The first page cached this pass under `tier3Key` before it created the cursor.
1168
- const tier3Full = tier3CacheRef.current.get(cursor.tier3Key);
1169
- const tier3Offset = cursor.tier3Offset;
1170
- const tier3Page = tier3Full.slice(tier3Offset, tier3Offset + MESSAGE_PAGE_SIZE);
1308
+ const tier1TimeoutMs = crossMailbox ? SEARCH_LIMITS.tier1TimeoutMs : Infinity;
1309
+ // Every mailbox with more to give is asked for its own next page - the others (exhausted, or given up on) stay as they are.
1310
+ const attempted = mailboxesWithMore(cursor);
1311
+ const pages = new Map();
1312
+ const failedMailboxes = [];
1313
+ const failedErrors = [];
1314
+ await runLimited(attempted, SEARCH_LIMITS.concurrency, async (uid) => {
1315
+ const mailboxCursor = cursor.perMailbox.get(uid);
1316
+ try {
1317
+ const [tier1Page, tier2Page] = await Promise.all([
1318
+ withTimeout(searchMailbox(parsed.text, tier1SearchParams(parsed, mailboxCursor.tier1Cursor, uid, cursor.pageSize)), tier1TimeoutMs),
1319
+ searchLocalIndex(uid, parsed, getUnlockedKeys(uid), cursor.pageSize, mailboxCursor.tier2Offset),
1320
+ ]);
1321
+ // The first page cached this pass under `tier3Key` before it created the cursor - unless Tier 3 had nothing to give.
1322
+ const tier3Page = mailboxCursor.tier3Offset < mailboxCursor.tier3Total
1323
+ ? tier3CacheRef.current
1324
+ .get(mailboxCursor.tier3Key)
1325
+ .slice(mailboxCursor.tier3Offset, mailboxCursor.tier3Offset + cursor.pageSize)
1326
+ : [];
1327
+ pages.set(uid, {
1328
+ tier1: tagHits(uid, tier1Page.results),
1329
+ tier1Cursor: tier1Page.nextCursor,
1330
+ tier2: tagHits(uid, tier2Page.results),
1331
+ tier2More: tier2Page.hasMore,
1332
+ tier3: tagHits(uid, tier3Page),
1333
+ });
1334
+ }
1335
+ catch (err) {
1336
+ // One mailbox failing to give its next page must not stop the others' (a search of one has nothing else to show).
1337
+ if (!crossMailbox) {
1338
+ throw err;
1339
+ }
1340
+ failedErrors.push(err);
1341
+ failedMailboxes.push({
1342
+ mailboxUid: uid,
1343
+ mailboxName: searchedMailboxes.find((mb) => mb.uid === uid).displayName,
1344
+ encrypted: false,
1345
+ reason: classifyFailure(err),
1346
+ });
1347
+ }
1348
+ }, () => !isCurrentRun());
1349
+ if (failedErrors.length === attempted.length) {
1350
+ // No mailbox answered: that is a failed page (with Retry), not a page with fewer rows.
1351
+ throw failedErrors[0];
1352
+ }
1353
+ const answered = [...pages.values()];
1171
1354
  // Unlike the fresh-search pass above, a load-more page is resolved and appended in one
1172
1355
  // shot rather than progressively revealed - rows already on screen shouldn't reorder or
1173
1356
  // grow skeletons out from under a reader who has since scrolled past them. Any Tier 1
1174
1357
  // metadataOnly hit this page that neither Tier 2 nor Tier 3 (both already awaited above)
1175
1358
  // confirms simply keeps showing its existing placeholder text rather than a live skeleton
1176
1359
  // - a deliberate, documented scope trim of progressive reveal to the first page only.
1177
- const merged = mergeSearchResults(capSkeletons(tier1Page.results), tier2Page.results, tier3Page);
1360
+ const merged = mergeSearchResults(capSkeletons(answered.flatMap((page) => page.tier1)), answered.flatMap((page) => page.tier2), answered.flatMap((page) => page.tier3));
1178
1361
  const { messages: more, snippets: moreSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
1179
1362
  if (!isCurrentRun()) {
1180
1363
  return;
1181
1364
  }
1182
1365
  const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1183
- setMessages((prev) => appendUnseenRows(prev, more, messageUid, MAX_LOADED_ROWS));
1366
+ setMessages((prev) => appendUnseenRows(prev, more, messageUid, rowCap));
1184
1367
  setSnippets((prev) => ({ ...prev, ...moreSnippets }));
1185
- const nextTier3Offset = tier3Offset + tier3Page.length;
1186
- compositeCursorRef.current = {
1187
- tier1Cursor: tier1Page.nextCursor,
1188
- tier2Offset: cursor.tier2Offset + tier2Page.results.length,
1189
- tier3Offset: nextTier3Offset,
1190
- tier3Key: cursor.tier3Key,
1191
- fingerprint,
1192
- };
1193
- const moreRemain = !!tier1Page.nextCursor || tier2Page.hasMore || nextTier3Offset < tier3Full.length;
1368
+ setSearchFailures((prev) => [...prev, ...failedMailboxes]);
1369
+ const perMailbox = new Map(cursor.perMailbox);
1370
+ for (const uid of attempted) {
1371
+ const previous = cursor.perMailbox.get(uid);
1372
+ const page = pages.get(uid);
1373
+ if (!page) {
1374
+ // Failed just now: given up on for the rest of this search (the notice says so).
1375
+ perMailbox.set(uid, { ...previous, more: false });
1376
+ continue;
1377
+ }
1378
+ const nextTier3Offset = previous.tier3Offset + page.tier3.length;
1379
+ perMailbox.set(uid, {
1380
+ tier1Cursor: page.tier1Cursor,
1381
+ tier2Offset: previous.tier2Offset + page.tier2.length,
1382
+ tier3Offset: nextTier3Offset,
1383
+ tier3Total: previous.tier3Total,
1384
+ tier3Key: previous.tier3Key,
1385
+ more: !!page.tier1Cursor || page.tier2More || nextTier3Offset < previous.tier3Total,
1386
+ });
1387
+ }
1388
+ const nextCursor = { ...cursor, perMailbox };
1389
+ compositeCursorRef.current = nextCursor;
1390
+ const moreRemain = mailboxesWithMore(nextCursor).length > 0;
1194
1391
  setHasMore(moreRemain);
1195
1392
  notePageLanded(addedRows, moreRemain);
1196
1393
  }
@@ -1204,7 +1401,7 @@ function InboxContent({ userUid }) {
1204
1401
  return;
1205
1402
  }
1206
1403
  const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1207
- setMessages((prev) => appendUnseenRows(prev, more, messageUid, MAX_LOADED_ROWS));
1404
+ setMessages((prev) => appendUnseenRows(prev, more, messageUid, rowCap));
1208
1405
  setHasMore(more.length === MESSAGE_PAGE_SIZE);
1209
1406
  listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1210
1407
  notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
@@ -1233,6 +1430,8 @@ function InboxContent({ userUid }) {
1233
1430
  searchQuery,
1234
1431
  mailboxUid,
1235
1432
  activeMailboxUid,
1433
+ crossMailbox,
1434
+ searchTargetKey,
1236
1435
  ]);
1237
1436
  // Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
1238
1437
  // dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
@@ -1349,7 +1548,7 @@ function InboxContent({ userUid }) {
1349
1548
  * mailbox/search whose true size lands at or near the cap can have its very last page be a partial one,
1350
1549
  * which correctly turns `hasMore` false - without checking it here, the banner would still claim rows
1351
1550
  * are being hidden even though every one of them is already on screen. */
1352
- const rowCapReached = hasMore && (asConversations ? conversations.length : messages.length) >= MAX_LOADED_ROWS;
1551
+ const rowCapReached = hasMore && (asConversations ? conversations.length : messages.length) >= rowCap;
1353
1552
  function leaveSelectMode() {
1354
1553
  setSelectMode(false);
1355
1554
  setSelectedUids(new Set());
@@ -1477,13 +1676,27 @@ function InboxContent({ userUid }) {
1477
1676
  * first single-message archive, so that call both creates the folder and archives the first message,
1478
1677
  * and the rest of the selection is then moved into the folder it reports. */
1479
1678
  async function bulkArchive(chosen) {
1480
- const archiveFolderUid = currentFolders.find((f) => f.type === "archive")?.uid;
1481
- if (archiveFolderUid) {
1482
- return moveMessages(chosen, archiveFolderUid);
1679
+ return inEachMailbox(chosen, async (ofMailbox, group) => {
1680
+ const archiveFolderUid = foldersOf(ofMailbox).find((f) => f.type === "archive")?.uid;
1681
+ if (archiveFolderUid) {
1682
+ return moveMessages(group, archiveFolderUid);
1683
+ }
1684
+ const first = await archiveMessage(group[0].uid);
1685
+ const rest = group.slice(1);
1686
+ return rest.length === 0 ? [first] : [first, ...(await moveMessages(rest, first.folderUid))];
1687
+ });
1688
+ }
1689
+ /**
1690
+ * Runs `action` once for every mailbox `chosen` has messages in and gathers what each returns. Folders belong to a mailbox, so anything that
1691
+ * moves messages into "the" Archive or Deleted Items has to do so per mailbox: a search over several mailboxes lists hits from more than one, while
1692
+ * every other list holds one mailbox's messages and this is one call, as it always was.
1693
+ */
1694
+ async function inEachMailbox(chosen, action) {
1695
+ const groups = new Map();
1696
+ for (const message of chosen) {
1697
+ groups.set(message.mailboxUid, [...(groups.get(message.mailboxUid) ?? []), message]);
1483
1698
  }
1484
- const first = await archiveMessage(chosen[0].uid);
1485
- const rest = chosen.slice(1);
1486
- return rest.length === 0 ? [first] : [first, ...(await moveMessages(rest, first.folderUid))];
1699
+ return (await Promise.all([...groups].map(([ofMailbox, group]) => action(ofMailbox, group)))).flat();
1487
1700
  }
1488
1701
  /**
1489
1702
  * This mailbox's folder of `type`. The server makes every well-known folder itself (when the mailbox is made, and again when the folders are listed
@@ -1494,22 +1707,26 @@ function InboxContent({ userUid }) {
1494
1707
  * A folder found or created here may not be in `mailboxFolders` yet, so it is filed there and remembered per mailbox and type until the page
1495
1708
  * reloads; otherwise a second Delete would ask again.
1496
1709
  */
1497
- async function resolveFolderOfType(type, name) {
1498
- const key = `${activeMailboxUid}:${type}`;
1499
- const known = currentFolders.find((f) => f.type === type)?.uid ?? lazyFoldersRef.current.get(key);
1710
+ async function resolveFolderOfType(type, name, ofMailbox) {
1711
+ const key = `${ofMailbox}:${type}`;
1712
+ const known = foldersOf(ofMailbox).find((f) => f.type === type)?.uid ?? lazyFoldersRef.current.get(key);
1500
1713
  if (known) {
1501
1714
  return known;
1502
1715
  }
1503
- const listed = (await listFolders(activeMailboxUid)).find((f) => f.type === type);
1716
+ const listed = (await listFolders(ofMailbox)).find((f) => f.type === type);
1504
1717
  if (listed) {
1505
1718
  onFolderCreated(listed);
1506
1719
  lazyFoldersRef.current.set(key, listed.uid);
1507
1720
  return listed.uid;
1508
1721
  }
1509
- const created = await createFolder({ mailboxUid: activeMailboxUid, name, type });
1722
+ const created = await createFolder({ mailboxUid: ofMailbox, name, type });
1510
1723
  lazyFoldersRef.current.set(key, created.uid);
1511
1724
  return created.uid;
1512
1725
  }
1726
+ /** Moves `chosen` into each of its mailbox's own folder of `type` (Deleted Items, Junk), making it where the server has none yet. */
1727
+ function moveToFolderOfType(chosen, type, name) {
1728
+ return inEachMailbox(chosen, async (ofMailbox, group) => moveMessages(group, await resolveFolderOfType(type, name, ofMailbox)));
1729
+ }
1513
1730
  /**
1514
1731
  * Sets every selected message's labels in one bulk update: each ends up with `labelUids`, plus the
1515
1732
  * ones left partially applied (`keepPartial`) that it already had, plus any label this mailbox no
@@ -1525,7 +1742,7 @@ function InboxContent({ userUid }) {
1525
1742
  })), false);
1526
1743
  }
1527
1744
  function moveSelectionToType(type, name, chosen) {
1528
- return runBulkAction(async (moving) => moveMessages(moving, await resolveFolderOfType(type, name)), true, chosen);
1745
+ return runBulkAction((moving) => moveToFolderOfType(moving, type, name), true, chosen);
1529
1746
  }
1530
1747
  // ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
1531
1748
  // ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
@@ -1570,7 +1787,7 @@ function InboxContent({ userUid }) {
1570
1787
  const loadMoreStatus = loadingMore ? ("Loading more\u2026") : loadMoreError ? (_jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { role: "alert", className: "text-danger", children: loadMoreError }), _jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Retry" })] })) : loadMoreStalled ? (_jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Load more" })) : null;
1571
1788
  /** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
1572
1789
  function renderMessageRow(message) {
1573
- return (_jsxs(SwipeRow, { as: "li", enabled: swipeEnabled, onArchive: () => swipeArchive([message]), onMove: () => setMoveDialog({ messages: [message] }), "data-message-uid": message.uid, "data-unread": isUnread(message) ? "true" : undefined, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: isUnread(message) }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid), onChange: () => toggleSelected(message.uid), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => handleSelect(message), className: ["w-full text-left px-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: isUnread(message) }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(isUnread(message)) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(isUnread(message))].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), aggregateFolderType && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: ["text-sm truncate", subjectClass(isUnread(message))].join(" "), children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] ||
1790
+ return (_jsxs(SwipeRow, { as: "li", enabled: swipeEnabled, onArchive: () => swipeArchive([message]), onMove: () => setMoveDialog({ messages: [message] }), "data-message-uid": message.uid, "data-unread": isUnread(message) ? "true" : undefined, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: isUnread(message) }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid), onChange: () => toggleSelected(message.uid), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => handleSelect(message), className: ["w-full text-left px-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: isUnread(message) }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(isUnread(message)) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(isUnread(message))].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), crossMailbox && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: ["text-sm truncate", subjectClass(isUnread(message))].join(" "), children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] ||
1574
1791
  decryptedRows[message.uid]?.preview ||
1575
1792
  message.bodyPreview ||
1576
1793
  (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && _jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" }), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] }), isOutbox && _jsx(OutboxRowStatus, { message: message })] }))] }), _jsx(InviteRowChip, { message: message, onResponded: (updated) => patchListedMessage(updated) })] })] }, message.uid));
@@ -1579,7 +1796,7 @@ function InboxContent({ userUid }) {
1579
1796
  * first match of each - the order the results are ranked in. `null` when the list is not showing grouped results. */
1580
1797
  const resultGroups = groupedResults ? groupByConversation(messages) : null;
1581
1798
  /** The search box: in the list on a desktop (flat lists only), in the shell's header beside the folders button on a phone. */
1582
- const searchField = (_jsx("input", { ref: searchInputRef, type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…", "aria-label": "Search all mail", disabled: !!aggregateFolderType, className: ["w-full text-sm px-3 rounded-md border border-border bg-surface disabled:opacity-55", isMobile ? "py-2" : "py-1.5"].join(" ") }));
1799
+ const searchField = (_jsx("input", { ref: searchInputRef, type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: "Search all mail\u2026", "aria-label": "Search all mail", className: ["w-full text-sm px-3 rounded-md border border-border bg-surface", isMobile ? "py-2" : "py-1.5"].join(" ") }));
1583
1800
  function handleSelect(message) {
1584
1801
  if (selectMode) {
1585
1802
  toggleSelected(message.uid);
@@ -1611,7 +1828,7 @@ function InboxContent({ userUid }) {
1611
1828
  }
1612
1829
  removedAnchorRef.current = null;
1613
1830
  setSelectedUid(message.uid);
1614
- setOpenThread({ conversation, uid: message.uid });
1831
+ setOpenThread({ conversation, uid: message.uid, mailboxUid: message.mailboxUid });
1615
1832
  }
1616
1833
  /** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
1617
1834
  * stands for, or the latest for a parent row. The thread pane loads the thread itself. */
@@ -1629,21 +1846,23 @@ function InboxContent({ userUid }) {
1629
1846
  }
1630
1847
  removedAnchorRef.current = null;
1631
1848
  setSelectedUid(uid);
1632
- setOpenThread({ conversation, uid });
1849
+ setOpenThread({ conversation, uid, mailboxUid: activeMailboxUid });
1633
1850
  }
1634
1851
  // ---- Keyboard shortcuts (see `shared/keyboard`). Each is registered only while this view can do it, and calls what the toolbar and
1635
1852
  // ---- the selection bar call: the same bulk-action path (`runBulkAction()` - so the same optimistic badge tracking, the same rollback,
1636
1853
  // ---- the same lazily created Deleted Items folder), never a second implementation. Reply, Reply all, Forward, Archive and Move to are
1637
1854
  // ---- registered by the reading pane itself (`MessageDetailPane`'s `shortcuts`), which owns those handlers.
1638
1855
  const inConversations = asConversations;
1639
- /** The selection bar isn't offered in the aggregate view (its rows belong to several mailboxes), and neither are these. */
1640
- const keyboardActions = !aggregateFolderType;
1856
+ /** The selection bar isn't offered in the aggregate listing (its rows belong to several mailboxes), and neither are these - but the results of a
1857
+ * search over them are, each hit acted on in its own mailbox (see `inEachMailbox()`). */
1858
+ const keyboardActions = !aggregateFolderType || isSearching;
1641
1859
  /** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
1642
1860
  const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : threadPane ? openThread !== null : selected !== null;
1643
1861
  const rowCount = inConversations ? listedConversations.length : messages.length;
1644
1862
  const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0;
1863
+ /** The type of a message's folder - looked for in every mailbox's, as a search over several has messages from each. */
1645
1864
  function folderTypeOf(folderUidOfMessage) {
1646
- return currentFolders.find((f) => f.uid === folderUidOfMessage)?.type;
1865
+ return mailboxFolders.flatMap((mf) => mf.folders).find((f) => f.uid === folderUidOfMessage)?.type;
1647
1866
  }
1648
1867
  /** Moves the keyboard's focus to a row - and scrolls it into view - once the selection has moved to it. */
1649
1868
  function focusRow(uid) {
@@ -1687,16 +1906,19 @@ function InboxContent({ userUid }) {
1687
1906
  }
1688
1907
  }
1689
1908
  }
1690
- /** The messages of the open conversation that are in the folder being listed - fetched (once) like a ticked conversation's. */
1691
- async function loadOpenConversation(conversation) {
1909
+ /** The messages of the open conversation that are in the folder being listed - fetched (once) like a ticked conversation's. `ofMailbox` is the
1910
+ * conversation's mailbox: a search over several mailboxes can open one that is not the page's, and the same conversation id can exist in two. */
1911
+ async function loadOpenConversation(conversation, ofMailbox = activeMailboxUid) {
1692
1912
  const id = conversation.conversationId;
1693
- let all = conversationMessagesById[id];
1913
+ const cacheKey = ofMailbox === activeMailboxUid ? id : `${ofMailbox}\u0000${id}`;
1914
+ let all = conversationMessagesById[cacheKey];
1694
1915
  if (!all) {
1695
- all = await listConversationMessages(activeMailboxUid, id);
1916
+ all = await listConversationMessages(ofMailbox, id);
1696
1917
  const loaded = all;
1697
- setConversationMessagesById((prev) => ({ ...prev, [id]: loaded }));
1918
+ setConversationMessagesById((prev) => ({ ...prev, [cacheKey]: loaded }));
1698
1919
  }
1699
- return all.filter((m) => !folderUid || m.folderUid === folderUid);
1920
+ // What a search over several mailboxes finds is in every folder of them, not just the one open.
1921
+ return all.filter((m) => !folderUid || crossMailbox || m.folderUid === folderUid);
1700
1922
  }
1701
1923
  /**
1702
1924
  * Runs a bulk action on what the keyboard acts on - the ticked rows in select mode, else the open message or conversation - keeping only
@@ -1709,7 +1931,11 @@ function InboxContent({ userUid }) {
1709
1931
  }
1710
1932
  let chosen;
1711
1933
  try {
1712
- chosen = selectMode ? selectedMessages : threadPane ? await loadOpenConversation(openThread.conversation) : [selected];
1934
+ chosen = selectMode
1935
+ ? selectedMessages
1936
+ : threadPane
1937
+ ? await loadOpenConversation(openThread.conversation, openThread.mailboxUid)
1938
+ : [selected];
1713
1939
  }
1714
1940
  catch (err) {
1715
1941
  notifyApiError(err, "Couldn't load the messages in that conversation");
@@ -1729,7 +1955,7 @@ function InboxContent({ userUid }) {
1729
1955
  useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
1730
1956
  useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
1731
1957
  useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
1732
- useShortcut(SHORTCUTS.mail.delete, () => void runOnKeyboardTargets(async (chosen) => moveMessages(chosen, await resolveFolderOfType("deleted_items", "Deleted Items")), true, (message) => folderTypeOf(message.folderUid) !== "deleted_items"), { enabled: keyboardActions && keyboardTargetExists });
1958
+ useShortcut(SHORTCUTS.mail.delete, () => void runOnKeyboardTargets((chosen) => moveToFolderOfType(chosen, "deleted_items", "Deleted Items"), true, (message) => folderTypeOf(message.folderUid) !== "deleted_items"), { enabled: keyboardActions && keyboardTargetExists });
1733
1959
  useShortcut(SHORTCUTS.mail.markRead, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, true, { patch: patchListedMessage, track: trackMessageChange }), false, isUnread), { enabled: keyboardActions && keyboardTargetExists });
1734
1960
  useShortcut(SHORTCUTS.mail.markUnread, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, false, { patch: patchListedMessage, track: trackMessageChange }), false, (message) => !isUnread(message)), { enabled: keyboardActions && keyboardTargetExists });
1735
1961
  useShortcut(SHORTCUTS.mail.flag, () =>
@@ -1759,7 +1985,16 @@ function InboxContent({ userUid }) {
1759
1985
  useShortcut(SHORTCUTS.mail.search, () => {
1760
1986
  searchInputRef.current?.focus();
1761
1987
  searchInputRef.current?.select();
1762
- }, { enabled: !aggregateFolderType });
1988
+ });
1989
+ // Folders and labels belong to one mailbox. A search over several mailboxes can have a selection in more than one: Move to needs it to be in one
1990
+ // (and offers that mailbox's folders), and Apply label needs it to be in the open mailbox, whose labels are the ones loaded. Archive, Delete and
1991
+ // Report junk go to each hit's own mailbox's folders (`inEachMailbox()`), so they need nothing of the sort.
1992
+ const selectedMailboxUids = new Set(selectedMessages.map((m) => m.mailboxUid));
1993
+ const selectionMailboxUid = crossMailbox && selectedMailboxUids.size === 1 ? [...selectedMailboxUids][0] : activeMailboxUid;
1994
+ const moveDisabledReason = crossMailbox && selectedMailboxUids.size > 1 ? "The selected messages are in different mailboxes - select messages from one mailbox to move them" : undefined;
1995
+ const labelsDisabledReason = crossMailbox && selectedMessages.some((m) => m.mailboxUid !== activeMailboxUid)
1996
+ ? "Labels can only be applied here to messages in the open mailbox"
1997
+ : undefined;
1763
1998
  if (!folderUid && !aggregateFolderType) {
1764
1999
  // `MailShell` never renders this component at all until a mailbox is resolved (see its own
1765
2000
  // full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
@@ -1775,7 +2010,7 @@ function InboxContent({ userUid }) {
1775
2010
  ? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
1776
2011
  : undefined, onSelectAll: () => asConversations
1777
2012
  ? selectAllConversations()
1778
- : setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: currentFolders, currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: activeMailboxUid, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction(
2013
+ : setSelectedUids(new Set(messages.map((m) => m.uid))), onClearSelection: () => asConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set()), onCancel: leaveSelectMode, folders: foldersOf(selectionMailboxUid), currentFolderUid: folderUid, labels: mailboxLabels, mailboxUid: selectionMailboxUid, moveDisabledReason: moveDisabledReason, labelsDisabledReason: labelsDisabledReason, onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated, onApplyLabels: applyLabelsToSelection, onSetRead: (read) => void runBulkAction(
1779
2014
  // Optimistic: the rows and the folder badges change now, and change back if the server refuses.
1780
2015
  (chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }), false), onSetFlagged: (flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged), false), shortcuts: keyboardActions, onArchive: () => void runBulkAction(bulkArchive, true), onMoveTo: async (targetFolderUid) => {
1781
2016
  await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true);
@@ -1788,7 +2023,7 @@ function InboxContent({ userUid }) {
1788
2023
  ? "This view merges the newest mail from every mailbox and is always listed by date."
1789
2024
  : asConversations
1790
2025
  ? CONVERSATION_SORT_NOTE
1791
- : undefined, selectDisabled: !!aggregateFolderType || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType
2026
+ : undefined, selectDisabled: (!!aggregateFolderType && !isSearching) || loading || listedRowCount === 0, selectDisabledReason: aggregateFolderType && !isSearching
1792
2027
  ? "Open a mailbox's own folder to select messages"
1793
2028
  : loading
1794
2029
  ? "Wait for this folder to finish loading"
@@ -1797,21 +2032,23 @@ function InboxContent({ userUid }) {
1797
2032
  preferences.filter === value
1798
2033
  ? "text-primary-dark border-b-2 border-primary-dark"
1799
2034
  : "text-text-muted",
1800
- ].join(" "), children: label }, value))) })), isSearching && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2", children: [_jsx("span", { children: tier1Done && tier2Done && tier3Done
1801
- ? `${messages.length} result${messages.length === 1 ? "" : "s"}`
1802
- : `${messages.length} of ??` }), tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, className: "text-primary-dark hover:underline font-medium shrink-0", children: "Search all mail" }))] })), isSearching && coverage?.indexedFrom && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Local search covers messages back to ", new Date(coverage.indexedFrom).toLocaleDateString(), coverage.building ? " (still building)" : "", searchAllMail
2035
+ ].join(" "), children: label }, value))) })), isSearching && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2", children: [_jsxs("span", { className: "flex flex-col", children: [_jsx("span", { children: tier1Done && tier2Done && tier3Done
2036
+ ? `${messages.length} result${messages.length === 1 ? "" : "s"}`
2037
+ : `${messages.length} of ??` }), crossMailbox && _jsx("span", { "data-testid": "search-scope", children: "All mailboxes" })] }), _jsxs("span", { className: "flex flex-col items-end gap-0.5", children: [tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, title: crossMailbox
2038
+ ? "Also search encrypted mail from before each mailbox's local index begins"
2039
+ : undefined, className: "text-primary-dark hover:underline font-medium shrink-0", children: crossMailbox ? "Search older encrypted mail" : "Search all mail" })), widenable && (_jsx("button", { type: "button", onClick: () => setSearchAllMailboxes(!searchAllMailboxes), className: "text-primary-dark hover:underline font-medium shrink-0", children: searchAllMailboxes ? "Search this mailbox only" : "Search all mailboxes" }))] })] })), crossMailbox && isSearching && searchableMailboxes.length > searchedMailboxes.length && (_jsxs("div", { role: "status", className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Searched ", searchedMailboxes.length, " of ", searchableMailboxes.length, " mailboxes - the most that are searched at once."] })), crossMailbox && isSearching && searchFailures.length > 0 && (_jsxs("div", { role: "status", className: "px-4 py-1.5 text-xs text-text-muted border-b border-border bg-surface-alt", children: ["Some results may be missing: ", describeFailures(searchFailures)] })), isSearching && !crossMailbox && coverage?.indexedFrom && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Local search covers messages back to ", new Date(coverage.indexedFrom).toLocaleDateString(), coverage.building ? " (still building)" : "", searchAllMail
1803
2040
  ? " - searching everything, not just recent mail."
1804
- : " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !asConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : asConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onMeetingResponded: patchListedMessage, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected, swipe: {
2041
+ : " - older encrypted mail is still searched, just slower."] })), isSearching && crossMailbox && tier2Done && serverOnlyMailboxes.length > 0 && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["There is no local index on this device for ", serverOnlyMailboxes.map((mb) => mb.displayName).join(", "), ": its encrypted mail is searched through the server instead, which is slower and looks at a limited number of messages."] })), isSearching && crossMailbox && lockedSearchMailboxes.length > 0 && (_jsxs("div", { className: "px-4 py-2 border-b border-border bg-surface-alt flex flex-col gap-1", children: [unlockableSearchMailboxes.length > 0 && (_jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages from ", unlockableSearchMailboxes.map((mb) => mb.displayName).join(", ")] })), sharedLockedSearchMailboxes.length > 0 && (_jsxs("p", { className: "text-xs text-text-muted", children: ["Encrypted messages in ", sharedLockedSearchMailboxes.map((mb) => mb.displayName).join(", "), " are not included: only the mailbox\u2019s owner can unlock them."] }))] })), isSearching && !crossMailbox && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && !asConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsxs("div", { role: "status", "aria-busy": "true", className: "p-4", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx(SkeletonList, { count: 8 })] })) : asConversations ? (_jsxs(_Fragment, { children: [_jsx(ConversationList, { conversations: listedConversations, newestFirst: preferences.sortBy === "date" && preferences.sortOrder === "desc", mailboxUid: activeMailboxUid, selectedUid: selectedUid, messageOverrides: conversationPatches, onMeetingResponded: patchListedMessage, onOpenMessage: handleOpenConversation, selectMode: selectMode, selectedConversationIds: selectedConversationIds, onToggleSelected: toggleConversationSelected, swipe: {
1805
2042
  enabled: swipeEnabled,
1806
2043
  onArchive: swipeArchive,
1807
2044
  onMove: (conversation) => setMoveDialog({ conversation }),
1808
- } }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " conversations \u2014 refine your search or filters to see more."] }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
2045
+ } }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", rowCap, " conversations \u2014 refine your search or filters to see more."] }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
1809
2046
  ? `No messages match "${searchQuery}".`
1810
2047
  : effectiveFilter === "all"
1811
2048
  ? "No messages in this folder."
1812
2049
  : "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { className: swipeEnabled ? "overflow-x-clip" : undefined, children: resultGroups
1813
- ? resultGroups.map((group) => (_jsxs("li", { "data-search-group": group.id, children: [_jsxs("div", { className: "px-4 py-1.5 bg-surface-alt border-b border-border text-xs text-text-muted flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate font-semibold", children: rowSubject(group.messages[0]) }), _jsxs("span", { className: "shrink-0", children: [group.messages.length, " matching message", group.messages.length === 1 ? "" : "s"] })] }), _jsx("ul", { children: group.messages.map(renderMessageRow) })] }, group.id)))
1814
- : messages.map(renderMessageRow) }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", MAX_LOADED_ROWS, " messages \u2014 refine your search or filters to see more."] })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: threadPane ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: activeMailboxUid, folders: currentFolders, labels: mailboxLabels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => setMailboxLabels((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
2050
+ ? resultGroups.map((group) => (_jsxs("li", { "data-search-group": group.id, children: [_jsxs("div", { className: "px-4 py-1.5 bg-surface-alt border-b border-border text-xs text-text-muted flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate font-semibold", children: rowSubject(group.messages[0]) }), _jsxs("span", { className: "shrink-0", children: [group.messages.length, " matching message", group.messages.length === 1 ? "" : "s"] })] }), _jsx("ul", { children: group.messages.map(renderMessageRow) })] }, `${group.mailboxUid}\u0000${group.id}`)))
2051
+ : messages.map(renderMessageRow) }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", rowCap, " messages \u2014 refine your search or filters to see more."] })), aggregateFolderType && !isSearching && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0 min-h-0", children: threadPane ? (_jsx(LazyConversationThreadPane, { conversation: openThread?.conversation ?? null, selectedUid: openThread?.uid ?? null, mailboxUid: labelsMailboxUid, folders: foldersOf(labelsMailboxUid), labels: labels, shortcuts: !selectMode, onMessagePatched: patchListedMessage, onMessageRemoved: (updated) => removeListedMessage(updated.uid), onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]), onFolderCreated: onFolderCreated })) : (_jsx(LazyMessageDetailPane, { shortcuts: !selectMode, message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: patchListedMessage, isOutbox: isOutbox, onReceiptHandled: patchListedMessage, draftsFolderUid: draftsFolderUid, folders: folders, onMoved: (updated) => {
1815
2052
  // Same reasoning as onArchived below - a move takes the message out of the folder
1816
2053
  // being listed, so it leaves the list rather than being patched in place.
1817
2054
  removeListedMessage(updated.uid);