@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,490 +1,546 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
6
- import { HiOutlineBars3 } from "react-icons/hi2";
7
- import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
8
- import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
9
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
- import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
11
- import AppShell, { AppShellProps } from "../../layout/AppShell.js";
12
- import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
13
- import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
14
- import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
15
- import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
16
- import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
17
- import { LiveUpdates, NO_LIVE_UPDATES } from "../../../mail/useMailLiveUpdates.js";
18
- import { MAILBOX_LIST_LIMIT, MailConnectionContext, useMailConnection } from "../../../mail/useMailConnection.js";
19
- import { folderRows } from "../../../mail/folderTree.js";
20
- import {
21
- CountTracker,
22
- FolderBadge,
23
- FolderCount,
24
- badgeFor,
25
- badgeLabel,
26
- countOfFolder,
27
- inboxUnreadTotal,
28
- } from "../../../mail/folderCounts.js";
29
- import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
30
- import { pendingCountFor, usePendingSends } from "../../../mail/outbox/pendingSends.js";
31
- import OutboxBadge from "../OutboxBadge.js";
32
- import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
33
- import { SHORTCUTS } from "../../../keyboard/keymap.js";
34
- import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
35
- import { useShortcut } from "../../../keyboard/useShortcut.js";
36
-
37
- export type MailShellProps = Omit<AppShellProps, "active">;
38
-
39
- /** One mailbox's own mail folders (already filtered to `MAIL_FOLDER_TYPES` and unsorted) - one entry per
40
- * mailbox in `mailboxes`, fetched in parallel so every accessible mailbox's folder tree can render
41
- * simultaneously (see `MailShell`'s own doc comment on why this replaced the old single-mailbox `folders`
42
- * field). `error` is set (and `folders` left empty) for a mailbox whose own `listFolders()` call failed -
43
- * one mailbox's fetch failure must never blank out every other mailbox's section. */
44
- export interface MailboxFolders {
45
- mailbox: Mailbox;
46
- folders: Folder[];
47
- error?: string;
48
- }
49
-
50
- /** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
51
- * mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
52
- * state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
53
- * `contacts`/`tasks`/`notes`/`user` folders (Mail's own sidebar already excludes those - see
54
- * `MAIL_FOLDER_TYPES`). */
55
- export const AGGREGATE_FOLDER_TYPES = ["inbox", "sent_items", "drafts", "deleted_items", "junk"] as const;
56
- export type AggregateFolderType = (typeof AGGREGATE_FOLDER_TYPES)[number];
57
-
58
- function isAggregateFolderType(value: string | null): value is AggregateFolderType {
59
- return !!value && (AGGREGATE_FOLDER_TYPES as readonly string[]).includes(value);
60
- }
61
-
62
- export interface MailShellContextValue {
63
- /** The mailbox currently selected (`?mailboxUid=`) - `undefined` while an aggregate pseudo-folder is
64
- * active instead (`aggregateFolderType` set), since there's no single mailbox to speak of then. */
65
- mailboxUid?: string;
66
- /** The folder currently selected (`?folderUid=`, or the selected mailbox's Inbox) - `undefined` in
67
- * aggregate mode, for the same reason. */
68
- folderUid?: string;
69
- /** Set only when an aggregate pseudo-folder (`?aggregate=inbox` etc.) is selected instead of a real,
70
- * single mailbox+folder - mutually exclusive with `mailboxUid`/`folderUid` above. */
71
- aggregateFolderType?: AggregateFolderType;
72
- mailboxes: Mailbox[];
73
- /** Every accessible mailbox's own mail folders - replaces the old single-mailbox `folders: Folder[]`
74
- * now that every mailbox's tree renders at once (see `MailboxFolders`'s own doc comment). */
75
- mailboxFolders: MailboxFolders[];
76
- /**
77
- * Adds a folder a page has just created (the Move to prompt's "New folder") to the tree this shell
78
- * already fetched, so it appears in the sidebar and in every folder picker without a reload - this
79
- * framework has no client-side router, so a reload is a whole page load.
80
- *
81
- * A no-op on the default context value, which is only ever read outside a real shell.
82
- */
83
- onFolderCreated: (folder: Folder) => void;
84
- /** Says which folders the messages on screen live in: one the sidebar does not know is looked for (see `MailConnection.noteFolderUids`). A no-op on the default context value. */
85
- noteFolderUids: (folderUids: Iterable<string>) => void;
86
- /**
87
- * Bumped whenever new mail (or another change to a message) may have arrived - a push event, a reconnect or the safety-net
88
- * poll - so the list on screen can quietly refetch its first page. See `useMailLiveUpdates()`. Never changes on the default
89
- * context value, which is only ever read outside a real shell.
90
- */
91
- live: LiveUpdates;
92
- /**
93
- * Applies a change to a message (`previous` to `next`; `next` absent when it was deleted) to the folder badges at once, and
94
- * hands back what to call when the server has answered: `settle()` keeps it, `revert()` takes it back. Every place that
95
- * reads, moves or deletes a message goes through this (see `setReadState()`), so the badges follow. A no-op on the default
96
- * context value, which is only ever read outside a real shell.
97
- */
98
- trackMessageChange: (previous: Message, next: Message | null) => CountTracker;
99
- }
100
-
101
- const NO_TRACKER: CountTracker = { settle: () => undefined, revert: () => undefined };
102
-
103
- const MailShellContext = createContext<MailShellContextValue>({
104
- mailboxes: [],
105
- mailboxFolders: [],
106
- onFolderCreated: () => undefined,
107
- noteFolderUids: () => undefined,
108
- live: NO_LIVE_UPDATES,
109
- trackMessageChange: () => NO_TRACKER,
110
- });
111
-
112
- /** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
113
- export function useMailShell(): MailShellContextValue {
114
- return useContext(MailShellContext);
115
- }
116
-
117
- const FOLDER_LABELS: Record<string, string> = {
118
- inbox: "Inbox",
119
- sent_items: "Sent Items",
120
- drafts: "Drafts",
121
- outbox: "Outbox",
122
- junk: "Junk Email",
123
- archive: "Archive",
124
- deleted_items: "Deleted Items",
125
- };
126
-
127
- /** The badge an "All Mailboxes" entry shows: the folders of that type, summed across every mailbox, under the same rules as a single folder's. */
128
- function aggregateBadge(mailboxFolders: MailboxFolders[], type: AggregateFolderType, counts: Record<string, FolderCount>): FolderBadge | undefined {
129
- const sum = mailboxFolders.reduce(
130
- (total, mf) => {
131
- const folder = mf.folders.find((f) => f.type === type);
132
- const count = folder ? countOfFolder(folder, counts) : { unread: 0, total: 0 };
133
- return { unread: total.unread + count.unread, total: total.total + count.total };
134
- },
135
- { unread: 0, total: 0 },
136
- );
137
- return badgeFor(type, sum);
138
- }
139
-
140
- /** A folder's badge: an accent pill with the unread count, or - for the folders that show how many they hold - plain muted text. */
141
- function FolderBadgeChip({ badge }: { badge: FolderBadge }) {
142
- return (
143
- <span
144
- className={[
145
- "text-xs rounded-pill py-0.5 px-1.5",
146
- badge.kind === "unread" ? "font-bold bg-primary/15 text-primary-dark" : "font-medium text-text-muted",
147
- ].join(" ")}
148
- >
149
- <span aria-hidden="true">{badge.value}</span>
150
- <span className="sr-only"> {badgeLabel(badge)}</span>
151
- </span>
152
- );
153
- }
154
-
155
- type Status = "checking" | "error" | "ready";
156
-
157
- export { MAILBOX_LIST_LIMIT };
158
-
159
- /**
160
- * A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
161
- * `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
162
- * returns, i.e. a descendant of `MailShell`, not an ancestor — a hook call made directly in `MailShell`'s
163
- * own function body would see only whatever context exists *above* `MailShell`, never a provider one of
164
- * its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
165
- * inside that subtree.
166
- *
167
- * Opens with the caller's own mailbox (the shell has already listed them), so the window can start on its
168
- * signature, draft and encryption lookups straight away instead of first asking which mailbox that is; the
169
- * compose window's own From field is where the sender is chosen. Fetches the window's code as the pointer or
170
- * keyboard reaches the button, so the click finds it already here.
171
- */
172
- function ComposeButton({ mailboxUid }: { mailboxUid?: string }) {
173
- const { openCompose } = useCompose();
174
- const env = useKeyEnvironment();
175
- return (
176
- <button
177
- type="button"
178
- title={withHint("Compose", SHORTCUTS.mail.create, env)}
179
- aria-keyshortcuts={ariaKeyShortcuts(SHORTCUTS.mail.create, env)}
180
- onClick={() => openCompose({ mailboxUid })}
181
- onPointerEnter={prefetchComposeWindow}
182
- onFocus={prefetchComposeWindow}
183
- className="block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark"
184
- >
185
- Compose
186
- </button>
187
- );
188
- }
189
-
190
- /**
191
- * Mail's keyboard shortcuts that don't belong to a list or a message: "New message". A component of its own for the reason `ComposeButton`
192
- * is - `useCompose()` only resolves below `ComposeProvider`, i.e. inside the `AppShell` this shell renders - and mounted once, whatever number
193
- * of Compose buttons (the sidebar, the folders drawer) are on screen. Opens the same window, for the same mailbox, as the button.
194
- */
195
- function MailShortcuts({ mailboxUid }: { mailboxUid?: string }) {
196
- const { openCompose } = useCompose();
197
- useShortcut(SHORTCUTS.mail.create, () => openCompose({ mailboxUid }));
198
- return null;
199
- }
200
-
201
- /**
202
- * Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
203
- * aggregate section) + content area, rendered inside the shared `AppShell` chrome (icon rail, header,
204
- * impersonation banner — see that component). There is no client-side router in this framework (see
205
- * `ReactRoute`'s file-convention resolver) — the selected mailbox/folder (or aggregate pseudo-folder) live
206
- * in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read once on mount (never during
207
- * the initial render itself, matching every other query-param reader in this codebase — e.g.
208
- * `apps/admin/quarantine`'s `readMailboxUid()` — so the server-rendered and just-hydrated client markup
209
- * match).
210
- *
211
- * Every accessible mailbox's folder tree renders at once - there is no "switch mailbox" affordance
212
- * anymore (the mailbox `<select>` this shell used to have is gone) - so a single-mailbox user sees zero
213
- * behavior change from before this, and a multi-mailbox user sees every mailbox's folders (and the new
214
- * aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
215
- * of folders" requirement.
216
- */
217
- export default function MailShell({
218
- userUid,
219
- authServerUrl,
220
- impersonating,
221
- impersonationBaseUrl,
222
- trusted,
223
- trustedRoles,
224
- pluginNav,
225
- children,
226
- }: PropsWithChildren<MailShellProps>) {
227
- // The mailboxes, their folders and the live connection come from the persistent app frame when there is one (`AppChrome` owns them, so
228
- // they survive Mail -> Calendar -> Mail and the push socket, pop-ups, counters and tab title work in every app). A page rendered
229
- // outside the frame - a test, a plugin page - has no frame to ask, so this shell runs the same hook for itself, and renders the pop-ups.
230
- const navigate = useNavigate();
231
- const hosted = useContext(MailConnectionContext);
232
- const own = useMailConnection({ userUid, enabled: !hosted, open: navigate });
233
- const { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, noteFolderUids, live, folderCounts, outbox: outboxStatuses } = hosted ?? own;
234
- // The messages this tab is still handing to the server: the Outbox pill counts them at once.
235
- const pendingSends = usePendingSends();
236
- const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
237
- const [requestedFolderUid, setRequestedFolderUid] = useState<string | null>(null);
238
- const [requestedAggregateType, setRequestedAggregateType] = useState<string | null>(null);
239
- const [drawerOpen, setDrawerOpen] = useState(false);
240
-
241
- // The selection lives in the URL, and the router (`AppRouter`) changes the URL without a page load when a folder link is
242
- // clicked - so it is read from the router's location, which updates, rather than once from `window.location`. Still read
243
- // in an effect, never during the first render, so the server render and the hydrating render agree.
244
- const search = useLocationSearch();
245
- useEffect(() => {
246
- const params = new URLSearchParams(search);
247
- setRequestedMailboxUid(params.get("mailboxUid"));
248
- setRequestedFolderUid(params.get("folderUid"));
249
- setRequestedAggregateType(params.get("aggregate"));
250
- }, [search]);
251
- // Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
252
- // with the page - is closed here.
253
- useEffect(() => {
254
- setDrawerOpen(false);
255
- }, [search]);
256
-
257
- const aggregateFolderType: AggregateFolderType | undefined = isAggregateFolderType(requestedAggregateType)
258
- ? requestedAggregateType
259
- : undefined;
260
-
261
- const mailboxUid: string | undefined = aggregateFolderType
262
- ? undefined
263
- : (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
264
- mailboxes[0]?.uid;
265
-
266
- const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
267
- const folderUid: string | undefined = aggregateFolderType
268
- ? undefined
269
- : (requestedFolderUid && selectedMailboxFolders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
270
- selectedMailboxFolders.find((f) => f.type === "inbox")?.uid;
271
-
272
- // The mailbox `KeyEnrollmentGate`/`LocalIndexLifecycle`/`ComposeButton` treat as "the" mailbox when
273
- // there's no single selected one to use (aggregate mode) - the caller's own owned mailbox if they
274
- // have one, else whichever accessible mailbox happens to be first. See MailShell's own doc comment on
275
- // this being an accepted limitation: an aggregate-view message from a *different*, not-yet-visited
276
- // mailbox may still need that mailbox's own folder view opened directly to unlock/decrypt it.
277
- const defaultMailboxUid = mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
278
- const activeMailboxUid = mailboxUid ?? defaultMailboxUid;
279
-
280
- const counts = folderCounts.counts;
281
- const trackMessageChange = folderCounts.track;
282
- // `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
283
- useUnreadTitle(inboxUnreadTotal(mailboxFolders, counts), { enabled: !hosted });
284
-
285
- const contextValue = useMemo<MailShellContextValue>(
286
- () => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange }),
287
- [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange],
288
- );
289
-
290
- // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
291
- // mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
292
- // `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
293
- // aggregate mode even with mailboxes present.
294
- if (userUid && status === "ready" && mailboxes.length === 0) {
295
- return <MailboxProvisioning />;
296
- }
297
-
298
- let inner: ReactNode = null;
299
- if (userUid && status === "checking") {
300
- // Renders immediately (no network round trip needed) so switching into Mail never shows a blank
301
- // pane while `listMailboxes()` is in flight — the outline below mirrors the real sidebar's shape.
302
- inner = (
303
- <aside className="w-64 shrink-0 bg-surface border-r border-border flex flex-col p-3 gap-4">
304
- <Skeleton height="h-9" className="rounded-sm" />
305
- <SkeletonList count={6} />
306
- </aside>
307
- );
308
- } else if (userUid && status === "error") {
309
- inner = (
310
- <div className="flex-1 flex items-center justify-center p-8">
311
- <div className="w-full max-w-md">
312
- <Alert>{error}</Alert>
313
- </div>
314
- </div>
315
- );
316
- } else if (userUid && status === "ready") {
317
- // A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
318
- // `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
319
- // unmounted).
320
- const sidebarContent = () => (
321
- <>
322
- <div className="p-3">
323
- <ComposeButton mailboxUid={defaultMailboxUid} />
324
- </div>
325
- {foldersLoading ? (
326
- <div className="flex-1 overflow-y-auto px-3 pb-3">
327
- <SkeletonList count={6} className="pt-1" />
328
- </div>
329
- ) : (
330
- <nav className="flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3">
331
- {mailboxes.length > 1 && (
332
- <div>
333
- <div className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5">
334
- All Mailboxes
335
- </div>
336
- <div className="flex flex-col gap-0.5">
337
- {AGGREGATE_FOLDER_TYPES.map((type) => {
338
- const badge = aggregateBadge(mailboxFolders, type, counts);
339
- return (
340
- <a
341
- key={type}
342
- href={`/?aggregate=${encodeURIComponent(type)}`}
343
- className={[
344
- "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
345
- aggregateFolderType === type
346
- ? "bg-primary/10 text-primary-dark font-semibold"
347
- : "text-text hover:bg-surface-alt",
348
- ].join(" ")}
349
- >
350
- <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>{FOLDER_LABELS[type]}</span>
351
- {badge && <FolderBadgeChip badge={badge} />}
352
- </a>
353
- );
354
- })}
355
- </div>
356
- </div>
357
- )}
358
- {mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (
359
- <div key={mailbox.uid}>
360
- <div className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1 px-2.5 truncate">
361
- {mailbox.displayName}
362
- {mailbox.ownerUserUid ? "" : " (shared)"}
363
- </div>
364
- {mailboxError && (
365
- <div className="px-2.5 pb-1">
366
- <Alert>{mailboxError}</Alert>
367
- </div>
368
- )}
369
- <div className="flex flex-col gap-0.5">
370
- {folderRows(folders, pendingCountFor(mailbox.uid, pendingSends)).map((row) => {
371
- if (row.kind === "placeholder") {
372
- // A folder the server has not made (or told us about) yet - the Outbox and Sent Items are made with the first message
373
- // sent - shown the moment a message is on its way. Nothing to open; the real folder replaces it when it arrives.
374
- const sending = pendingCountFor(mailbox.uid, pendingSends);
375
- return (
376
- <div
377
- key={`placeholder-${row.type}`}
378
- data-folder-placeholder={row.type}
379
- className="flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted"
380
- >
381
- <span>{FOLDER_LABELS[row.type]}</span>
382
- {row.type === "outbox" && <OutboxBadge total={sending} pendingHere={sending} />}
383
- </div>
384
- );
385
- }
386
- const folder = row.folder;
387
- const badge = badgeFor(folder.type, countOfFolder(folder, counts));
388
- return (
389
- <a
390
- key={folder.uid}
391
- href={`/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`}
392
- className={[
393
- "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
394
- folder.uid === folderUid
395
- ? "bg-primary/10 text-primary-dark font-semibold"
396
- : "text-text hover:bg-surface-alt",
397
- ].join(" ")}
398
- >
399
- <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>
400
- {FOLDER_LABELS[folder.type] ?? folder.name}
401
- </span>
402
- {badge &&
403
- (folder.type === "outbox" ? (
404
- <OutboxBadge
405
- total={badge.value}
406
- status={outboxStatuses[folder.uid]}
407
- pendingHere={pendingCountFor(mailbox.uid, pendingSends)}
408
- />
409
- ) : (
410
- <FolderBadgeChip badge={badge} />
411
- ))}
412
- </a>
413
- );
414
- })}
415
- </div>
416
- </div>
417
- ))}
418
- </nav>
419
- )}
420
- </>
421
- );
422
-
423
- inner = (
424
- <>
425
- <MailShortcuts mailboxUid={defaultMailboxUid} />
426
- <aside className="hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col">{sidebarContent()}</aside>
427
- <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Folders">
428
- <div className="flex flex-col">{sidebarContent()}</div>
429
- </Drawer>
430
- <main className="flex-1 min-w-0 overflow-y-auto">
431
- <button
432
- type="button"
433
- className="md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
434
- aria-label="Open folders"
435
- onClick={() => setDrawerOpen(true)}
436
- >
437
- <HiOutlineBars3 size={20} aria-hidden="true" />
438
- </button>
439
- <MailShellContext.Provider value={contextValue}>{children}</MailShellContext.Provider>
440
- </main>
441
- </>
442
- );
443
- }
444
-
445
- // Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
446
- // resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
447
- // mailboxUid is supplied (see its own doc comment). Wrapping only once ready (a conditional tree
448
- // position) would make AppShell itself remount the moment mailboxUid resolves, tearing down
449
- // whatever state/effects it had already started (confirmed by direct reproduction: the
450
- // impersonation banner's own internal state was lost exactly at that transition).
451
- const activeMailbox = mailboxes.find((mb) => mb.uid === activeMailboxUid);
452
- return (
453
- <KeyEnrollmentGate
454
- mailboxUid={activeMailboxUid}
455
- mailboxAddress={activeMailbox?.primarySmtpAddress}
456
- mailboxKeys={activeMailbox?.keys}
457
- // Unlocking is only actually required to sign/encrypt a compose, read an already-encrypted
458
- // message, or change encryption settings - not merely to open Mail. Those specific call sites
459
- // (ComposeWindow, MessageDetailPane) request an unlock on demand via useUnlockPrompt() instead.
460
- // First-time provisioning (a mailbox with no vault at all yet) still always blocks - see this
461
- // prop's own doc comment on KeyEnrollmentGateProps.
462
- blocking={false}
463
- // A shared/delegated mailbox the caller doesn't own is never provisioned from here - its keys
464
- // belong to its owner (or its admins), not to whoever happens to open it first - and neither is
465
- // the impersonated user's own mailbox: an administrator must never choose its password/recovery codes.
466
- canProvision={!impersonating && activeMailbox?.ownerUserUid === userUid}
467
- >
468
- <LocalIndexLifecycle
469
- mailboxUid={activeMailboxUid}
470
- mailboxKeys={activeMailbox?.keys}
471
- folders={mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? []}
472
- // A full page may be truncated - pruning against it would delete indexes of accessible mailboxes
473
- // beyond the limit, so it's only trusted as the complete list when it came back short.
474
- accessibleMailboxUids={status === "ready" && mailboxes.length < MAILBOX_LIST_LIMIT ? mailboxes.map((mb) => mb.uid) : undefined}
475
- />
476
- <AppShell
477
- active="mail"
478
- userUid={userUid}
479
- authServerUrl={authServerUrl}
480
- impersonating={impersonating}
481
- impersonationBaseUrl={impersonationBaseUrl}
482
- trusted={trusted}
483
- trustedRoles={trustedRoles}
484
- pluginNav={pluginNav}
485
- >
486
- {inner}
487
- </AppShell>
488
- </KeyEnrollmentGate>
489
- );
490
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
6
+ import { HiOutlineBars3, HiOutlinePencilSquare } from "react-icons/hi2";
7
+ import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
8
+ import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
11
+ import AppShell, { AppShellProps } from "../../layout/AppShell.js";
12
+ import FloatingActionButton from "../../layout/FloatingActionButton.js";
13
+ import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
14
+ import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
15
+ import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
+ import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
17
+ import LocalIndexLifecycle from "../../../search/LocalIndexLifecycle.js";
18
+ import { LiveUpdates, NO_LIVE_UPDATES } from "../../../mail/useMailLiveUpdates.js";
19
+ import { MAILBOX_LIST_LIMIT, MailConnectionContext, useMailConnection } from "../../../mail/useMailConnection.js";
20
+ import { folderRows } from "../../../mail/folderTree.js";
21
+ import { primaryMailboxUid } from "../../../mail/primaryMailbox.js";
22
+ import {
23
+ CountTracker,
24
+ FolderBadge,
25
+ FolderCount,
26
+ badgeFor,
27
+ countOfFolder,
28
+ inboxUnreadTotal,
29
+ unreadBadgeTotal,
30
+ } from "../../../mail/folderCounts.js";
31
+ import { ALL_MAILBOXES_SECTION, useSidebarSections } from "../../../mail/useCollapsedSections.js";
32
+ import FolderBadgeChip from "./FolderBadgeChip.js";
33
+ import SidebarSection from "./SidebarSection.js";
34
+ import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
35
+ import { pendingCountFor, usePendingSends } from "../../../mail/outbox/pendingSends.js";
36
+ import OutboxBadge from "../OutboxBadge.js";
37
+ import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
38
+ import { SHORTCUTS } from "../../../keyboard/keymap.js";
39
+ import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
40
+ import { useShortcut } from "../../../keyboard/useShortcut.js";
41
+
42
+ export type MailShellProps = Omit<AppShellProps, "active">;
43
+
44
+ /** One mailbox's own mail folders (already filtered to `MAIL_FOLDER_TYPES` and unsorted) - one entry per
45
+ * mailbox in `mailboxes`, fetched in parallel so every accessible mailbox's folder tree can render
46
+ * simultaneously (see `MailShell`'s own doc comment on why this replaced the old single-mailbox `folders`
47
+ * field). `error` is set (and `folders` left empty) for a mailbox whose own `listFolders()` call failed -
48
+ * one mailbox's fetch failure must never blank out every other mailbox's section. */
49
+ export interface MailboxFolders {
50
+ mailbox: Mailbox;
51
+ folders: Folder[];
52
+ error?: string;
53
+ }
54
+
55
+ /** The well-known mail folder types an "aggregate" pseudo-folder can merge across every accessible
56
+ * mailbox - a fixed, small set: `outbox` is deliberately excluded (transient per-mailbox send-queue
57
+ * state, not a "merge across mailboxes" concept), and there's no aggregate concept for `calendar`/
58
+ * `contacts`/`tasks`/`notes`/`user` folders (Mail's own sidebar already excludes those - see
59
+ * `MAIL_FOLDER_TYPES`). */
60
+ export const AGGREGATE_FOLDER_TYPES = ["inbox", "sent_items", "drafts", "deleted_items", "junk"] as const;
61
+ export type AggregateFolderType = (typeof AGGREGATE_FOLDER_TYPES)[number];
62
+
63
+ function isAggregateFolderType(value: string | null): value is AggregateFolderType {
64
+ return !!value && (AGGREGATE_FOLDER_TYPES as readonly string[]).includes(value);
65
+ }
66
+
67
+ export interface MailShellContextValue {
68
+ /** The mailbox currently selected (`?mailboxUid=`) - `undefined` while an aggregate pseudo-folder is
69
+ * active instead (`aggregateFolderType` set), since there's no single mailbox to speak of then. */
70
+ mailboxUid?: string;
71
+ /** The folder currently selected (`?folderUid=`, or the selected mailbox's Inbox) - `undefined` in
72
+ * aggregate mode, for the same reason. */
73
+ folderUid?: string;
74
+ /** Set only when an aggregate pseudo-folder (`?aggregate=inbox` etc.) is selected instead of a real,
75
+ * single mailbox+folder - mutually exclusive with `mailboxUid`/`folderUid` above. */
76
+ aggregateFolderType?: AggregateFolderType;
77
+ mailboxes: Mailbox[];
78
+ /** Every accessible mailbox's own mail folders - replaces the old single-mailbox `folders: Folder[]`
79
+ * now that every mailbox's tree renders at once (see `MailboxFolders`'s own doc comment). */
80
+ mailboxFolders: MailboxFolders[];
81
+ /**
82
+ * Adds a folder a page has just created (the Move to prompt's "New folder") to the tree this shell
83
+ * already fetched, so it appears in the sidebar and in every folder picker without a reload - this
84
+ * framework has no client-side router, so a reload is a whole page load.
85
+ *
86
+ * A no-op on the default context value, which is only ever read outside a real shell.
87
+ */
88
+ onFolderCreated: (folder: Folder) => void;
89
+ /** Says which folders the messages on screen live in: one the sidebar does not know is looked for (see `MailConnection.noteFolderUids`). A no-op on the default context value. */
90
+ noteFolderUids: (folderUids: Iterable<string>) => void;
91
+ /**
92
+ * Bumped whenever new mail (or another change to a message) may have arrived - a push event, a reconnect or the safety-net
93
+ * poll - so the list on screen can quietly refetch its first page. See `useMailLiveUpdates()`. Never changes on the default
94
+ * context value, which is only ever read outside a real shell.
95
+ */
96
+ live: LiveUpdates;
97
+ /**
98
+ * Applies a change to a message (`previous` to `next`; `next` absent when it was deleted) to the folder badges at once, and
99
+ * hands back what to call when the server has answered: `settle()` keeps it, `revert()` takes it back. Every place that
100
+ * reads, moves or deletes a message goes through this (see `setReadState()`), so the badges follow. A no-op on the default
101
+ * context value, which is only ever read outside a real shell.
102
+ */
103
+ trackMessageChange: (previous: Message, next: Message | null) => CountTracker;
104
+ /**
105
+ * The phone layout's header row beside the folders button, for the page to put its search box in (`createPortal()`): the shell
106
+ * owns the row, the page owns the search state. `null` until the row has rendered, and on the default context value.
107
+ */
108
+ mobileSearchSlot: HTMLElement | null;
109
+ }
110
+
111
+ const NO_TRACKER: CountTracker = { settle: () => undefined, revert: () => undefined };
112
+
113
+ const MailShellContext = createContext<MailShellContextValue>({
114
+ mailboxes: [],
115
+ mailboxFolders: [],
116
+ onFolderCreated: () => undefined,
117
+ noteFolderUids: () => undefined,
118
+ live: NO_LIVE_UPDATES,
119
+ trackMessageChange: () => NO_TRACKER,
120
+ mobileSearchSlot: null,
121
+ });
122
+
123
+ /** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
124
+ export function useMailShell(): MailShellContextValue {
125
+ return useContext(MailShellContext);
126
+ }
127
+
128
+ const FOLDER_LABELS: Record<string, string> = {
129
+ inbox: "Inbox",
130
+ sent_items: "Sent Items",
131
+ drafts: "Drafts",
132
+ outbox: "Outbox",
133
+ junk: "Junk Email",
134
+ archive: "Archive",
135
+ deleted_items: "Deleted Items",
136
+ };
137
+
138
+ /** The badge an "All Mailboxes" entry shows: the folders of that type, summed across every mailbox, under the same rules as a single folder's. */
139
+ function aggregateBadge(mailboxFolders: MailboxFolders[], type: AggregateFolderType, counts: Record<string, FolderCount>): FolderBadge | undefined {
140
+ const sum = mailboxFolders.reduce(
141
+ (total, mf) => {
142
+ const folder = mf.folders.find((f) => f.type === type);
143
+ const count = folder ? countOfFolder(folder, counts) : { unread: 0, total: 0 };
144
+ return { unread: total.unread + count.unread, total: total.total + count.total };
145
+ },
146
+ { unread: 0, total: 0 },
147
+ );
148
+ return badgeFor(type, sum);
149
+ }
150
+
151
+ type Status = "checking" | "error" | "ready";
152
+
153
+ export { MAILBOX_LIST_LIMIT };
154
+
155
+ /**
156
+ * A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
157
+ * `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
158
+ * returns, i.e. a descendant of `MailShell`, not an ancestor — a hook call made directly in `MailShell`'s
159
+ * own function body would see only whatever context exists *above* `MailShell`, never a provider one of
160
+ * its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
161
+ * inside that subtree.
162
+ *
163
+ * Opens with the caller's own mailbox (the shell has already listed them), so the window can start on its
164
+ * signature, draft and encryption lookups straight away instead of first asking which mailbox that is; the
165
+ * compose window's own From field is where the sender is chosen. Fetches the window's code as the pointer or
166
+ * keyboard reaches the button, so the click finds it already here.
167
+ */
168
+ function ComposeButton({ mailboxUid }: { mailboxUid?: string }) {
169
+ const { openCompose } = useCompose();
170
+ const env = useKeyEnvironment();
171
+ return (
172
+ <button
173
+ type="button"
174
+ title={withHint("Compose", SHORTCUTS.mail.create, env)}
175
+ aria-keyshortcuts={ariaKeyShortcuts(SHORTCUTS.mail.create, env)}
176
+ onClick={() => openCompose({ mailboxUid })}
177
+ onPointerEnter={prefetchComposeWindow}
178
+ onFocus={prefetchComposeWindow}
179
+ className="block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark"
180
+ >
181
+ Compose
182
+ </button>
183
+ );
184
+ }
185
+
186
+ /**
187
+ * A small round Compose button floating at the bottom right on a phone, just above the bottom tab bar (which is `h-14`, fixed), for the
188
+ * layout where the sidebar's Compose button is behind the folders drawer. Opens the same window, for the same mailbox, as that button.
189
+ */
190
+ function MobileComposeButton({ mailboxUid }: { mailboxUid?: string }) {
191
+ const { openCompose } = useCompose();
192
+ return (
193
+ <FloatingActionButton
194
+ label="New message"
195
+ icon={HiOutlinePencilSquare}
196
+ onClick={() => openCompose({ mailboxUid })}
197
+ onPointerEnter={prefetchComposeWindow}
198
+ onFocus={prefetchComposeWindow}
199
+ />
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Mail's keyboard shortcuts that don't belong to a list or a message: "New message". A component of its own for the reason `ComposeButton`
205
+ * is - `useCompose()` only resolves below `ComposeProvider`, i.e. inside the `AppShell` this shell renders - and mounted once, whatever number
206
+ * of Compose buttons (the sidebar, the folders drawer) are on screen. Opens the same window, for the same mailbox, as the button.
207
+ */
208
+ function MailShortcuts({ mailboxUid }: { mailboxUid?: string }) {
209
+ const { openCompose } = useCompose();
210
+ useShortcut(SHORTCUTS.mail.create, () => openCompose({ mailboxUid }));
211
+ return null;
212
+ }
213
+
214
+ /**
215
+ * Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
216
+ * aggregate section) + content area, rendered inside the shared `AppShell` chrome (icon rail, header,
217
+ * impersonation banner — see that component). There is no client-side router in this framework (see
218
+ * `ReactRoute`'s file-convention resolver) — the selected mailbox/folder (or aggregate pseudo-folder) live
219
+ * in the URL's `?mailboxUid=`/`?folderUid=`/`?aggregate=` query params, read once on mount (never during
220
+ * the initial render itself, matching every other query-param reader in this codebase — e.g.
221
+ * `apps/admin/quarantine`'s `readMailboxUid()` — so the server-rendered and just-hydrated client markup
222
+ * match).
223
+ *
224
+ * Every accessible mailbox's folder tree renders at once - there is no "switch mailbox" affordance
225
+ * anymore (the mailbox `<select>` this shell used to have is gone) - so a single-mailbox user sees zero
226
+ * behavior change from before this, and a multi-mailbox user sees every mailbox's folders (and the new
227
+ * aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
228
+ * of folders" requirement.
229
+ *
230
+ * An address that names nothing opens "All Mailboxes > Inbox" when there is more than one mailbox (that is when the section exists), and
231
+ * the one mailbox's own Inbox otherwise; a `?mailboxUid=`, `?folderUid=` or `?aggregate=` always wins. Nothing is selected until the address
232
+ * has been read. What needs a single mailbox while an all-mailboxes view is open (Compose, the new-message shortcut, key unlocking) uses the
233
+ * primary mailbox.
234
+ */
235
+ export default function MailShell({
236
+ userUid,
237
+ authServerUrl,
238
+ impersonating,
239
+ impersonationBaseUrl,
240
+ trusted,
241
+ trustedRoles,
242
+ pluginNav,
243
+ children,
244
+ }: PropsWithChildren<MailShellProps>) {
245
+ // The mailboxes, their folders and the live connection come from the persistent app frame when there is one (`AppChrome` owns them, so
246
+ // they survive Mail -> Calendar -> Mail and the push socket, pop-ups, counters and tab title work in every app). A page rendered
247
+ // outside the frame - a test, a plugin page - has no frame to ask, so this shell runs the same hook for itself, and renders the pop-ups.
248
+ const navigate = useNavigate();
249
+ const hosted = useContext(MailConnectionContext);
250
+ const own = useMailConnection({ userUid, enabled: !hosted, open: navigate });
251
+ const { status, error, mailboxes, mailboxFolders, foldersLoading, onFolderCreated, noteFolderUids, live, folderCounts, outbox: outboxStatuses } = hosted ?? own;
252
+ // The messages this tab is still handing to the server: the Outbox pill counts them at once.
253
+ const pendingSends = usePendingSends();
254
+ const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
255
+ const [requestedFolderUid, setRequestedFolderUid] = useState<string | null>(null);
256
+ const [requestedAggregateType, setRequestedAggregateType] = useState<string | null>(null);
257
+ const [drawerOpen, setDrawerOpen] = useState(false);
258
+ // Whether the URL has been read yet (see below): until then the selection is not known, and the sidebar sections have nothing to open for.
259
+ const [selectionRead, setSelectionRead] = useState(false);
260
+
261
+ // The selection lives in the URL, and the router (`AppRouter`) changes the URL without a page load when a folder link is
262
+ // clicked - so it is read from the router's location, which updates, rather than once from `window.location`. Still read
263
+ // in an effect, never during the first render, so the server render and the hydrating render agree.
264
+ const search = useLocationSearch();
265
+ useEffect(() => {
266
+ const params = new URLSearchParams(search);
267
+ setRequestedMailboxUid(params.get("mailboxUid"));
268
+ setRequestedFolderUid(params.get("folderUid"));
269
+ setRequestedAggregateType(params.get("aggregate"));
270
+ setSelectionRead(true);
271
+ }, [search]);
272
+ // Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
273
+ // with the page - is closed here.
274
+ useEffect(() => {
275
+ setDrawerOpen(false);
276
+ }, [search]);
277
+
278
+ // What the address asks for. Nothing is selected until it has been read (the first render, before the effect above): choosing a
279
+ // default then would list a folder - the most expensive being every mailbox's - only to throw it away for the one the address names.
280
+ const requestedAggregate: AggregateFolderType | undefined =
281
+ selectionRead && isAggregateFolderType(requestedAggregateType) ? requestedAggregateType : undefined;
282
+ const requestedMailbox: string | undefined =
283
+ selectionRead && requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined;
284
+
285
+ // The mailbox the caller has not chosen: their own, never a shared one that merely sorts first. It is what compose, the new-message
286
+ // shortcut and everything else that needs a single mailbox use while an all-mailboxes view (which has none) is open.
287
+ const defaultMailboxUid = primaryMailboxUid(mailboxes, userUid);
288
+
289
+ // With several mailboxes the address that names nothing opens "All Mailboxes > Inbox", the merged Inbox; with one there is no such
290
+ // section, so it opens that mailbox's own Inbox. A mailbox, a folder or an all-mailboxes view in the address wins over either.
291
+ const aggregateByDefault =
292
+ selectionRead && !requestedAggregate && !requestedMailbox && requestedFolderUid === null && mailboxes.length > 1;
293
+ const aggregateFolderType: AggregateFolderType | undefined = requestedAggregate ?? (aggregateByDefault ? "inbox" : undefined);
294
+ const mailboxUid: string | undefined = aggregateFolderType || !selectionRead ? undefined : (requestedMailbox ?? defaultMailboxUid);
295
+
296
+ const selectedMailboxFolders = mailboxFolders.find((mf) => mf.mailbox.uid === mailboxUid)?.folders ?? [];
297
+ const folderUid: string | undefined = aggregateFolderType
298
+ ? undefined
299
+ : (requestedFolderUid && selectedMailboxFolders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
300
+ selectedMailboxFolders.find((f) => f.type === "inbox")?.uid;
301
+
302
+ // The mailbox `KeyEnrollmentGate`/`LocalIndexLifecycle`/`ComposeButton` treat as "the" mailbox when
303
+ // there's no single selected one to use (aggregate mode) - the caller's primary mailbox (`primaryMailbox()`). See MailShell's own doc comment on
304
+ // this being an accepted limitation: an aggregate-view message from a *different*, not-yet-visited
305
+ // mailbox may still need that mailbox's own folder view opened directly to unlock/decrypt it.
306
+ const activeMailboxUid = mailboxUid ?? defaultMailboxUid;
307
+
308
+ // Which sidebar sections are open: "All mailboxes" and the primary mailbox unless the reader chose otherwise. Any of them can be collapsed;
309
+ // a change of the open folder into another section opens that one (see `useSidebarSections()`); the first selection, once the address has been read
310
+ // and the mailboxes are here, is where a load lands, not a navigation. A lone mailbox has nothing to collapse, so its section is never a toggle.
311
+ const sections = useSidebarSections({
312
+ userUid,
313
+ primaryUid: defaultMailboxUid,
314
+ activeId: aggregateFolderType ? ALL_MAILBOXES_SECTION : mailboxUid,
315
+ });
316
+ const collapsible = mailboxes.length > 1;
317
+
318
+ const counts = folderCounts.counts;
319
+ const trackMessageChange = folderCounts.track;
320
+ // `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
321
+ useUnreadTitle(inboxUnreadTotal(mailboxFolders, counts), { enabled: !hosted });
322
+
323
+ // A state rather than a ref: the page reads it during its render, so it has to re-render once the row it names is in the document.
324
+ const [mobileSearchSlot, setMobileSearchSlot] = useState<HTMLElement | null>(null);
325
+ const contextValue = useMemo<MailShellContextValue>(
326
+ () => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot }),
327
+ [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot],
328
+ );
329
+
330
+ // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
331
+ // mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
332
+ // `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
333
+ // aggregate mode even with mailboxes present.
334
+ if (userUid && status === "ready" && mailboxes.length === 0) {
335
+ return <MailboxProvisioning />;
336
+ }
337
+
338
+ let inner: ReactNode = null;
339
+ if (userUid && status === "checking") {
340
+ // Renders immediately (no network round trip needed) so switching into Mail never shows a blank
341
+ // pane while `listMailboxes()` is in flight — the outline below mirrors the real sidebar's shape.
342
+ inner = (
343
+ <aside className="w-64 shrink-0 bg-surface border-r border-border flex flex-col p-3 gap-4">
344
+ <Skeleton height="h-9" className="rounded-sm" />
345
+ <SkeletonList count={6} />
346
+ </aside>
347
+ );
348
+ } else if (userUid && status === "error") {
349
+ inner = (
350
+ <div className="flex-1 flex items-center justify-center p-8">
351
+ <div className="w-full max-w-md">
352
+ <Alert>{error}</Alert>
353
+ </div>
354
+ </div>
355
+ );
356
+ } else if (userUid && status === "ready") {
357
+ // A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
358
+ // `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
359
+ // unmounted).
360
+ // `idPrefix` keeps each section's folder list's id (which its heading's `aria-controls` names) unique between the two.
361
+ const sidebarContent = (idPrefix: string) => (
362
+ <>
363
+ <div className="p-3">
364
+ <ComposeButton mailboxUid={defaultMailboxUid} />
365
+ </div>
366
+ {foldersLoading ? (
367
+ <div className="flex-1 overflow-y-auto px-3 pb-3">
368
+ <SkeletonList count={6} className="pt-1" />
369
+ </div>
370
+ ) : (
371
+ <nav className="flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-3">
372
+ {mailboxes.length > 1 && (
373
+ <SidebarSection
374
+ domId={`${idPrefix}-mail-section-${ALL_MAILBOXES_SECTION}`}
375
+ label="All Mailboxes"
376
+ collapsible={collapsible}
377
+ expanded={sections.isExpanded(ALL_MAILBOXES_SECTION)}
378
+ onToggle={() => sections.toggle(ALL_MAILBOXES_SECTION)}
379
+ unread={unreadBadgeTotal(mailboxFolders.flatMap((mf) => mf.folders), counts)}
380
+ >
381
+ {AGGREGATE_FOLDER_TYPES.map((type) => {
382
+ const badge = aggregateBadge(mailboxFolders, type, counts);
383
+ return (
384
+ <a
385
+ key={type}
386
+ href={`/?aggregate=${encodeURIComponent(type)}`}
387
+ className={[
388
+ "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
389
+ aggregateFolderType === type
390
+ ? "bg-primary/10 text-primary-dark font-semibold"
391
+ : "text-text hover:bg-surface-alt",
392
+ ].join(" ")}
393
+ >
394
+ <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>{FOLDER_LABELS[type]}</span>
395
+ {badge && <FolderBadgeChip badge={badge} />}
396
+ </a>
397
+ );
398
+ })}
399
+ </SidebarSection>
400
+ )}
401
+ {mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (
402
+ <SidebarSection
403
+ key={mailbox.uid}
404
+ domId={`${idPrefix}-mail-section-${mailbox.uid}`}
405
+ label={
406
+ <>
407
+ {mailbox.displayName}
408
+ {mailbox.ownerUserUid ? "" : " (shared)"}
409
+ </>
410
+ }
411
+ collapsible={collapsible}
412
+ expanded={sections.isExpanded(mailbox.uid)}
413
+ onToggle={() => sections.toggle(mailbox.uid)}
414
+ unread={unreadBadgeTotal(folders, counts)}
415
+ notice={
416
+ mailboxError && (
417
+ <div className="px-2.5 pb-1">
418
+ <Alert>{mailboxError}</Alert>
419
+ </div>
420
+ )
421
+ }
422
+ >
423
+ {folderRows(folders, pendingCountFor(mailbox.uid, pendingSends)).map((row) => {
424
+ if (row.kind === "placeholder") {
425
+ // A folder the server has not made (or told us about) yet - the Outbox and Sent Items are made with the first message
426
+ // sent - shown the moment a message is on its way. Nothing to open; the real folder replaces it when it arrives.
427
+ const sending = pendingCountFor(mailbox.uid, pendingSends);
428
+ return (
429
+ <div
430
+ key={`placeholder-${row.type}`}
431
+ data-folder-placeholder={row.type}
432
+ className="flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted"
433
+ >
434
+ <span>{FOLDER_LABELS[row.type]}</span>
435
+ {row.type === "outbox" && <OutboxBadge total={sending} pendingHere={sending} />}
436
+ </div>
437
+ );
438
+ }
439
+ const folder = row.folder;
440
+ const badge = badgeFor(folder.type, countOfFolder(folder, counts));
441
+ return (
442
+ <a
443
+ key={folder.uid}
444
+ href={`/?mailboxUid=${encodeURIComponent(mailbox.uid)}&folderUid=${encodeURIComponent(folder.uid)}`}
445
+ className={[
446
+ "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
447
+ folder.uid === folderUid
448
+ ? "bg-primary/10 text-primary-dark font-semibold"
449
+ : "text-text hover:bg-surface-alt",
450
+ ].join(" ")}
451
+ >
452
+ <span className={badge?.kind === "unread" ? "font-semibold" : undefined}>
453
+ {FOLDER_LABELS[folder.type] ?? folder.name}
454
+ </span>
455
+ {badge &&
456
+ (folder.type === "outbox" ? (
457
+ <OutboxBadge
458
+ total={badge.value}
459
+ status={outboxStatuses[folder.uid]}
460
+ pendingHere={pendingCountFor(mailbox.uid, pendingSends)}
461
+ />
462
+ ) : (
463
+ <FolderBadgeChip badge={badge} />
464
+ ))}
465
+ </a>
466
+ );
467
+ })}
468
+ </SidebarSection>
469
+ ))}
470
+ </nav>
471
+ )}
472
+ </>
473
+ );
474
+
475
+ inner = (
476
+ <>
477
+ <MailShortcuts mailboxUid={defaultMailboxUid} />
478
+ <aside className="hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col">{sidebarContent("desktop")}</aside>
479
+ <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Folders" fullScreen>
480
+ <div className="flex flex-col">{sidebarContent("mobile")}</div>
481
+ </Drawer>
482
+ <main className="flex-1 min-w-0 overflow-y-auto">
483
+ <div className="md:hidden flex items-center gap-2 p-3">
484
+ <button
485
+ type="button"
486
+ className="shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
487
+ aria-label="Open folders"
488
+ onClick={() => setDrawerOpen(true)}
489
+ >
490
+ <HiOutlineBars3 size={20} aria-hidden="true" />
491
+ </button>
492
+ <div ref={setMobileSearchSlot} className="flex-1 min-w-0" />
493
+ </div>
494
+ <MobileComposeButton mailboxUid={defaultMailboxUid} />
495
+ <MailShellContext.Provider value={contextValue}>{children}</MailShellContext.Provider>
496
+ </main>
497
+ </>
498
+ );
499
+ }
500
+
501
+ // Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
502
+ // resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
503
+ // mailboxUid is supplied (see its own doc comment). Wrapping only once ready (a conditional tree
504
+ // position) would make AppShell itself remount the moment mailboxUid resolves, tearing down
505
+ // whatever state/effects it had already started (confirmed by direct reproduction: the
506
+ // impersonation banner's own internal state was lost exactly at that transition).
507
+ const activeMailbox = mailboxes.find((mb) => mb.uid === activeMailboxUid);
508
+ return (
509
+ <KeyEnrollmentGate
510
+ mailboxUid={activeMailboxUid}
511
+ mailboxAddress={activeMailbox?.primarySmtpAddress}
512
+ mailboxKeys={activeMailbox?.keys}
513
+ // Unlocking is only actually required to sign/encrypt a compose, read an already-encrypted
514
+ // message, or change encryption settings - not merely to open Mail. Those specific call sites
515
+ // (ComposeWindow, MessageDetailPane) request an unlock on demand via useUnlockPrompt() instead.
516
+ // First-time provisioning (a mailbox with no vault at all yet) still always blocks - see this
517
+ // prop's own doc comment on KeyEnrollmentGateProps.
518
+ blocking={false}
519
+ // A shared/delegated mailbox the caller doesn't own is never provisioned from here - its keys
520
+ // belong to its owner (or its admins), not to whoever happens to open it first - and neither is
521
+ // the impersonated user's own mailbox: an administrator must never choose its password/recovery codes.
522
+ canProvision={!impersonating && activeMailbox?.ownerUserUid === userUid}
523
+ >
524
+ <LocalIndexLifecycle
525
+ mailboxUid={activeMailboxUid}
526
+ mailboxKeys={activeMailbox?.keys}
527
+ folders={mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? []}
528
+ // A full page may be truncated - pruning against it would delete indexes of accessible mailboxes
529
+ // beyond the limit, so it's only trusted as the complete list when it came back short.
530
+ accessibleMailboxUids={status === "ready" && mailboxes.length < MAILBOX_LIST_LIMIT ? mailboxes.map((mb) => mb.uid) : undefined}
531
+ />
532
+ <AppShell
533
+ active="mail"
534
+ userUid={userUid}
535
+ authServerUrl={authServerUrl}
536
+ impersonating={impersonating}
537
+ impersonationBaseUrl={impersonationBaseUrl}
538
+ trusted={trusted}
539
+ trustedRoles={trustedRoles}
540
+ pluginNav={pluginNav}
541
+ >
542
+ {inner}
543
+ </AppShell>
544
+ </KeyEnrollmentGate>
545
+ );
546
+ }