@rapidmx/web-client 0.14.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 (314) 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 -163
  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/appearance/AppearanceForm.tsx +9 -1
  15. package/apps/shared/auth/adminAccess.ts +99 -99
  16. package/apps/shared/calendar/bookingPlugin.ts +100 -0
  17. package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
  18. package/apps/shared/components/admin/layout/AdminShell.tsx +378 -346
  19. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
  20. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  21. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  23. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  24. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +53 -4
  25. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  26. package/apps/shared/components/admin/settings/PluginsManager.tsx +64 -10
  27. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  28. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  29. package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
  30. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  31. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
  32. package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
  33. package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
  34. package/apps/shared/components/calendar/EventDetails.tsx +401 -0
  35. package/apps/shared/components/calendar/EventEditor.tsx +635 -0
  36. package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
  37. package/apps/shared/components/calendar/EventFormParts.tsx +591 -0
  38. package/apps/shared/components/calendar/EventModal.tsx +206 -946
  39. package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
  40. package/apps/shared/components/calendar/EventShell.tsx +202 -0
  41. package/apps/shared/components/calendar/FindATime.tsx +379 -0
  42. package/apps/shared/components/calendar/GuestInput.tsx +100 -0
  43. package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
  44. package/apps/shared/components/calendar/MonthView.tsx +185 -173
  45. package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
  46. package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
  47. package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
  48. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -222
  49. package/apps/shared/components/calendar/RequestChangeForm.tsx +240 -0
  50. package/apps/shared/components/calendar/SplitDayView.tsx +144 -143
  51. package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
  52. package/apps/shared/components/calendar/TimeGridView.tsx +246 -244
  53. package/apps/shared/components/calendar/allDay.ts +124 -124
  54. package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
  55. package/apps/shared/components/calendar/eventForm.ts +108 -0
  56. package/apps/shared/components/calendar/eventFormat.ts +210 -0
  57. package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
  58. package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
  59. package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
  60. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  61. package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
  62. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
  63. package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
  64. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  65. package/apps/shared/components/layout/AppShell.tsx +480 -480
  66. package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
  67. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  68. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  69. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  70. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  71. package/apps/shared/components/layout/UserMenu.tsx +439 -372
  72. package/apps/shared/components/mail/ConversationList.tsx +323 -271
  73. package/apps/shared/components/mail/ConversationThreadPane.tsx +130 -5
  74. package/apps/shared/components/mail/InviteCard.tsx +387 -0
  75. package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
  76. package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1537
  77. package/apps/shared/components/mail/SwipeRow.tsx +120 -0
  78. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  79. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  80. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1673
  81. package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
  82. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  83. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  84. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  85. package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
  86. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
  87. package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
  88. package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
  89. package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
  90. package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
  91. package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
  92. package/apps/shared/components/mail/layout/MailShell.tsx +546 -490
  93. package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
  94. package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
  95. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  96. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  97. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  98. package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -253
  99. package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
  100. package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
  101. package/apps/shared/gestures/useEnterSlide.ts +54 -0
  102. package/apps/shared/gestures/useSwipe.ts +114 -0
  103. package/apps/shared/gestures/useSwipeSlide.ts +113 -0
  104. package/apps/shared/gestures/useWheelPaging.ts +107 -0
  105. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  106. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  107. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  108. package/apps/shared/keyboard/dispatch.ts +124 -124
  109. package/apps/shared/keyboard/format.ts +89 -89
  110. package/apps/shared/keyboard/keymap.ts +114 -114
  111. package/apps/shared/keyboard/registry.ts +65 -65
  112. package/apps/shared/keyboard/targets.ts +79 -79
  113. package/apps/shared/mail/folderCounts.ts +17 -0
  114. package/apps/shared/mail/folderTree.ts +143 -143
  115. package/apps/shared/mail/listAllPages.ts +39 -39
  116. package/apps/shared/mail/newMailNotifications.ts +3 -15
  117. package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
  118. package/apps/shared/mail/outbox/sendJob.ts +19 -1
  119. package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
  120. package/apps/shared/mail/primaryMailbox.ts +71 -0
  121. package/apps/shared/mail/useCollapsedSections.ts +146 -0
  122. package/apps/shared/mail/useMailConnection.ts +205 -203
  123. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  124. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  125. package/apps/shared/mail/useNewMailNotifications.ts +2 -2
  126. package/apps/shared/navigation/AppRouter.tsx +300 -300
  127. package/apps/shared/navigation/routerContext.tsx +83 -83
  128. package/apps/shared/notifications/preferences.ts +36 -0
  129. package/apps/shared/notifications/store.ts +9 -0
  130. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  131. package/apps/shared/search/crossMailboxSearch.ts +169 -0
  132. package/apps/shared/search/localIndexBuilder.ts +481 -481
  133. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  134. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  135. package/apps/shared/signing/enrollmentView.ts +251 -251
  136. package/apps/shared/signing/useNow.ts +19 -19
  137. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  138. package/apps/shared/styles/app.css +396 -386
  139. package/apps/www/_routes.ts +1 -0
  140. package/apps/www/calendar/index.tsx +578 -469
  141. package/apps/www/contacts/[uid].tsx +109 -109
  142. package/apps/www/contacts/index.tsx +6 -0
  143. package/apps/www/index.tsx +2847 -2332
  144. package/apps/www/messages/[uid].tsx +135 -108
  145. package/apps/www/settings/auto-reply/index.tsx +133 -133
  146. package/apps/www/settings/encryption/index.tsx +1287 -1287
  147. package/apps/www/settings/filters/[uid].tsx +176 -176
  148. package/apps/www/settings/filters/index.tsx +102 -102
  149. package/apps/www/settings/filters/new/index.tsx +140 -140
  150. package/apps/www/settings/labels/index.tsx +204 -204
  151. package/apps/www/settings/privacy/index.tsx +492 -492
  152. package/apps/www/settings/profile/index.tsx +248 -0
  153. package/apps/www/settings/read-receipts/index.tsx +147 -147
  154. package/apps/www/settings/sharing/index.tsx +256 -256
  155. package/apps/www/settings/signatures/[uid].tsx +172 -172
  156. package/apps/www/settings/signatures/index.tsx +88 -88
  157. package/apps/www/settings/signatures/new/index.tsx +135 -135
  158. package/apps/www/tasks/index.tsx +649 -649
  159. package/dist/apps/admin/data-requests/index.js +1 -1
  160. package/dist/apps/admin/domains/[uid].js +4 -2
  161. package/dist/apps/admin/index.js +2 -1
  162. package/dist/apps/admin/mailboxes/[uid].js +12 -2
  163. package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
  164. package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
  165. package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
  166. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
  167. package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
  168. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  169. package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
  170. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
  171. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
  172. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
  173. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
  174. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  175. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +28 -4
  176. package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
  177. package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
  178. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
  179. package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
  180. package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
  181. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
  182. package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
  183. package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
  184. package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
  185. package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
  186. package/dist/apps/shared/components/calendar/EventEditor.js +544 -0
  187. package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
  188. package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
  189. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +70 -0
  190. package/dist/apps/shared/components/calendar/EventFormParts.js +191 -0
  191. package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
  192. package/dist/apps/shared/components/calendar/EventModal.js +57 -459
  193. package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
  194. package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
  195. package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
  196. package/dist/apps/shared/components/calendar/EventShell.js +134 -0
  197. package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
  198. package/dist/apps/shared/components/calendar/FindATime.js +190 -0
  199. package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
  200. package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
  201. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
  202. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
  203. package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
  204. package/dist/apps/shared/components/calendar/MonthView.js +11 -5
  205. package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
  206. package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
  207. package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
  208. package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
  209. package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
  210. package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
  211. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  212. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
  213. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
  214. package/dist/apps/shared/components/calendar/RequestChangeForm.js +127 -0
  215. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
  216. package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
  217. package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
  218. package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
  219. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
  220. package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
  221. package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
  222. package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
  223. package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
  224. package/dist/apps/shared/components/calendar/eventForm.js +1 -0
  225. package/dist/apps/shared/components/calendar/eventFormat.d.ts +80 -0
  226. package/dist/apps/shared/components/calendar/eventFormat.js +190 -0
  227. package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
  228. package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
  229. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
  230. package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
  231. package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
  232. package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
  233. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  234. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
  235. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
  236. package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
  237. package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
  238. package/dist/apps/shared/components/layout/UserMenu.d.ts +14 -9
  239. package/dist/apps/shared/components/layout/UserMenu.js +74 -26
  240. package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
  241. package/dist/apps/shared/components/mail/ConversationList.js +21 -4
  242. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  243. package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
  244. package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
  245. package/dist/apps/shared/components/mail/InviteCard.js +150 -0
  246. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
  247. package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
  248. package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
  249. package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
  250. package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
  251. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +16 -7
  252. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
  253. package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
  254. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
  255. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
  256. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
  257. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
  258. package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
  259. package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
  260. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
  261. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
  262. package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
  263. package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
  264. package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
  265. package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
  266. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
  267. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
  268. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +10 -0
  269. package/dist/apps/shared/components/mail/layout/MailShell.js +76 -46
  270. package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
  271. package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
  272. package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
  273. package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
  274. package/dist/apps/shared/components/settings/layout/SettingsShell.js +7 -5
  275. package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
  276. package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
  277. package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
  278. package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
  279. package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
  280. package/dist/apps/shared/gestures/useSwipe.js +90 -0
  281. package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
  282. package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
  283. package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
  284. package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
  285. package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
  286. package/dist/apps/shared/mail/folderCounts.js +12 -0
  287. package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
  288. package/dist/apps/shared/mail/newMailNotifications.js +3 -12
  289. package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
  290. package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
  291. package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
  292. package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
  293. package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
  294. package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
  295. package/dist/apps/shared/mail/primaryMailbox.js +61 -0
  296. package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
  297. package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
  298. package/dist/apps/shared/mail/useMailConnection.js +3 -1
  299. package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
  300. package/dist/apps/shared/notifications/preferences.d.ts +13 -0
  301. package/dist/apps/shared/notifications/preferences.js +36 -0
  302. package/dist/apps/shared/notifications/store.d.ts +1 -0
  303. package/dist/apps/shared/notifications/store.js +7 -23
  304. package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
  305. package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
  306. package/dist/apps/shared/styles/app.css +396 -386
  307. package/dist/apps/www/_routes.js +1 -0
  308. package/dist/apps/www/calendar/index.js +78 -7
  309. package/dist/apps/www/contacts/index.js +3 -1
  310. package/dist/apps/www/index.js +548 -210
  311. package/dist/apps/www/messages/[uid].js +13 -5
  312. package/dist/apps/www/settings/profile/index.d.ts +4 -0
  313. package/dist/apps/www/settings/profile/index.js +124 -0
  314. package/package.json +2 -2
@@ -1,481 +1,481 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- /**
6
- * The Tier 2 local index's background builder (`specs/search.md` §11 "Initial build... incrementally,
7
- * newest-first"). Runs on the main thread - the actual storage I/O and encryption happen in
8
- * `localIndexWorker.ts` (off the UI thread, per that file's own doc comment); this module's own work is
9
- * orchestration (deciding what to fetch) plus already-async fetch/decrypt calls, not CPU-heavy work that
10
- * would itself need to move off-thread.
11
- *
12
- * **Known scoping simplification**: walks the mailbox's real mail folders one at a time (a fixed,
13
- * reasonable priority order - Inbox and Sent first), each already newest-first via `listMessages()`'s own
14
- * default sort, rather than a true interleaved k-way merge producing one single globally-newest-first
15
- * stream across folders. A message in, say, Sent slightly older than the *oldest-processed-so-far*
16
- * message in Inbox can therefore be indexed slightly out of true global date order relative to it. Given
17
- * the byte-budget eviction below is itself date-based (oldest `date_for_sort` first, enforced by
18
- * `localIndexWorker.ts`'s own `applyEviction()` after every insert - see that file), a small amount of
19
- * cross-folder interleaving imprecision here does not affect *what* ultimately survives the window, only
20
- * the exact order entities are inserted (and therefore briefly evicted-and-reinserted) in.
21
- *
22
- * Only encrypted messages (`subject === "[...]"`) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
23
- * own precedent in `apps/www/index.tsx`'s inbox-list decrypt work. An unencrypted message is already
24
- * fully searchable via Tier 1; indexing it here too would spend this index's bounded byte budget on
25
- * content that didn't need it.
26
- *
27
- * **Verification seals.** A message this pass decrypts that has no seal for the vault's current `masterKeyGeneration` is
28
- * evaluated with `evaluateMessageSecurityWithSeal()`, against the sender's pinned signing keys from the reader's
29
- * contacts (`pinnedSigners.ts`, the message pane's own source, looked up once per sender per pass), so a verified
30
- * message gets its seal without being opened (see `apps/shared/components/mail/verificationSeals.ts`). Pins only change
31
- * the security state, never the recovered subject or body this index stores. Seal writes are best effort and bounded:
32
- * at most `SEAL_WRITE_CONCURRENCY` in flight, at most `MAX_SEAL_WRITES_PER_PASS` per pass (past it, messages are
33
- * evaluated without seals), failures ignored, and nothing new starts once the pass is aborted or its keys are destroyed.
34
- * A seal carries only a hash, fingerprint, state and time, never plaintext. With no readable vault generation the pass
35
- * seals nothing. Signed-only (unencrypted) messages aren't sealed here: this pass never fetches their raw MIME (see
36
- * above), and fetching it only to seal would cost a download per message; they are sealed when opened.
37
- */
38
- import { getMessageRawContent, listFolders, listMessages, type Folder, type Message } from "@rapidmx/react-shared/mail/mailApi.js";
39
- import { type MessageSecurityResult, evaluateMessageSecurity, evaluateMessageSecurityWithSeal } from "@rapidmx/react-shared/crypto/messageSecurity.js";
40
- import type { UnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
41
- import { getPinnedSignerFingerprints } from "../components/mail/pinnedSigners.js";
42
- import { currentVerificationSeal, readVaultGeneration, sendVerificationSeal, verificationSealPending } from "../components/mail/verificationSeals.js";
43
- import type { LocalIndexEntity } from "./localIndexSchema.js";
44
- import {
45
- getIndexedVersions,
46
- indexLocalEntities,
47
- initLocalIndex,
48
- nextLocalIndexGeneration,
49
- pruneLocalEntities,
50
- setLocalIndexBuilding,
51
- setLocalIndexWindow,
52
- } from "./localIndexRpcClient.js";
53
- import { deriveLocalIndexKey } from "./localIndexKey.js";
54
- import { getLocalIndexByteBudget } from "./localIndexSizePreference.js";
55
-
56
- /** The RFC 9788 placeholder subject every encrypted message's outer envelope carries server-side - see
57
- * `apps/www/index.tsx`'s identical constant and its own doc comment for the full citation. */
58
- const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
59
-
60
- /** §11's Window Sizing table's time-floor column - explicitly unvalidated starting-point default per the
61
- * spec's own §16 "Measurement Task" ("cannot be supplied by design work... MUST NOT be treated as
62
- * validated"). Used as-is rather than invented/adjusted here. Not user-adjustable today (unlike the byte
63
- * budget - see `localIndexSizePreference.ts`) - nothing in this codebase has asked for that yet. */
64
- export const WEB_TIME_FLOOR_MONTHS = 12;
65
-
66
- /** Either bound as `0` means "no limit": `applyEviction()` (`localIndexWorker.ts`) already treats a
67
- * falsy byte budget as unconfigured/unenforced, and `buildLocalIndex()` below mirrors that same
68
- * convention for `timeFloorMonths` so a single `0` means the same thing in both dimensions. */
69
- export interface LocalIndexWindowConfig {
70
- timeFloorMonths: number;
71
- byteBudgetBytes: number;
72
- }
73
-
74
- /** Folder types that actually hold messages, including `user` (custom folders). Inbox and Sent first: the two
75
- * folders a "did I find that email" search is overwhelmingly likely to land in, so they're covered soonest if
76
- * the build is interrupted (tab closed, idle timeout) partway through. */
77
- const MESSAGE_FOLDER_TYPES = new Set(["inbox", "sent_items", "drafts", "deleted_items", "outbox", "junk", "archive", "user"]);
78
- /** Folder types known not to hold mail. Any folder in neither set is skipped, and the pass is then not
79
- * complete - a type this module doesn't know about may well hold messages. */
80
- const NON_MESSAGE_FOLDER_TYPES = new Set(["calendar", "contacts", "tasks", "notes"]);
81
- const FOLDER_PRIORITY: Record<string, number> = { inbox: 0, sent_items: 1 };
82
-
83
- /** How many times a pass walks a folder whose total count changed during the walk. `listMessages()` only
84
- * pages by offset, so a message removed from an already-walked page shifts a later one onto it unseen;
85
- * only a folder whose count held steady across a whole walk is trusted for completeness and pruning. */
86
- export const MAX_WALK_ATTEMPTS = 3;
87
-
88
- /** Subtracted from the pass's start time before it's recorded as the end of guaranteed coverage - a
89
- * message's server-assigned `receivedDate` can be ahead of this device's clock. */
90
- export const CLOCK_SKEW_MARGIN_MS = 10 * 60 * 1000;
91
-
92
- const PAGE_SIZE = 100;
93
- const MAX_APPROX_BYTES_PER_MESSAGE_PADDING = 512; // subject/participants/flags overhead beyond raw text length
94
- const FETCH_FAILED = Symbol("fetch-failed");
95
-
96
- /** How many raw-MIME fetch+decrypt calls run at once - a page of 100 encrypted messages used to fire all
97
- * 100 requests simultaneously. */
98
- export const FETCH_CONCURRENCY = 6;
99
-
100
- /** How many verification seal writes run at once during a pass. */
101
- export const SEAL_WRITE_CONCURRENCY = 2;
102
-
103
- /** The most verification seals one pass writes. */
104
- export const MAX_SEAL_WRITES_PER_PASS = 200;
105
-
106
- /** A pass's verification sealing - see this module's doc comment. */
107
- interface PassSealer {
108
- /** Evaluates `rawMime`, with seal options when `message` still needs a seal, queuing any seal to write. */
109
- evaluate(message: Message, rawMime: string): Promise<MessageSecurityResult>;
110
- /** Resolves once every queued write has finished or been dropped. Never rejects. */
111
- drain(): Promise<void>;
112
- }
113
-
114
- function createPassSealer(mailboxUid: string, unlocked: UnlockedKeys, signal: AbortSignal): PassSealer {
115
- let generation: Promise<number | undefined> | undefined;
116
- const pins = new Map<string, Promise<string[] | undefined>>();
117
- const queue: (() => Promise<unknown>)[] = [];
118
- let queued = 0;
119
- let active = 0;
120
- let idle: (() => void) | undefined;
121
- const stopped = () => signal.aborted || !!unlocked.destroyed;
122
-
123
- const vaultGeneration = () => (generation ??= readVaultGeneration(mailboxUid));
124
-
125
- const pinsFor = (address: string) => {
126
- const key = address.toLowerCase();
127
- let entry = pins.get(key);
128
- if (!entry) {
129
- entry = getPinnedSignerFingerprints(mailboxUid, address).then(
130
- (fingerprints) => (fingerprints.length > 0 ? fingerprints : undefined),
131
- () => undefined,
132
- );
133
- pins.set(key, entry);
134
- }
135
- return entry;
136
- };
137
-
138
- function pump(): void {
139
- if (stopped()) {
140
- queue.length = 0;
141
- }
142
- while (active < SEAL_WRITE_CONCURRENCY && queue.length > 0) {
143
- const write = queue.shift()!;
144
- active++;
145
- void write().finally(() => {
146
- active--;
147
- pump();
148
- });
149
- }
150
- if (active === 0 && queue.length === 0) {
151
- idle?.();
152
- }
153
- }
154
-
155
- return {
156
- async evaluate(message, rawMime) {
157
- const current = await vaultGeneration();
158
- const { seal, sealGeneration } = currentVerificationSeal(message);
159
- if (current === undefined || stopped() || queued >= MAX_SEAL_WRITES_PER_PASS || !verificationSealPending(message.uid, current) || (seal && sealGeneration === current)) {
160
- return evaluateMessageSecurity(rawMime, unlocked);
161
- }
162
- const result = await evaluateMessageSecurityWithSeal(rawMime, unlocked, await pinsFor(message.from.address), undefined, {
163
- mailboxUid,
164
- messageUid: message.uid,
165
- seal,
166
- sealGeneration,
167
- masterKeyGeneration: current,
168
- });
169
- const sealToWrite = result.sealToWrite;
170
- if (sealToWrite && queued < MAX_SEAL_WRITES_PER_PASS) {
171
- queued++;
172
- queue.push(() => sendVerificationSeal(message.uid, sealToWrite));
173
- pump();
174
- }
175
- return result;
176
- },
177
- drain() {
178
- return new Promise<void>((resolve) => {
179
- idle = resolve;
180
- pump();
181
- });
182
- },
183
- };
184
- }
185
-
186
- /** `cutoff === undefined` means no time floor at all (an unbounded `windowConfig`) - every message is
187
- * "within" it, so the caller's own end-of-folder check (`messages.length < PAGE_SIZE`) becomes the only
188
- * stopping condition. */
189
- function isWithinTimeFloor(receivedDate: string, cutoff: Date | undefined): boolean {
190
- return !cutoff || new Date(receivedDate).getTime() >= cutoff.getTime();
191
- }
192
-
193
- /** A relative weight only - the worker enforces the budget against the database's real measured size and
194
- * uses these per-row values just to decide how much of the oldest range to evict at once. UTF-8 bytes,
195
- * not UTF-16 code units. */
196
- function estimateByteSize(entity: Pick<LocalIndexEntity, "subject" | "body" | "attachmentText" | "participants">): number {
197
- const encoder = new TextEncoder();
198
- const textBytes = [entity.subject, entity.body, entity.attachmentText, entity.participants].reduce(
199
- (total, value) => total + (value ? encoder.encode(value).length : 0),
200
- 0,
201
- );
202
- return textBytes + MAX_APPROX_BYTES_PER_MESSAGE_PADDING;
203
- }
204
-
205
- /** The change marker stored per row: a new server `version` (flags, content) or a folder move both
206
- * invalidate the indexed copy. */
207
- function versionOf(message: Message): string {
208
- return `${message.version}:${message.folderUid}`;
209
- }
210
-
211
- /** `Promise.all(items.map(fn))`, but with at most `limit` calls in flight. Preserves input order. */
212
- async function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
213
- const results = new Array<R>(items.length);
214
- let next = 0;
215
- async function worker(): Promise<void> {
216
- while (next < items.length) {
217
- const index = next++;
218
- results[index] = await fn(items[index]);
219
- }
220
- }
221
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
222
- return results;
223
- }
224
-
225
- /** Decrypts one message and shapes it into a `LocalIndexEntity`, or `undefined` when nothing usable was
226
- * recovered (a decrypt failure, or a message that turns out not to actually be encrypted despite the
227
- * placeholder subject) - mirrors `searchTier3.ts`'s own `!security.html && !security.subject` discard
228
- * rule exactly, for the same reason. */
229
- async function buildEntity(message: Message, sealer: PassSealer): Promise<LocalIndexEntity | undefined | typeof FETCH_FAILED> {
230
- let rawMime: Awaited<ReturnType<typeof getMessageRawContent>>;
231
- try {
232
- rawMime = await getMessageRawContent(message.uid);
233
- } catch {
234
- // Distinct from "undecryptable": a message that couldn't even be fetched may well be searchable,
235
- // so the pass can't claim complete coverage.
236
- return FETCH_FAILED;
237
- }
238
- try {
239
- const security = await sealer.evaluate(message, rawMime);
240
- if (!security.subject && !security.html) {
241
- return undefined;
242
- }
243
- const participants = [message.from.address, message.from.displayName, ...message.recipients.map((r) => r.address)]
244
- .filter(Boolean)
245
- .join(" ");
246
- const setFlags = Object.entries(message.flags)
247
- .filter(([, value]) => value)
248
- .map(([key]) => key);
249
- // Leading/trailing comma so `flags LIKE '%,x,%'` (localIndexSchema.ts's buildSearchPredicates())
250
- // matches correctly even for the first/last flag in the list.
251
- const flags = `,${setFlags.join(",")},`;
252
- const entity: LocalIndexEntity = {
253
- entityType: "message",
254
- entityUid: message.uid,
255
- mailboxUid: message.mailboxUid,
256
- folderUid: message.folderUid,
257
- dateForSort: message.receivedDate,
258
- participants,
259
- flags,
260
- hasAttachments: message.hasAttachments,
261
- subject: security.subject,
262
- body: security.html,
263
- byteSize: 0, // filled in below, after the fields above are known
264
- entityVersion: versionOf(message),
265
- };
266
- entity.byteSize = estimateByteSize(entity);
267
- return entity;
268
- } catch {
269
- // Best-effort, matching searchTier3.ts's own Promise.allSettled-per-candidate posture - one
270
- // message's fetch/decrypt failure never aborts the rest of the build.
271
- return undefined;
272
- }
273
- }
274
-
275
- /** Runs one full incremental build pass for `mailboxUid`: sets the window, walks mail folders newest-first
276
- * (per this module's own doc comment on the folder-order simplification), decrypts and indexes only
277
- * `"[...]"`-subject messages until each folder's own coverage passes the time floor, then clears the
278
- * `building` flag. Messages already indexed at the same version are skipped, fetches run at most
279
- * `FETCH_CONCURRENCY` at a time, and messages older than the eviction watermark (`WindowState.evictedBefore`)
280
- * are neither fetched nor walked past. A failure partway through leaves whatever was indexed so far in place
281
- * and records the pass as incomplete (spec §11's "incomplete-index UX... MUST indicate that coverage is
282
- * partial" - see `Coverage.complete`). Rows the server no longer lists (deleted or moved elsewhere by any
283
- * client) are pruned only within folders whose count held steady for a whole walk (see `MAX_WALK_ATTEMPTS`).
284
- *
285
- * Only one pass runs per mailbox (a newer call cancels the older), and every Worker call carries this pass's
286
- * generation, so a pass outlived by a destroy can't write anything (see `cancelLocalIndexBuild()`).
287
- *
288
- * `windowConfig` defaults to this device's own configured byte budget (`getLocalIndexByteBudget()` -
289
- * 500 MB in a browser tab, 1 GB in Electron, or whatever the user has since set in Settings > Encryption)
290
- * alongside the fixed time floor above. Evaluated fresh on every call with no explicit override, so a
291
- * preference change in Settings takes effect starting with this mailbox's next build pass (its next
292
- * unlock), without requiring a reload.
293
- */
294
- export async function buildLocalIndex(
295
- mailboxUid: string,
296
- unlocked: UnlockedKeys,
297
- folders: Folder[],
298
- windowConfig: LocalIndexWindowConfig = { timeFloorMonths: WEB_TIME_FLOOR_MONTHS, byteBudgetBytes: getLocalIndexByteBudget() },
299
- ): Promise<void> {
300
- // At most one pass per mailbox: a newer one cancels the older and waits for it to wind down first.
301
- const previous = activeBuilds.get(mailboxUid);
302
- const controller = new AbortController();
303
- const run = (async () => {
304
- if (previous) {
305
- previous.controller.abort();
306
- await previous.settled;
307
- }
308
- await runBuildPass(mailboxUid, unlocked, folders, windowConfig, controller.signal);
309
- })();
310
- const entry: ActiveBuild = { controller, settled: run.catch(() => undefined) };
311
- activeBuilds.set(mailboxUid, entry);
312
- try {
313
- await run;
314
- } finally {
315
- if (activeBuilds.get(mailboxUid) === entry) {
316
- activeBuilds.delete(mailboxUid);
317
- }
318
- }
319
- }
320
-
321
- interface ActiveBuild {
322
- controller: AbortController;
323
- /** Resolves (never rejects) once the pass has fully stopped. */
324
- settled: Promise<void>;
325
- }
326
-
327
- const activeBuilds = new Map<string, ActiveBuild>();
328
-
329
- /** Stops a mailbox's running build pass, if any (e.g. its keys were just destroyed), resolving once it has
330
- * wound down. The Worker independently rejects the pass's later calls once the index is destroyed (see
331
- * `GenerationParams`); this just stops the fetching and decrypting too. Never rejects. */
332
- export async function cancelLocalIndexBuild(mailboxUid: string): Promise<void> {
333
- const active = activeBuilds.get(mailboxUid);
334
- if (active) {
335
- active.controller.abort();
336
- await active.settled;
337
- }
338
- }
339
-
340
- function isOlderThan(date: string, watermark: string | undefined): boolean {
341
- return !!watermark && new Date(date).getTime() < new Date(watermark).getTime();
342
- }
343
-
344
- /** How one folder's walk ended. */
345
- type WalkOutcome =
346
- /** Reached the time floor or the end of the folder. */
347
- | "done"
348
- /** Reached messages older than the eviction watermark, which is itself within the time floor. */
349
- | "budget"
350
- /** A listing failed - nothing can be concluded about the rest of the folder. */
351
- | "failed";
352
-
353
- async function runBuildPass(mailboxUid: string, unlocked: UnlockedKeys, folders: Folder[], windowConfig: LocalIndexWindowConfig, signal: AbortSignal): Promise<void> {
354
- const generation = nextLocalIndexGeneration();
355
- const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
356
- signal.throwIfAborted();
357
- await initLocalIndex({ mailboxUid, indexKey, generation });
358
- let { evictedBefore } = await setLocalIndexWindow(mailboxUid, windowConfig.timeFloorMonths, windowConfig.byteBudgetBytes, generation);
359
- await setLocalIndexBuilding(mailboxUid, true, { complete: false, generation });
360
- // Mail arriving after this moment isn't guaranteed to be picked up by this pass.
361
- const coveredUntil = new Date(Date.now() - CLOCK_SKEW_MARGIN_MS).toISOString();
362
- let cutoff: Date | undefined;
363
- if (windowConfig.timeFloorMonths > 0) {
364
- cutoff = new Date();
365
- cutoff.setMonth(cutoff.getMonth() - windowConfig.timeFloorMonths);
366
- }
367
- // A pass is "complete" only if every folder was walked back to the time floor (or its end) with a stable
368
- // count, no listing/fetch failure and no budget cut-off - the only case where coverage can be used to
369
- // narrow Tier 3.
370
- let complete = true;
371
- const sealer = createPassSealer(mailboxUid, unlocked, signal);
372
- try {
373
- if (folders.some((f) => !MESSAGE_FOLDER_TYPES.has(f.type) && !NON_MESSAGE_FOLDER_TYPES.has(f.type))) {
374
- complete = false;
375
- }
376
- const mailFolders = folders
377
- .filter((f) => MESSAGE_FOLDER_TYPES.has(f.type))
378
- .sort((a, b) => (FOLDER_PRIORITY[a.type] ?? 99) - (FOLDER_PRIORITY[b.type] ?? 99));
379
- const seenEncryptedUids = new Set<string>();
380
-
381
- async function walkFolder(folder: Folder): Promise<WalkOutcome> {
382
- for (let page = 0; ; page++) {
383
- signal.throwIfAborted();
384
- let messages: Message[];
385
- try {
386
- messages = await listMessages(folder.uid, { page, limit: PAGE_SIZE });
387
- } catch {
388
- return "failed";
389
- }
390
- if (messages.length === 0) {
391
- return "done";
392
- }
393
- const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
394
- for (const m of encrypted) {
395
- seenEncryptedUids.add(m.uid);
396
- }
397
- // Anything older than the eviction watermark would only be inserted and evicted again.
398
- const candidates = encrypted.filter((m) => !isOlderThan(m.receivedDate, evictedBefore));
399
- if (candidates.length > 0) {
400
- // Skip anything already indexed at the same version - a re-unlock used to re-fetch and
401
- // re-decrypt the entire window every time.
402
- const indexed = await getIndexedVersions(
403
- mailboxUid,
404
- candidates.map((m) => m.uid),
405
- );
406
- const changed = candidates.filter((m) => indexed[m.uid] !== versionOf(m));
407
- const built = await mapWithConcurrency(changed, FETCH_CONCURRENCY, (m) => buildEntity(m, sealer));
408
- signal.throwIfAborted();
409
- if (built.includes(FETCH_FAILED)) {
410
- complete = false;
411
- }
412
- const entities = built.filter((e): e is LocalIndexEntity => e !== undefined && e !== FETCH_FAILED);
413
- if (entities.length > 0) {
414
- ({ evictedBefore } = await indexLocalEntities(mailboxUid, entities, generation));
415
- }
416
- }
417
- const oldestOnPage = messages[messages.length - 1].receivedDate;
418
- if (evictedBefore && isOlderThan(oldestOnPage, evictedBefore) && isWithinTimeFloor(evictedBefore, cutoff)) {
419
- return "budget";
420
- }
421
- if (!isWithinTimeFloor(oldestOnPage, cutoff) || messages.length < PAGE_SIZE) {
422
- return "done";
423
- }
424
- }
425
- }
426
-
427
- /** Each folder's current total, or `undefined` if the listing failed. */
428
- async function folderTotals(): Promise<Map<string, number> | undefined> {
429
- try {
430
- return new Map((await listFolders(mailboxUid)).map((f) => [f.uid, f.totalCount]));
431
- } catch {
432
- return undefined;
433
- }
434
- }
435
-
436
- // Folders walked with a steady count and no listing failure - the only ones whose unseen rows can be
437
- // pruned (deleted, or moved elsewhere by any client).
438
- const reliable: string[] = [];
439
- let totalsBefore = await folderTotals();
440
- let toWalk = mailFolders;
441
- for (let attempt = 0; attempt < MAX_WALK_ATTEMPTS && toWalk.length > 0; attempt++) {
442
- const outcomes = new Map<string, WalkOutcome>();
443
- for (const folder of toWalk) {
444
- outcomes.set(folder.uid, await walkFolder(folder));
445
- }
446
- const totalsAfter = await folderTotals();
447
- const changed: Folder[] = [];
448
- for (const folder of toWalk) {
449
- const outcome = outcomes.get(folder.uid);
450
- if (outcome === "failed" || !totalsBefore || !totalsAfter) {
451
- // A failed listing, or no counts to vouch for the walk with: indexed as far as it got, but
452
- // neither complete nor prunable (see the `reliable` check below).
453
- } else if (totalsAfter.get(folder.uid) === totalsBefore.get(folder.uid)) {
454
- reliable.push(folder.uid);
455
- complete &&= outcome === "done";
456
- } else {
457
- changed.push(folder);
458
- }
459
- }
460
- totalsBefore = totalsAfter;
461
- toWalk = changed;
462
- }
463
- // Failed, count-less, or still changing after every attempt.
464
- if (reliable.length < mailFolders.length) {
465
- complete = false;
466
- }
467
- if (reliable.length > 0) {
468
- // Every reliable folder was walked back to at least the watermark (or the floor).
469
- const since = evictedBefore && isWithinTimeFloor(evictedBefore, cutoff) ? evictedBefore : cutoff?.toISOString();
470
- await pruneLocalEntities(mailboxUid, [...seenEncryptedUids], since, { folderUids: reliable, generation });
471
- }
472
- } catch (err) {
473
- complete = false;
474
- throw err;
475
- } finally {
476
- // Rejected (harmlessly) when the index was destroyed mid-pass.
477
- await setLocalIndexBuilding(mailboxUid, false, { complete, coveredFrom: cutoff?.toISOString(), coveredUntil, generation }).catch(() => undefined);
478
- // At most `SEAL_WRITE_CONCURRENCY` writes are still in flight; queued ones are dropped if the pass was aborted.
479
- await sealer.drain();
480
- }
481
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The Tier 2 local index's background builder (`specs/search.md` §11 "Initial build... incrementally,
7
+ * newest-first"). Runs on the main thread - the actual storage I/O and encryption happen in
8
+ * `localIndexWorker.ts` (off the UI thread, per that file's own doc comment); this module's own work is
9
+ * orchestration (deciding what to fetch) plus already-async fetch/decrypt calls, not CPU-heavy work that
10
+ * would itself need to move off-thread.
11
+ *
12
+ * **Known scoping simplification**: walks the mailbox's real mail folders one at a time (a fixed,
13
+ * reasonable priority order - Inbox and Sent first), each already newest-first via `listMessages()`'s own
14
+ * default sort, rather than a true interleaved k-way merge producing one single globally-newest-first
15
+ * stream across folders. A message in, say, Sent slightly older than the *oldest-processed-so-far*
16
+ * message in Inbox can therefore be indexed slightly out of true global date order relative to it. Given
17
+ * the byte-budget eviction below is itself date-based (oldest `date_for_sort` first, enforced by
18
+ * `localIndexWorker.ts`'s own `applyEviction()` after every insert - see that file), a small amount of
19
+ * cross-folder interleaving imprecision here does not affect *what* ultimately survives the window, only
20
+ * the exact order entities are inserted (and therefore briefly evicted-and-reinserted) in.
21
+ *
22
+ * Only encrypted messages (`subject === "[...]"`) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
23
+ * own precedent in `apps/www/index.tsx`'s inbox-list decrypt work. An unencrypted message is already
24
+ * fully searchable via Tier 1; indexing it here too would spend this index's bounded byte budget on
25
+ * content that didn't need it.
26
+ *
27
+ * **Verification seals.** A message this pass decrypts that has no seal for the vault's current `masterKeyGeneration` is
28
+ * evaluated with `evaluateMessageSecurityWithSeal()`, against the sender's pinned signing keys from the reader's
29
+ * contacts (`pinnedSigners.ts`, the message pane's own source, looked up once per sender per pass), so a verified
30
+ * message gets its seal without being opened (see `apps/shared/components/mail/verificationSeals.ts`). Pins only change
31
+ * the security state, never the recovered subject or body this index stores. Seal writes are best effort and bounded:
32
+ * at most `SEAL_WRITE_CONCURRENCY` in flight, at most `MAX_SEAL_WRITES_PER_PASS` per pass (past it, messages are
33
+ * evaluated without seals), failures ignored, and nothing new starts once the pass is aborted or its keys are destroyed.
34
+ * A seal carries only a hash, fingerprint, state and time, never plaintext. With no readable vault generation the pass
35
+ * seals nothing. Signed-only (unencrypted) messages aren't sealed here: this pass never fetches their raw MIME (see
36
+ * above), and fetching it only to seal would cost a download per message; they are sealed when opened.
37
+ */
38
+ import { getMessageRawContent, listFolders, listMessages, type Folder, type Message } from "@rapidmx/react-shared/mail/mailApi.js";
39
+ import { type MessageSecurityResult, evaluateMessageSecurity, evaluateMessageSecurityWithSeal } from "@rapidmx/react-shared/crypto/messageSecurity.js";
40
+ import type { UnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
41
+ import { getPinnedSignerFingerprints } from "../components/mail/pinnedSigners.js";
42
+ import { currentVerificationSeal, readVaultGeneration, sendVerificationSeal, verificationSealPending } from "../components/mail/verificationSeals.js";
43
+ import type { LocalIndexEntity } from "./localIndexSchema.js";
44
+ import {
45
+ getIndexedVersions,
46
+ indexLocalEntities,
47
+ initLocalIndex,
48
+ nextLocalIndexGeneration,
49
+ pruneLocalEntities,
50
+ setLocalIndexBuilding,
51
+ setLocalIndexWindow,
52
+ } from "./localIndexRpcClient.js";
53
+ import { deriveLocalIndexKey } from "./localIndexKey.js";
54
+ import { getLocalIndexByteBudget } from "./localIndexSizePreference.js";
55
+
56
+ /** The RFC 9788 placeholder subject every encrypted message's outer envelope carries server-side - see
57
+ * `apps/www/index.tsx`'s identical constant and its own doc comment for the full citation. */
58
+ const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
59
+
60
+ /** §11's Window Sizing table's time-floor column - explicitly unvalidated starting-point default per the
61
+ * spec's own §16 "Measurement Task" ("cannot be supplied by design work... MUST NOT be treated as
62
+ * validated"). Used as-is rather than invented/adjusted here. Not user-adjustable today (unlike the byte
63
+ * budget - see `localIndexSizePreference.ts`) - nothing in this codebase has asked for that yet. */
64
+ export const WEB_TIME_FLOOR_MONTHS = 12;
65
+
66
+ /** Either bound as `0` means "no limit": `applyEviction()` (`localIndexWorker.ts`) already treats a
67
+ * falsy byte budget as unconfigured/unenforced, and `buildLocalIndex()` below mirrors that same
68
+ * convention for `timeFloorMonths` so a single `0` means the same thing in both dimensions. */
69
+ export interface LocalIndexWindowConfig {
70
+ timeFloorMonths: number;
71
+ byteBudgetBytes: number;
72
+ }
73
+
74
+ /** Folder types that actually hold messages, including `user` (custom folders). Inbox and Sent first: the two
75
+ * folders a "did I find that email" search is overwhelmingly likely to land in, so they're covered soonest if
76
+ * the build is interrupted (tab closed, idle timeout) partway through. */
77
+ const MESSAGE_FOLDER_TYPES = new Set(["inbox", "sent_items", "drafts", "deleted_items", "outbox", "junk", "archive", "user"]);
78
+ /** Folder types known not to hold mail. Any folder in neither set is skipped, and the pass is then not
79
+ * complete - a type this module doesn't know about may well hold messages. */
80
+ const NON_MESSAGE_FOLDER_TYPES = new Set(["calendar", "contacts", "tasks", "notes"]);
81
+ const FOLDER_PRIORITY: Record<string, number> = { inbox: 0, sent_items: 1 };
82
+
83
+ /** How many times a pass walks a folder whose total count changed during the walk. `listMessages()` only
84
+ * pages by offset, so a message removed from an already-walked page shifts a later one onto it unseen;
85
+ * only a folder whose count held steady across a whole walk is trusted for completeness and pruning. */
86
+ export const MAX_WALK_ATTEMPTS = 3;
87
+
88
+ /** Subtracted from the pass's start time before it's recorded as the end of guaranteed coverage - a
89
+ * message's server-assigned `receivedDate` can be ahead of this device's clock. */
90
+ export const CLOCK_SKEW_MARGIN_MS = 10 * 60 * 1000;
91
+
92
+ const PAGE_SIZE = 100;
93
+ const MAX_APPROX_BYTES_PER_MESSAGE_PADDING = 512; // subject/participants/flags overhead beyond raw text length
94
+ const FETCH_FAILED = Symbol("fetch-failed");
95
+
96
+ /** How many raw-MIME fetch+decrypt calls run at once - a page of 100 encrypted messages used to fire all
97
+ * 100 requests simultaneously. */
98
+ export const FETCH_CONCURRENCY = 6;
99
+
100
+ /** How many verification seal writes run at once during a pass. */
101
+ export const SEAL_WRITE_CONCURRENCY = 2;
102
+
103
+ /** The most verification seals one pass writes. */
104
+ export const MAX_SEAL_WRITES_PER_PASS = 200;
105
+
106
+ /** A pass's verification sealing - see this module's doc comment. */
107
+ interface PassSealer {
108
+ /** Evaluates `rawMime`, with seal options when `message` still needs a seal, queuing any seal to write. */
109
+ evaluate(message: Message, rawMime: string): Promise<MessageSecurityResult>;
110
+ /** Resolves once every queued write has finished or been dropped. Never rejects. */
111
+ drain(): Promise<void>;
112
+ }
113
+
114
+ function createPassSealer(mailboxUid: string, unlocked: UnlockedKeys, signal: AbortSignal): PassSealer {
115
+ let generation: Promise<number | undefined> | undefined;
116
+ const pins = new Map<string, Promise<string[] | undefined>>();
117
+ const queue: (() => Promise<unknown>)[] = [];
118
+ let queued = 0;
119
+ let active = 0;
120
+ let idle: (() => void) | undefined;
121
+ const stopped = () => signal.aborted || !!unlocked.destroyed;
122
+
123
+ const vaultGeneration = () => (generation ??= readVaultGeneration(mailboxUid));
124
+
125
+ const pinsFor = (address: string) => {
126
+ const key = address.toLowerCase();
127
+ let entry = pins.get(key);
128
+ if (!entry) {
129
+ entry = getPinnedSignerFingerprints(mailboxUid, address).then(
130
+ (fingerprints) => (fingerprints.length > 0 ? fingerprints : undefined),
131
+ () => undefined,
132
+ );
133
+ pins.set(key, entry);
134
+ }
135
+ return entry;
136
+ };
137
+
138
+ function pump(): void {
139
+ if (stopped()) {
140
+ queue.length = 0;
141
+ }
142
+ while (active < SEAL_WRITE_CONCURRENCY && queue.length > 0) {
143
+ const write = queue.shift()!;
144
+ active++;
145
+ void write().finally(() => {
146
+ active--;
147
+ pump();
148
+ });
149
+ }
150
+ if (active === 0 && queue.length === 0) {
151
+ idle?.();
152
+ }
153
+ }
154
+
155
+ return {
156
+ async evaluate(message, rawMime) {
157
+ const current = await vaultGeneration();
158
+ const { seal, sealGeneration } = currentVerificationSeal(message);
159
+ if (current === undefined || stopped() || queued >= MAX_SEAL_WRITES_PER_PASS || !verificationSealPending(message.uid, current) || (seal && sealGeneration === current)) {
160
+ return evaluateMessageSecurity(rawMime, unlocked);
161
+ }
162
+ const result = await evaluateMessageSecurityWithSeal(rawMime, unlocked, await pinsFor(message.from.address), undefined, {
163
+ mailboxUid,
164
+ messageUid: message.uid,
165
+ seal,
166
+ sealGeneration,
167
+ masterKeyGeneration: current,
168
+ });
169
+ const sealToWrite = result.sealToWrite;
170
+ if (sealToWrite && queued < MAX_SEAL_WRITES_PER_PASS) {
171
+ queued++;
172
+ queue.push(() => sendVerificationSeal(message.uid, sealToWrite));
173
+ pump();
174
+ }
175
+ return result;
176
+ },
177
+ drain() {
178
+ return new Promise<void>((resolve) => {
179
+ idle = resolve;
180
+ pump();
181
+ });
182
+ },
183
+ };
184
+ }
185
+
186
+ /** `cutoff === undefined` means no time floor at all (an unbounded `windowConfig`) - every message is
187
+ * "within" it, so the caller's own end-of-folder check (`messages.length < PAGE_SIZE`) becomes the only
188
+ * stopping condition. */
189
+ function isWithinTimeFloor(receivedDate: string, cutoff: Date | undefined): boolean {
190
+ return !cutoff || new Date(receivedDate).getTime() >= cutoff.getTime();
191
+ }
192
+
193
+ /** A relative weight only - the worker enforces the budget against the database's real measured size and
194
+ * uses these per-row values just to decide how much of the oldest range to evict at once. UTF-8 bytes,
195
+ * not UTF-16 code units. */
196
+ function estimateByteSize(entity: Pick<LocalIndexEntity, "subject" | "body" | "attachmentText" | "participants">): number {
197
+ const encoder = new TextEncoder();
198
+ const textBytes = [entity.subject, entity.body, entity.attachmentText, entity.participants].reduce(
199
+ (total, value) => total + (value ? encoder.encode(value).length : 0),
200
+ 0,
201
+ );
202
+ return textBytes + MAX_APPROX_BYTES_PER_MESSAGE_PADDING;
203
+ }
204
+
205
+ /** The change marker stored per row: a new server `version` (flags, content) or a folder move both
206
+ * invalidate the indexed copy. */
207
+ function versionOf(message: Message): string {
208
+ return `${message.version}:${message.folderUid}`;
209
+ }
210
+
211
+ /** `Promise.all(items.map(fn))`, but with at most `limit` calls in flight. Preserves input order. */
212
+ async function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
213
+ const results = new Array<R>(items.length);
214
+ let next = 0;
215
+ async function worker(): Promise<void> {
216
+ while (next < items.length) {
217
+ const index = next++;
218
+ results[index] = await fn(items[index]);
219
+ }
220
+ }
221
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
222
+ return results;
223
+ }
224
+
225
+ /** Decrypts one message and shapes it into a `LocalIndexEntity`, or `undefined` when nothing usable was
226
+ * recovered (a decrypt failure, or a message that turns out not to actually be encrypted despite the
227
+ * placeholder subject) - mirrors `searchTier3.ts`'s own `!security.html && !security.subject` discard
228
+ * rule exactly, for the same reason. */
229
+ async function buildEntity(message: Message, sealer: PassSealer): Promise<LocalIndexEntity | undefined | typeof FETCH_FAILED> {
230
+ let rawMime: Awaited<ReturnType<typeof getMessageRawContent>>;
231
+ try {
232
+ rawMime = await getMessageRawContent(message.uid);
233
+ } catch {
234
+ // Distinct from "undecryptable": a message that couldn't even be fetched may well be searchable,
235
+ // so the pass can't claim complete coverage.
236
+ return FETCH_FAILED;
237
+ }
238
+ try {
239
+ const security = await sealer.evaluate(message, rawMime);
240
+ if (!security.subject && !security.html) {
241
+ return undefined;
242
+ }
243
+ const participants = [message.from.address, message.from.displayName, ...message.recipients.map((r) => r.address)]
244
+ .filter(Boolean)
245
+ .join(" ");
246
+ const setFlags = Object.entries(message.flags)
247
+ .filter(([, value]) => value)
248
+ .map(([key]) => key);
249
+ // Leading/trailing comma so `flags LIKE '%,x,%'` (localIndexSchema.ts's buildSearchPredicates())
250
+ // matches correctly even for the first/last flag in the list.
251
+ const flags = `,${setFlags.join(",")},`;
252
+ const entity: LocalIndexEntity = {
253
+ entityType: "message",
254
+ entityUid: message.uid,
255
+ mailboxUid: message.mailboxUid,
256
+ folderUid: message.folderUid,
257
+ dateForSort: message.receivedDate,
258
+ participants,
259
+ flags,
260
+ hasAttachments: message.hasAttachments,
261
+ subject: security.subject,
262
+ body: security.html,
263
+ byteSize: 0, // filled in below, after the fields above are known
264
+ entityVersion: versionOf(message),
265
+ };
266
+ entity.byteSize = estimateByteSize(entity);
267
+ return entity;
268
+ } catch {
269
+ // Best-effort, matching searchTier3.ts's own Promise.allSettled-per-candidate posture - one
270
+ // message's fetch/decrypt failure never aborts the rest of the build.
271
+ return undefined;
272
+ }
273
+ }
274
+
275
+ /** Runs one full incremental build pass for `mailboxUid`: sets the window, walks mail folders newest-first
276
+ * (per this module's own doc comment on the folder-order simplification), decrypts and indexes only
277
+ * `"[...]"`-subject messages until each folder's own coverage passes the time floor, then clears the
278
+ * `building` flag. Messages already indexed at the same version are skipped, fetches run at most
279
+ * `FETCH_CONCURRENCY` at a time, and messages older than the eviction watermark (`WindowState.evictedBefore`)
280
+ * are neither fetched nor walked past. A failure partway through leaves whatever was indexed so far in place
281
+ * and records the pass as incomplete (spec §11's "incomplete-index UX... MUST indicate that coverage is
282
+ * partial" - see `Coverage.complete`). Rows the server no longer lists (deleted or moved elsewhere by any
283
+ * client) are pruned only within folders whose count held steady for a whole walk (see `MAX_WALK_ATTEMPTS`).
284
+ *
285
+ * Only one pass runs per mailbox (a newer call cancels the older), and every Worker call carries this pass's
286
+ * generation, so a pass outlived by a destroy can't write anything (see `cancelLocalIndexBuild()`).
287
+ *
288
+ * `windowConfig` defaults to this device's own configured byte budget (`getLocalIndexByteBudget()` -
289
+ * 500 MB in a browser tab, 1 GB in Electron, or whatever the user has since set in Settings > Encryption)
290
+ * alongside the fixed time floor above. Evaluated fresh on every call with no explicit override, so a
291
+ * preference change in Settings takes effect starting with this mailbox's next build pass (its next
292
+ * unlock), without requiring a reload.
293
+ */
294
+ export async function buildLocalIndex(
295
+ mailboxUid: string,
296
+ unlocked: UnlockedKeys,
297
+ folders: Folder[],
298
+ windowConfig: LocalIndexWindowConfig = { timeFloorMonths: WEB_TIME_FLOOR_MONTHS, byteBudgetBytes: getLocalIndexByteBudget() },
299
+ ): Promise<void> {
300
+ // At most one pass per mailbox: a newer one cancels the older and waits for it to wind down first.
301
+ const previous = activeBuilds.get(mailboxUid);
302
+ const controller = new AbortController();
303
+ const run = (async () => {
304
+ if (previous) {
305
+ previous.controller.abort();
306
+ await previous.settled;
307
+ }
308
+ await runBuildPass(mailboxUid, unlocked, folders, windowConfig, controller.signal);
309
+ })();
310
+ const entry: ActiveBuild = { controller, settled: run.catch(() => undefined) };
311
+ activeBuilds.set(mailboxUid, entry);
312
+ try {
313
+ await run;
314
+ } finally {
315
+ if (activeBuilds.get(mailboxUid) === entry) {
316
+ activeBuilds.delete(mailboxUid);
317
+ }
318
+ }
319
+ }
320
+
321
+ interface ActiveBuild {
322
+ controller: AbortController;
323
+ /** Resolves (never rejects) once the pass has fully stopped. */
324
+ settled: Promise<void>;
325
+ }
326
+
327
+ const activeBuilds = new Map<string, ActiveBuild>();
328
+
329
+ /** Stops a mailbox's running build pass, if any (e.g. its keys were just destroyed), resolving once it has
330
+ * wound down. The Worker independently rejects the pass's later calls once the index is destroyed (see
331
+ * `GenerationParams`); this just stops the fetching and decrypting too. Never rejects. */
332
+ export async function cancelLocalIndexBuild(mailboxUid: string): Promise<void> {
333
+ const active = activeBuilds.get(mailboxUid);
334
+ if (active) {
335
+ active.controller.abort();
336
+ await active.settled;
337
+ }
338
+ }
339
+
340
+ function isOlderThan(date: string, watermark: string | undefined): boolean {
341
+ return !!watermark && new Date(date).getTime() < new Date(watermark).getTime();
342
+ }
343
+
344
+ /** How one folder's walk ended. */
345
+ type WalkOutcome =
346
+ /** Reached the time floor or the end of the folder. */
347
+ | "done"
348
+ /** Reached messages older than the eviction watermark, which is itself within the time floor. */
349
+ | "budget"
350
+ /** A listing failed - nothing can be concluded about the rest of the folder. */
351
+ | "failed";
352
+
353
+ async function runBuildPass(mailboxUid: string, unlocked: UnlockedKeys, folders: Folder[], windowConfig: LocalIndexWindowConfig, signal: AbortSignal): Promise<void> {
354
+ const generation = nextLocalIndexGeneration();
355
+ const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
356
+ signal.throwIfAborted();
357
+ await initLocalIndex({ mailboxUid, indexKey, generation });
358
+ let { evictedBefore } = await setLocalIndexWindow(mailboxUid, windowConfig.timeFloorMonths, windowConfig.byteBudgetBytes, generation);
359
+ await setLocalIndexBuilding(mailboxUid, true, { complete: false, generation });
360
+ // Mail arriving after this moment isn't guaranteed to be picked up by this pass.
361
+ const coveredUntil = new Date(Date.now() - CLOCK_SKEW_MARGIN_MS).toISOString();
362
+ let cutoff: Date | undefined;
363
+ if (windowConfig.timeFloorMonths > 0) {
364
+ cutoff = new Date();
365
+ cutoff.setMonth(cutoff.getMonth() - windowConfig.timeFloorMonths);
366
+ }
367
+ // A pass is "complete" only if every folder was walked back to the time floor (or its end) with a stable
368
+ // count, no listing/fetch failure and no budget cut-off - the only case where coverage can be used to
369
+ // narrow Tier 3.
370
+ let complete = true;
371
+ const sealer = createPassSealer(mailboxUid, unlocked, signal);
372
+ try {
373
+ if (folders.some((f) => !MESSAGE_FOLDER_TYPES.has(f.type) && !NON_MESSAGE_FOLDER_TYPES.has(f.type))) {
374
+ complete = false;
375
+ }
376
+ const mailFolders = folders
377
+ .filter((f) => MESSAGE_FOLDER_TYPES.has(f.type))
378
+ .sort((a, b) => (FOLDER_PRIORITY[a.type] ?? 99) - (FOLDER_PRIORITY[b.type] ?? 99));
379
+ const seenEncryptedUids = new Set<string>();
380
+
381
+ async function walkFolder(folder: Folder): Promise<WalkOutcome> {
382
+ for (let page = 0; ; page++) {
383
+ signal.throwIfAborted();
384
+ let messages: Message[];
385
+ try {
386
+ messages = await listMessages(folder.uid, { page, limit: PAGE_SIZE });
387
+ } catch {
388
+ return "failed";
389
+ }
390
+ if (messages.length === 0) {
391
+ return "done";
392
+ }
393
+ const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
394
+ for (const m of encrypted) {
395
+ seenEncryptedUids.add(m.uid);
396
+ }
397
+ // Anything older than the eviction watermark would only be inserted and evicted again.
398
+ const candidates = encrypted.filter((m) => !isOlderThan(m.receivedDate, evictedBefore));
399
+ if (candidates.length > 0) {
400
+ // Skip anything already indexed at the same version - a re-unlock used to re-fetch and
401
+ // re-decrypt the entire window every time.
402
+ const indexed = await getIndexedVersions(
403
+ mailboxUid,
404
+ candidates.map((m) => m.uid),
405
+ );
406
+ const changed = candidates.filter((m) => indexed[m.uid] !== versionOf(m));
407
+ const built = await mapWithConcurrency(changed, FETCH_CONCURRENCY, (m) => buildEntity(m, sealer));
408
+ signal.throwIfAborted();
409
+ if (built.includes(FETCH_FAILED)) {
410
+ complete = false;
411
+ }
412
+ const entities = built.filter((e): e is LocalIndexEntity => e !== undefined && e !== FETCH_FAILED);
413
+ if (entities.length > 0) {
414
+ ({ evictedBefore } = await indexLocalEntities(mailboxUid, entities, generation));
415
+ }
416
+ }
417
+ const oldestOnPage = messages[messages.length - 1].receivedDate;
418
+ if (evictedBefore && isOlderThan(oldestOnPage, evictedBefore) && isWithinTimeFloor(evictedBefore, cutoff)) {
419
+ return "budget";
420
+ }
421
+ if (!isWithinTimeFloor(oldestOnPage, cutoff) || messages.length < PAGE_SIZE) {
422
+ return "done";
423
+ }
424
+ }
425
+ }
426
+
427
+ /** Each folder's current total, or `undefined` if the listing failed. */
428
+ async function folderTotals(): Promise<Map<string, number> | undefined> {
429
+ try {
430
+ return new Map((await listFolders(mailboxUid)).map((f) => [f.uid, f.totalCount]));
431
+ } catch {
432
+ return undefined;
433
+ }
434
+ }
435
+
436
+ // Folders walked with a steady count and no listing failure - the only ones whose unseen rows can be
437
+ // pruned (deleted, or moved elsewhere by any client).
438
+ const reliable: string[] = [];
439
+ let totalsBefore = await folderTotals();
440
+ let toWalk = mailFolders;
441
+ for (let attempt = 0; attempt < MAX_WALK_ATTEMPTS && toWalk.length > 0; attempt++) {
442
+ const outcomes = new Map<string, WalkOutcome>();
443
+ for (const folder of toWalk) {
444
+ outcomes.set(folder.uid, await walkFolder(folder));
445
+ }
446
+ const totalsAfter = await folderTotals();
447
+ const changed: Folder[] = [];
448
+ for (const folder of toWalk) {
449
+ const outcome = outcomes.get(folder.uid);
450
+ if (outcome === "failed" || !totalsBefore || !totalsAfter) {
451
+ // A failed listing, or no counts to vouch for the walk with: indexed as far as it got, but
452
+ // neither complete nor prunable (see the `reliable` check below).
453
+ } else if (totalsAfter.get(folder.uid) === totalsBefore.get(folder.uid)) {
454
+ reliable.push(folder.uid);
455
+ complete &&= outcome === "done";
456
+ } else {
457
+ changed.push(folder);
458
+ }
459
+ }
460
+ totalsBefore = totalsAfter;
461
+ toWalk = changed;
462
+ }
463
+ // Failed, count-less, or still changing after every attempt.
464
+ if (reliable.length < mailFolders.length) {
465
+ complete = false;
466
+ }
467
+ if (reliable.length > 0) {
468
+ // Every reliable folder was walked back to at least the watermark (or the floor).
469
+ const since = evictedBefore && isWithinTimeFloor(evictedBefore, cutoff) ? evictedBefore : cutoff?.toISOString();
470
+ await pruneLocalEntities(mailboxUid, [...seenEncryptedUids], since, { folderUids: reliable, generation });
471
+ }
472
+ } catch (err) {
473
+ complete = false;
474
+ throw err;
475
+ } finally {
476
+ // Rejected (harmlessly) when the index was destroyed mid-pass.
477
+ await setLocalIndexBuilding(mailboxUid, false, { complete, coveredFrom: cutoff?.toISOString(), coveredUntil, generation }).catch(() => undefined);
478
+ // At most `SEAL_WRITE_CONCURRENCY` writes are still in flight; queued ones are dropped if the pass was aborted.
479
+ await sealer.drain();
480
+ }
481
+ }