@rapidmx/web-client 0.30.0 → 0.32.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 (402) hide show
  1. package/apps/admin/audit-log/index.tsx +3 -3
  2. package/apps/admin/branding/index.tsx +3 -3
  3. package/apps/admin/data-requests/index.tsx +18 -7
  4. package/apps/admin/distribution-lists/[uid].tsx +3 -3
  5. package/apps/admin/distribution-lists/index.tsx +3 -3
  6. package/apps/admin/distribution-lists/new/index.tsx +3 -3
  7. package/apps/admin/domains/[uid].tsx +6 -5
  8. package/apps/admin/domains/index.tsx +3 -3
  9. package/apps/admin/domains/new/index.tsx +3 -3
  10. package/apps/admin/escrow-scopes/[uid].tsx +11 -5
  11. package/apps/admin/escrow-scopes/index.tsx +3 -3
  12. package/apps/admin/escrow-scopes/new/index.tsx +9 -3
  13. package/apps/admin/index.tsx +4 -4
  14. package/apps/admin/ingest-queue/index.tsx +3 -3
  15. package/apps/admin/mailboxes/[uid].tsx +15 -7
  16. package/apps/admin/quarantine/index.tsx +4 -4
  17. package/apps/admin/retention-policy/index.tsx +3 -3
  18. package/apps/admin/signing-certificates/index.tsx +3 -3
  19. package/apps/admin/transport-rules/[uid].tsx +4 -4
  20. package/apps/admin/transport-rules/index.tsx +3 -3
  21. package/apps/admin/transport-rules/new/index.tsx +3 -3
  22. package/apps/escrow/matters/[uid].tsx +26 -2
  23. package/apps/escrow/matters/new/index.tsx +4 -0
  24. package/apps/shared/calendar/calendarLiveUpdates.ts +11 -10
  25. package/apps/shared/calendar/useCalendarReminders.ts +2 -0
  26. package/apps/shared/components/admin/ActionAlert.tsx +32 -0
  27. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +3 -3
  28. package/apps/shared/components/admin/diagnostics/InstalledPlugins.tsx +1 -1
  29. package/apps/shared/components/admin/diagnostics/LogsPanel.tsx +1 -1
  30. package/apps/shared/components/admin/diagnostics/RuntimePanel.tsx +1 -1
  31. package/apps/shared/components/admin/diagnostics/SystemPanel.tsx +1 -1
  32. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +2 -2
  33. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +4 -4
  34. package/apps/shared/components/admin/elevation.ts +19 -0
  35. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +58 -3
  36. package/apps/shared/components/admin/layout/AdminShell.tsx +6 -1
  37. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +3 -3
  38. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +4 -4
  39. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +4 -4
  40. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  41. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +6 -6
  42. package/apps/shared/components/admin/settings/BrandingForm.tsx +22 -9
  43. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -4
  44. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +33 -4
  45. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +3 -3
  46. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +8 -3
  47. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +3 -3
  48. package/apps/shared/components/admin/settings/PluginsManager.tsx +195 -46
  49. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +25 -12
  50. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +40 -7
  51. package/apps/shared/components/admin/setup/SetupWizard.tsx +8 -6
  52. package/apps/shared/components/admin/signOut.ts +3 -0
  53. package/apps/shared/components/admin/usePagedList.tsx +2 -2
  54. package/apps/shared/components/calendar/DescriptionEditor.tsx +2 -1
  55. package/apps/shared/components/calendar/EventDetails.tsx +34 -8
  56. package/apps/shared/components/calendar/EventEditor.tsx +90 -40
  57. package/apps/shared/components/calendar/EventFormParts.tsx +5 -2
  58. package/apps/shared/components/calendar/EventMatchNav.tsx +14 -2
  59. package/apps/shared/components/calendar/EventModal.tsx +25 -1
  60. package/apps/shared/components/calendar/FindATime.tsx +10 -2
  61. package/apps/shared/components/calendar/ListView.tsx +99 -25
  62. package/apps/shared/components/calendar/RecurrenceEditor.tsx +19 -8
  63. package/apps/shared/components/calendar/SplitDayView.tsx +14 -18
  64. package/apps/shared/components/calendar/TaskQuickForm.tsx +3 -2
  65. package/apps/shared/components/calendar/TimeGridView.tsx +35 -19
  66. package/apps/shared/components/calendar/gridLayout.ts +94 -0
  67. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  68. package/apps/shared/components/calendar/timePicker.ts +5 -2
  69. package/apps/shared/components/contacts/ContactCard.tsx +15 -4
  70. package/apps/shared/components/contacts/ContactDetailPane.tsx +54 -15
  71. package/apps/shared/components/contacts/ContactForm.tsx +61 -17
  72. package/apps/shared/components/contacts/ContactPhotoEditor.tsx +88 -0
  73. package/apps/shared/components/contacts/ContactPhotoField.tsx +36 -39
  74. package/apps/shared/components/contacts/layout/ContactsShell.tsx +1 -1
  75. package/apps/shared/components/contacts/participantDetails.ts +18 -1
  76. package/apps/shared/components/layout/AppShell.tsx +9 -0
  77. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +9 -4
  78. package/apps/shared/components/layout/MailboxProvisioning.tsx +31 -3
  79. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +4 -2
  80. package/apps/shared/components/mail/ConversationList.tsx +49 -6
  81. package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -1
  82. package/apps/shared/components/mail/MenuButton.tsx +3 -400
  83. package/apps/shared/components/mail/MessageDetailPane.tsx +16 -13
  84. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +11 -1
  85. package/apps/shared/components/mail/compose/ComposeWindow.tsx +57 -11
  86. package/apps/shared/components/mail/compose/GifPicker.tsx +10 -4
  87. package/apps/shared/components/mail/compose/RecipientInput.tsx +9 -7
  88. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +14 -1
  89. package/apps/shared/components/mail/compose/encryptionRequirement.ts +5 -0
  90. package/apps/shared/components/mail/compose/quotedBody.ts +31 -6
  91. package/apps/shared/components/mail/layout/MailShell.tsx +1 -1
  92. package/apps/shared/components/mail/reading/MessageBody.tsx +13 -7
  93. package/apps/shared/components/mail/reading/bodyContent.ts +2 -0
  94. package/apps/shared/components/mail/reading/bodyHtml.ts +25 -2
  95. package/apps/shared/components/mail/writableMailboxes.ts +2 -0
  96. package/apps/shared/components/rules/RuleBuilder.tsx +11 -2
  97. package/apps/shared/components/settings/GravatarSetting.tsx +27 -0
  98. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -1
  99. package/apps/shared/components/tasks/dueDate.ts +29 -0
  100. package/apps/shared/components/tasks/layout/TasksShell.tsx +1 -1
  101. package/apps/shared/mail/decryptedMessages.ts +51 -2
  102. package/apps/shared/mail/listSnapshots.ts +2 -0
  103. package/apps/shared/mail/outbox/sendDecision.ts +14 -3
  104. package/apps/shared/mail/outbox/sendJob.ts +5 -0
  105. package/apps/shared/mail/useMailLiveUpdates.ts +7 -1
  106. package/apps/shared/mail/usePermanentDelete.tsx +3 -1
  107. package/apps/shared/notifications/apiErrors.ts +2 -0
  108. package/apps/shared/notifications/preferences.ts +1 -1
  109. package/apps/shared/notifications/store.ts +3 -1
  110. package/apps/shared/plugins/pluginNav.ts +4 -2
  111. package/apps/www/calendar/index.tsx +24 -12
  112. package/apps/www/contacts/[uid].tsx +6 -1
  113. package/apps/www/contacts/index.tsx +66 -12
  114. package/apps/www/index.tsx +91 -28
  115. package/apps/www/messages/[uid].tsx +8 -3
  116. package/apps/www/settings/auto-reply/index.tsx +13 -0
  117. package/apps/www/settings/blocked-senders/index.tsx +4 -0
  118. package/apps/www/settings/encryption/index.tsx +66 -4
  119. package/apps/www/settings/privacy/index.tsx +4 -1
  120. package/apps/www/settings/profile/index.tsx +12 -1
  121. package/apps/www/settings/sharing/index.tsx +11 -1
  122. package/apps/www/settings/signatures/[uid].tsx +6 -4
  123. package/apps/www/tasks/index.tsx +97 -29
  124. package/dist/apps/admin/audit-log/index.js +3 -3
  125. package/dist/apps/admin/branding/index.js +3 -3
  126. package/dist/apps/admin/data-requests/index.js +17 -7
  127. package/dist/apps/admin/distribution-lists/[uid].js +3 -3
  128. package/dist/apps/admin/distribution-lists/index.js +3 -3
  129. package/dist/apps/admin/distribution-lists/new/index.js +3 -3
  130. package/dist/apps/admin/domains/[uid].js +6 -5
  131. package/dist/apps/admin/domains/index.js +3 -3
  132. package/dist/apps/admin/domains/new/index.js +3 -3
  133. package/dist/apps/admin/escrow-scopes/[uid].js +11 -6
  134. package/dist/apps/admin/escrow-scopes/index.js +3 -3
  135. package/dist/apps/admin/escrow-scopes/new/index.js +9 -4
  136. package/dist/apps/admin/index.js +4 -4
  137. package/dist/apps/admin/ingest-queue/index.js +3 -3
  138. package/dist/apps/admin/mailboxes/[uid].js +15 -7
  139. package/dist/apps/admin/quarantine/index.js +4 -4
  140. package/dist/apps/admin/retention-policy/index.js +3 -3
  141. package/dist/apps/admin/signing-certificates/index.js +3 -3
  142. package/dist/apps/admin/transport-rules/[uid].js +4 -4
  143. package/dist/apps/admin/transport-rules/index.js +3 -3
  144. package/dist/apps/admin/transport-rules/new/index.js +3 -3
  145. package/dist/apps/escrow/matters/[uid].js +8 -3
  146. package/dist/apps/escrow/matters/new/index.js +4 -0
  147. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -7
  148. package/dist/apps/shared/calendar/calendarLiveUpdates.js +10 -9
  149. package/dist/apps/shared/calendar/useCalendarReminders.js +2 -0
  150. package/dist/apps/shared/components/admin/ActionAlert.d.ts +13 -0
  151. package/dist/apps/shared/components/admin/ActionAlert.js +24 -0
  152. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +2 -2
  153. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.js +1 -1
  154. package/dist/apps/shared/components/admin/diagnostics/LogsPanel.js +1 -1
  155. package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.js +1 -1
  156. package/dist/apps/shared/components/admin/diagnostics/SystemPanel.js +1 -1
  157. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +2 -2
  158. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +4 -4
  159. package/dist/apps/shared/components/admin/elevation.d.ts +9 -0
  160. package/dist/apps/shared/components/admin/elevation.js +16 -0
  161. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +2 -0
  162. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +41 -1
  163. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -1
  164. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +3 -3
  165. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +4 -4
  166. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +4 -4
  167. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  168. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +6 -6
  169. package/dist/apps/shared/components/admin/settings/BrandingForm.js +17 -7
  170. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +4 -4
  171. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +17 -5
  172. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +3 -3
  173. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +8 -3
  174. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +3 -3
  175. package/dist/apps/shared/components/admin/settings/PluginsManager.js +103 -18
  176. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +17 -10
  177. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +18 -8
  178. package/dist/apps/shared/components/admin/setup/SetupWizard.js +8 -6
  179. package/dist/apps/shared/components/admin/signOut.js +3 -0
  180. package/dist/apps/shared/components/admin/usePagedList.js +2 -2
  181. package/dist/apps/shared/components/calendar/DescriptionEditor.js +2 -1
  182. package/dist/apps/shared/components/calendar/EventDetails.d.ts +3 -1
  183. package/dist/apps/shared/components/calendar/EventDetails.js +7 -4
  184. package/dist/apps/shared/components/calendar/EventEditor.js +81 -33
  185. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -1
  186. package/dist/apps/shared/components/calendar/EventFormParts.js +3 -3
  187. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +1 -1
  188. package/dist/apps/shared/components/calendar/EventMatchNav.js +12 -2
  189. package/dist/apps/shared/components/calendar/EventModal.js +10 -2
  190. package/dist/apps/shared/components/calendar/FindATime.js +4 -3
  191. package/dist/apps/shared/components/calendar/ListView.d.ts +10 -4
  192. package/dist/apps/shared/components/calendar/ListView.js +92 -27
  193. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  194. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +15 -10
  195. package/dist/apps/shared/components/calendar/SplitDayView.js +11 -17
  196. package/dist/apps/shared/components/calendar/TaskQuickForm.js +3 -2
  197. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -21
  198. package/dist/apps/shared/components/calendar/gridLayout.d.ts +41 -0
  199. package/dist/apps/shared/components/calendar/gridLayout.js +83 -0
  200. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  201. package/dist/apps/shared/components/calendar/timePicker.js +5 -2
  202. package/dist/apps/shared/components/contacts/ContactCard.js +9 -3
  203. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +5 -3
  204. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -5
  205. package/dist/apps/shared/components/contacts/ContactForm.js +54 -16
  206. package/dist/apps/shared/components/contacts/ContactPhotoEditor.d.ts +21 -0
  207. package/dist/apps/shared/components/contacts/ContactPhotoEditor.js +56 -0
  208. package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +7 -4
  209. package/dist/apps/shared/components/contacts/ContactPhotoField.js +19 -21
  210. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +1 -1
  211. package/dist/apps/shared/components/contacts/participantDetails.d.ts +4 -1
  212. package/dist/apps/shared/components/contacts/participantDetails.js +17 -1
  213. package/dist/apps/shared/components/layout/AppShell.js +9 -0
  214. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +2 -3
  215. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +4 -1
  216. package/dist/apps/shared/components/layout/MailboxProvisioning.js +24 -2
  217. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +4 -2
  218. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +4 -2
  219. package/dist/apps/shared/components/mail/ConversationList.js +49 -6
  220. package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
  221. package/dist/apps/shared/components/mail/MenuButton.d.ts +2 -78
  222. package/dist/apps/shared/components/mail/MenuButton.js +2 -211
  223. package/dist/apps/shared/components/mail/MessageDetailPane.js +10 -6
  224. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +3 -0
  225. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +10 -1
  226. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +55 -10
  227. package/dist/apps/shared/components/mail/compose/GifPicker.js +10 -4
  228. package/dist/apps/shared/components/mail/compose/RecipientInput.js +9 -7
  229. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +7 -1
  230. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +13 -1
  231. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +5 -0
  232. package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -5
  233. package/dist/apps/shared/components/mail/layout/MailShell.js +1 -1
  234. package/dist/apps/shared/components/mail/reading/MessageBody.js +11 -6
  235. package/dist/apps/shared/components/mail/reading/bodyContent.js +2 -0
  236. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +5 -1
  237. package/dist/apps/shared/components/mail/reading/bodyHtml.js +23 -2
  238. package/dist/apps/shared/components/mail/writableMailboxes.js +2 -0
  239. package/dist/apps/shared/components/rules/RuleBuilder.js +9 -1
  240. package/dist/apps/shared/components/settings/GravatarSetting.d.ts +6 -0
  241. package/dist/apps/shared/components/settings/GravatarSetting.js +15 -0
  242. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -1
  243. package/dist/apps/shared/components/tasks/dueDate.d.ts +12 -0
  244. package/dist/apps/shared/components/tasks/dueDate.js +26 -0
  245. package/dist/apps/shared/components/tasks/layout/TasksShell.js +1 -1
  246. package/dist/apps/shared/mail/decryptedMessages.d.ts +8 -0
  247. package/dist/apps/shared/mail/decryptedMessages.js +46 -2
  248. package/dist/apps/shared/mail/listSnapshots.js +2 -0
  249. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +6 -3
  250. package/dist/apps/shared/mail/outbox/sendDecision.js +13 -3
  251. package/dist/apps/shared/mail/outbox/sendJob.d.ts +1 -0
  252. package/dist/apps/shared/mail/outbox/sendJob.js +5 -1
  253. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +3 -0
  254. package/dist/apps/shared/mail/useMailLiveUpdates.js +6 -1
  255. package/dist/apps/shared/mail/usePermanentDelete.d.ts +2 -0
  256. package/dist/apps/shared/mail/usePermanentDelete.js +1 -1
  257. package/dist/apps/shared/notifications/apiErrors.js +2 -0
  258. package/dist/apps/shared/notifications/preferences.d.ts +1 -1
  259. package/dist/apps/shared/notifications/preferences.js +1 -1
  260. package/dist/apps/shared/notifications/store.js +3 -1
  261. package/dist/apps/shared/plugins/pluginNav.d.ts +2 -1
  262. package/dist/apps/shared/plugins/pluginNav.js +4 -2
  263. package/dist/apps/www/calendar/index.js +24 -12
  264. package/dist/apps/www/contacts/[uid].js +6 -2
  265. package/dist/apps/www/contacts/index.js +62 -11
  266. package/dist/apps/www/index.js +85 -30
  267. package/dist/apps/www/messages/[uid].js +8 -3
  268. package/dist/apps/www/settings/auto-reply/index.js +13 -1
  269. package/dist/apps/www/settings/blocked-senders/index.js +4 -0
  270. package/dist/apps/www/settings/encryption/index.js +29 -5
  271. package/dist/apps/www/settings/privacy/index.js +3 -2
  272. package/dist/apps/www/settings/profile/index.js +4 -1
  273. package/dist/apps/www/settings/sharing/index.js +10 -1
  274. package/dist/apps/www/settings/signatures/[uid].js +6 -4
  275. package/dist/apps/www/tasks/index.js +94 -32
  276. package/dist/lib/admin/auditLogApi.js +2 -2
  277. package/dist/lib/admin/distributionListsApi.js +2 -2
  278. package/dist/lib/admin/domainsApi.js +2 -2
  279. package/dist/lib/admin/escrowAuditLogApi.js +2 -2
  280. package/dist/lib/admin/escrowScopesApi.js +2 -2
  281. package/dist/lib/admin/pluginsApi.d.ts +31 -2
  282. package/dist/lib/admin/pluginsApi.js +23 -1
  283. package/dist/lib/admin/retentionPolicyApi.d.ts +3 -0
  284. package/dist/lib/admin/retentionPolicyApi.js +3 -0
  285. package/dist/lib/admin/transportRulesApi.js +2 -2
  286. package/dist/lib/appearance/preferencesApi.d.ts +1 -1
  287. package/dist/lib/appearance/preferencesApi.js +3 -14
  288. package/dist/lib/auth/session.d.ts +51 -12
  289. package/dist/lib/auth/session.js +214 -38
  290. package/dist/lib/branding/brandingApi.d.ts +3 -3
  291. package/dist/lib/branding/brandingApi.js +9 -21
  292. package/dist/lib/calendar/calendarApi.js +3 -1
  293. package/dist/lib/calendar/eventSearch.js +43 -6
  294. package/dist/lib/calendar/freeBusyApi.d.ts +2 -1
  295. package/dist/lib/calendar/freeBusyApi.js +13 -5
  296. package/dist/lib/calendar/recurrence.js +51 -3
  297. package/dist/lib/calendar/useDayKey.d.ts +7 -0
  298. package/dist/lib/calendar/useDayKey.js +43 -0
  299. package/dist/lib/components/avatar/ContactAvatar.d.ts +6 -2
  300. package/dist/lib/components/avatar/ContactAvatar.js +37 -8
  301. package/dist/lib/components/avatar/ContactPhotoAvatar.d.ts +11 -0
  302. package/dist/lib/components/avatar/ContactPhotoAvatar.js +18 -0
  303. package/dist/lib/components/forms/CameraCaptureModal.d.ts +17 -0
  304. package/dist/lib/components/forms/CameraCaptureModal.js +88 -0
  305. package/dist/lib/components/forms/ImageEditBadge.d.ts +28 -0
  306. package/dist/lib/components/forms/ImageEditBadge.js +62 -0
  307. package/dist/lib/components/menus/MenuButton.d.ts +78 -0
  308. package/dist/lib/components/menus/MenuButton.js +219 -0
  309. package/dist/lib/components/navigation/BottomTabBar.d.ts +3 -1
  310. package/dist/lib/components/navigation/BottomTabBar.js +22 -3
  311. package/dist/lib/components/overlays/PopoverPortal.d.ts +2 -1
  312. package/dist/lib/components/overlays/PopoverPortal.js +3 -2
  313. package/dist/lib/components/pickers/TimeZonePicker.js +13 -1
  314. package/dist/lib/contacts/contactsApi.d.ts +5 -2
  315. package/dist/lib/contacts/contactsApi.js +16 -19
  316. package/dist/lib/contacts/gravatar.d.ts +14 -3
  317. package/dist/lib/contacts/gravatar.js +42 -5
  318. package/dist/lib/contacts/preparePhoto.d.ts +24 -0
  319. package/dist/lib/contacts/preparePhoto.js +250 -0
  320. package/dist/lib/contacts/useContactPhotoSrc.d.ts +19 -0
  321. package/dist/lib/contacts/useContactPhotoSrc.js +153 -0
  322. package/dist/lib/contacts/vcard.d.ts +3 -0
  323. package/dist/lib/contacts/vcard.js +59 -7
  324. package/dist/lib/crypto/escrowKeys.d.ts +8 -0
  325. package/dist/lib/crypto/escrowKeys.js +20 -1
  326. package/dist/lib/crypto/messageSecurity.d.ts +7 -4
  327. package/dist/lib/crypto/messageSecurity.js +23 -5
  328. package/dist/lib/crypto/passwordUnlock.d.ts +6 -1
  329. package/dist/lib/crypto/passwordUnlock.js +13 -2
  330. package/dist/lib/crypto/smime.js +12 -2
  331. package/dist/lib/mail/compose/composeQuoting.js +3 -2
  332. package/dist/lib/mail/compose/quotedImages.d.ts +6 -3
  333. package/dist/lib/mail/compose/quotedImages.js +35 -9
  334. package/dist/lib/mail/labelsApi.js +2 -2
  335. package/dist/lib/mail/mailApi.d.ts +8 -5
  336. package/dist/lib/mail/mailApi.js +73 -39
  337. package/dist/lib/mail/mailDetailHooks.js +11 -3
  338. package/dist/lib/mail/mailFilterRulesApi.js +2 -2
  339. package/dist/lib/mail/mailSignaturesApi.js +2 -2
  340. package/dist/lib/mail/mailboxImportApi.d.ts +1 -1
  341. package/dist/lib/mail/mailboxImportApi.js +3 -15
  342. package/dist/lib/mail/messageBodySanitizer.d.ts +6 -0
  343. package/dist/lib/mail/messageBodySanitizer.js +44 -1
  344. package/dist/lib/search/queryGrammar.js +12 -4
  345. package/dist/lib/search/searchTier3.d.ts +6 -1
  346. package/dist/lib/search/searchTier3.js +54 -25
  347. package/dist/lib/tasks/tasksApi.js +3 -3
  348. package/dist/lib/util/api.d.ts +26 -4
  349. package/dist/lib/util/api.js +73 -16
  350. package/dist/lib/util/apiQuery.d.ts +11 -0
  351. package/dist/lib/util/apiQuery.js +11 -0
  352. package/dist/lib/util/settleWithLimit.d.ts +5 -0
  353. package/dist/lib/util/settleWithLimit.js +25 -0
  354. package/lib/admin/auditLogApi.ts +2 -2
  355. package/lib/admin/distributionListsApi.ts +2 -2
  356. package/lib/admin/domainsApi.ts +2 -2
  357. package/lib/admin/escrowAuditLogApi.ts +2 -2
  358. package/lib/admin/escrowScopesApi.ts +2 -2
  359. package/lib/admin/pluginsApi.ts +45 -3
  360. package/lib/admin/retentionPolicyApi.ts +4 -0
  361. package/lib/admin/transportRulesApi.ts +2 -2
  362. package/lib/appearance/preferencesApi.ts +3 -14
  363. package/lib/auth/session.ts +239 -39
  364. package/lib/branding/brandingApi.ts +9 -21
  365. package/lib/calendar/calendarApi.ts +3 -1
  366. package/lib/calendar/eventSearch.ts +46 -6
  367. package/lib/calendar/freeBusyApi.ts +14 -6
  368. package/lib/calendar/recurrence.ts +59 -8
  369. package/lib/calendar/useDayKey.ts +45 -0
  370. package/lib/components/avatar/ContactAvatar.tsx +46 -8
  371. package/lib/components/avatar/ContactPhotoAvatar.tsx +26 -0
  372. package/lib/components/forms/CameraCaptureModal.tsx +133 -0
  373. package/lib/components/forms/ImageEditBadge.tsx +121 -0
  374. package/lib/components/menus/MenuButton.tsx +408 -0
  375. package/lib/components/navigation/BottomTabBar.tsx +30 -8
  376. package/lib/components/overlays/PopoverPortal.tsx +3 -2
  377. package/lib/components/pickers/TimeZonePicker.tsx +17 -1
  378. package/lib/contacts/contactsApi.ts +18 -19
  379. package/lib/contacts/gravatar.ts +47 -5
  380. package/lib/contacts/preparePhoto.ts +273 -0
  381. package/lib/contacts/useContactPhotoSrc.ts +177 -0
  382. package/lib/contacts/vcard.ts +60 -7
  383. package/lib/crypto/escrowKeys.ts +21 -1
  384. package/lib/crypto/messageSecurity.ts +33 -9
  385. package/lib/crypto/passwordUnlock.ts +14 -2
  386. package/lib/crypto/smime.ts +13 -2
  387. package/lib/mail/compose/composeQuoting.ts +3 -2
  388. package/lib/mail/compose/quotedImages.ts +39 -9
  389. package/lib/mail/labelsApi.ts +2 -2
  390. package/lib/mail/mailApi.ts +96 -39
  391. package/lib/mail/mailDetailHooks.ts +11 -3
  392. package/lib/mail/mailFilterRulesApi.ts +2 -2
  393. package/lib/mail/mailSignaturesApi.ts +2 -2
  394. package/lib/mail/mailboxImportApi.ts +9 -15
  395. package/lib/mail/messageBodySanitizer.ts +47 -1
  396. package/lib/search/queryGrammar.ts +13 -4
  397. package/lib/search/searchTier3.ts +65 -32
  398. package/lib/tasks/tasksApi.ts +3 -3
  399. package/lib/util/api.ts +99 -16
  400. package/lib/util/apiQuery.ts +13 -0
  401. package/lib/util/settleWithLimit.ts +25 -0
  402. package/package.json +1 -1
@@ -28,6 +28,8 @@ import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localInde
28
28
  import { authApiFetch, setApiUnauthorizedObserver } from "../../../../lib/util/api.js";
29
29
  import { destroyUnlockedKeys } from "../../../../lib/crypto/keySession.js";
30
30
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
31
+ import { resetNotifications } from "../../notifications/store.js";
32
+ import { clearContactPhotoCache } from "../../../../lib/contacts/useContactPhotoSrc.js";
31
33
  import { pluginIcon } from "../../plugins/pluginIcons.js";
32
34
  import { mergePluginNavItems } from "../../plugins/pluginNav.js";
33
35
  import { useResolvedRailItems } from "../../plugins/useResolvedRailItems.js";
@@ -181,6 +183,9 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
181
183
  destroyUnlockedKeys();
182
184
  clearPinnedSignerCache();
183
185
  clearAppearanceCache();
186
+ clearContactPhotoCache();
187
+ // The notification history (meeting titles, error details) is this session's, and the tab's storage outlives it.
188
+ resetNotifications();
184
189
  // Bounded by its own timeout and never rejects - awaited so navigating doesn't kill the Worker mid-delete.
185
190
  void destroyAllLocalIndexes().then(() => {
186
191
  window.location.href = authServerUrl ?? "/";
@@ -198,6 +203,8 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
198
203
  // Trusted signer pins read from contacts don't outlive the session either.
199
204
  clearPinnedSignerCache();
200
205
  clearAppearanceCache();
206
+ clearContactPhotoCache();
207
+ resetNotifications();
201
208
  // Open compose windows save edits still waiting on their autosave debounce while the session is still
202
209
  // valid - logout invalidates it. Bounded the same way as logout itself, and never rejects.
203
210
  await flushComposeDrafts(LOGOUT_TIMEOUT_MS);
@@ -221,6 +228,8 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
221
228
  // handling, which surfaces via a full reload rather than an inline retry affordance.
222
229
  }
223
230
  finally {
231
+ // What the impersonated user's session raised (their meetings, their errors) must not be left for the administrator to read.
232
+ resetNotifications();
224
233
  window.location.href = "/admin";
225
234
  }
226
235
  }
@@ -11,12 +11,11 @@ import FormField from "../../../../lib/components/forms/FormField.js";
11
11
  import { enrollKey, getKeyVault, VaultAlreadyInitializedError } from "../../../../lib/crypto/keyvaultApi.js";
12
12
  import { UnopenableKeysNotice, unlockErrorMessage } from "./UnlockPromptProvider.js";
13
13
  import { FrameTakeover } from "../../navigation/frameContext.js";
14
- import { RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock } from "./RecoveryCodeUnlock.js";
14
+ import { MIN_PASSWORD_LENGTH, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
15
15
  import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
16
16
  import { buildAad, generateMasterKey, sealWithKey } from "../../../../lib/crypto/masterKey.js";
17
17
  import { buildPasswordWrap, buildRecoveryWraps } from "../../../../lib/crypto/masterKeyWraps.js";
18
18
  import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "../../../../lib/crypto/keys.js";
19
- const MIN_PASSWORD_LENGTH = 8;
20
19
  async function provisionEncryptionKey(mailboxUid, mailboxAddress, password) {
21
20
  const mk = generateMasterKey();
22
21
  const passwordWrap = await buildPasswordWrap(mailboxUid, mk, password);
@@ -180,7 +179,7 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
180
179
  }
181
180
  if (status === "setup_password" || status === "enrolling") {
182
181
  const enrolling = status === "enrolling";
183
- return (_jsx(FrameTakeover, { children: _jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Protect your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Choose a password to protect your encryption keys. This is separate from your sign-in password and is never sent to the server." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSetPassword, children: [_jsx(FormField, { label: "Encryption password", htmlFor: "key-enrollment-password", children: _jsx("input", { id: "key-enrollment-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(FormField, { label: "Confirm password", htmlFor: "key-enrollment-password-confirm", children: _jsx("input", { id: "key-enrollment-password-confirm", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(Button, { type: "submit", loading: enrolling, disabled: enrolling, children: "Continue" })] })] }) }) }));
182
+ return (_jsx(FrameTakeover, { children: _jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Protect your mailbox" }), _jsxs("p", { className: "text-sm text-text-muted mb-5", children: ["Choose a password to protect your encryption keys. This is separate from your sign-in password and is never sent to the server. Make it ", MIN_PASSWORD_LENGTH, " or more characters: a passphrase of several unrelated words is long enough and easy to remember."] }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSetPassword, children: [_jsx(FormField, { label: "Encryption password", htmlFor: "key-enrollment-password", children: _jsx("input", { id: "key-enrollment-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(FormField, { label: "Confirm password", htmlFor: "key-enrollment-password-confirm", children: _jsx("input", { id: "key-enrollment-password-confirm", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(Button, { type: "submit", loading: enrolling, disabled: enrolling, children: "Continue" })] })] }) }) }));
184
183
  }
185
184
  if (status === "already_set_up") {
186
185
  return (_jsx(FrameTakeover, { children: _jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Encryption is already set up" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "This mailbox\u2019s encryption was set up in another tab or on another device while this page was open, so nothing was changed here. Reload the page and unlock with the password chosen there." }), _jsx(Button, { type: "button", onClick: () => window.location.reload(), children: "Reload" })] }) }) }));
@@ -13,4 +13,7 @@ import React from "react";
13
13
  * refused deliberately (a 4xx, e.g. "Automatic mailbox provisioning is not enabled.") so the caller and the
14
14
  * administrator they ask can tell why - see `unavailableReason()`.
15
15
  */
16
- export default function MailboxProvisioning(): React.JSX.Element;
16
+ export default function MailboxProvisioning({ impersonating, impersonationBaseUrl }: {
17
+ impersonating?: boolean;
18
+ impersonationBaseUrl?: string;
19
+ }): React.JSX.Element;
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useState } from "react";
7
7
  import { ApiRequestError } from "../../../../lib/util/api.js";
8
- import { autoProvisionMailbox } from "../../../../lib/mail/mailApi.js";
8
+ import { autoProvisionMailbox, stopImpersonating } from "../../../../lib/mail/mailApi.js";
9
9
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
10
  import Alert from "../../../../lib/components/feedback/Alert.js";
11
11
  import Button from "../../../../lib/components/buttons/Button.js";
@@ -43,7 +43,7 @@ function unavailableReason(err) {
43
43
  * refused deliberately (a 4xx, e.g. "Automatic mailbox provisioning is not enabled.") so the caller and the
44
44
  * administrator they ask can tell why - see `unavailableReason()`.
45
45
  */
46
- export default function MailboxProvisioning() {
46
+ export default function MailboxProvisioning({ impersonating, impersonationBaseUrl }) {
47
47
  const client = useApiClient();
48
48
  const [status, setStatus] = useState("checking");
49
49
  const [options, setOptions] = useState([]);
@@ -55,6 +55,12 @@ export default function MailboxProvisioning() {
55
55
  // Bumped by "Retry" to re-run the check below.
56
56
  const [attempt, setAttempt] = useState(0);
57
57
  useEffect(() => {
58
+ if (impersonating) {
59
+ // An administrator viewing as a user must never create a mailbox for them just by looking: nothing is provisioned, and the way back is shown
60
+ // (this takeover hides the frame's own "Return to admin" bar).
61
+ setStatus("impersonating");
62
+ return;
63
+ }
58
64
  setStatus("checking");
59
65
  autoProvisionMailbox(undefined, client)
60
66
  .then((result) => {
@@ -87,6 +93,19 @@ export default function MailboxProvisioning() {
87
93
  }
88
94
  });
89
95
  }, [attempt]);
96
+ const [returning, setReturning] = useState(false);
97
+ async function handleReturnToAdmin() {
98
+ setReturning(true);
99
+ try {
100
+ await stopImpersonating(impersonationBaseUrl ?? "");
101
+ }
102
+ catch {
103
+ // Navigate either way, as the frame's own banner does: a failed call leaves the session as it was.
104
+ }
105
+ finally {
106
+ window.location.href = "/admin";
107
+ }
108
+ }
90
109
  async function handleConfirm() {
91
110
  // `selected` only ever takes a value from `options` itself (the initial default, or the
92
111
  // <select>'s own onChange, which can only report one of its own rendered <option> values), so
@@ -111,6 +130,9 @@ export default function MailboxProvisioning() {
111
130
  const creating = status === "creating";
112
131
  content = (_jsxs("div", { className: "w-full flex flex-col gap-3", children: [_jsx("p", { className: "text-sm text-text-muted text-center", children: "Choose your mailbox address to get started:" }), error && _jsx(Alert, { children: error }), _jsx("select", { "aria-label": "Mailbox address", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: selected, onChange: (e) => setSelected(e.target.value), disabled: creating, children: options.map((option) => (_jsx("option", { value: option.primarySmtpAddress, children: option.primarySmtpAddress }, option.primarySmtpAddress))) }), _jsx(Button, { type: "button", onClick: handleConfirm, loading: creating, disabled: creating, className: "!w-auto self-center", children: "Continue" })] }));
113
132
  }
133
+ else if (status === "impersonating") {
134
+ content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "No mailbox" }), _jsx("p", { className: "text-sm text-text-muted", children: "The user you are viewing as has no mailbox, and none is created for them while you are viewing as them." }), _jsx(Button, { type: "button", onClick: () => void handleReturnToAdmin(), loading: returning, disabled: returning, className: "!w-auto self-center", children: "Return to admin" })] }));
135
+ }
114
136
  else if (status === "retryable") {
115
137
  content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "Couldn\u2019t check right now" }), _jsx("p", { className: "text-sm text-text-muted", children: retryText }), _jsx(Button, { type: "button", onClick: () => setAttempt((n) => n + 1), className: "!w-auto self-center", children: "Retry" })] }));
116
138
  }
@@ -9,8 +9,10 @@
9
9
  */
10
10
  import React from "react";
11
11
  import { type PublicKey } from "../../../../lib/crypto/keyvaultApi.js";
12
- /** The same minimum every other "set an encryption password" form in this app uses. */
13
- export declare const MIN_PASSWORD_LENGTH = 8;
12
+ /** The same minimum every other "set an encryption password" form in this app uses. The password is the only thing
13
+ * between an offline guesser holding the server's wrapped master key and the mailbox's private keys, so it is
14
+ * long: a passphrase of a few words clears it easily. */
15
+ export declare const MIN_PASSWORD_LENGTH = 12;
14
16
  /** Where recovery codes are regenerated. */
15
17
  export declare const ENCRYPTION_SETTINGS_HREF = "/settings/encryption";
16
18
  /** Deliberately generic: a wrong code and a vault with no recovery codes fail the same way. */
@@ -22,8 +22,10 @@ import Modal from "../../../../lib/components/overlays/Modal.js";
22
22
  import Alert from "../../../../lib/components/feedback/Alert.js";
23
23
  import Button from "../../../../lib/components/buttons/Button.js";
24
24
  import FormField from "../../../../lib/components/forms/FormField.js";
25
- /** The same minimum every other "set an encryption password" form in this app uses. */
26
- export const MIN_PASSWORD_LENGTH = 8;
25
+ /** The same minimum every other "set an encryption password" form in this app uses. The password is the only thing
26
+ * between an offline guesser holding the server's wrapped master key and the mailbox's private keys, so it is
27
+ * long: a passphrase of a few words clears it easily. */
28
+ export const MIN_PASSWORD_LENGTH = 12;
27
29
  /** Where recovery codes are regenerated. */
28
30
  export const ENCRYPTION_SETTINGS_HREF = "/settings/encryption";
29
31
  /** Deliberately generic: a wrong code and a vault with no recovery codes fail the same way. */
@@ -3,13 +3,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useEffect, useState } from "react";
6
+ import { useEffect, useRef, useState } from "react";
7
7
  import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
8
8
  import { ApiRequestError } from "../../../../lib/util/api.js";
9
9
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
10
10
  import MailAddress from "./MailAddress.js";
11
11
  import { EncryptedPreview, conversationLooksEncrypted, displaySubject } from "./reading/EncryptedPreview.js";
12
- import { decryptForDisplay, useDecryptedMessages, useUnlockEpoch } from "../../mail/decryptedMessages.js";
12
+ import { decryptForDisplay, mapWithConcurrency, useDecryptedMessages, useUnlockEpoch } from "../../mail/decryptedMessages.js";
13
13
  import { listConversationMessages } from "../../../../lib/mail/conversationsApi.js";
14
14
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
15
15
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
@@ -44,22 +44,64 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
44
44
  const unlockEpoch = useUnlockEpoch();
45
45
  // An encrypted conversation's row stands for its latest message, whose subject only decrypting can tell. Rows are decrypted as they are listed
46
46
  // whenever the mailbox is unlocked (and again once it is), the same way the message list does.
47
+ // A few at a time (the list can hold hundreds), and none more once the list has been replaced by a newer one.
47
48
  useEffect(() => {
48
- for (const conversation of conversations) {
49
- if (conversationLooksEncrypted(conversation)) {
50
- void decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
49
+ let stale = false;
50
+ void mapWithConcurrency(conversations.filter(conversationLooksEncrypted), 4, async (conversation) => {
51
+ if (!stale) {
52
+ await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
51
53
  }
52
- }
54
+ });
55
+ return () => {
56
+ stale = true;
57
+ };
53
58
  }, [conversations, mailboxUid, unlockEpoch]);
54
59
  const [expanded, setExpanded] = useState(new Set());
55
60
  const [messagesById, setMessagesById] = useState({});
56
61
  const [loadingIds, setLoadingIds] = useState(new Set());
57
62
  const [errorsById, setErrorsById] = useState({});
63
+ // The messages each cached thread was fetched for (its `messageUids`): a list that now says otherwise has the thread fetched again.
64
+ const fetchedForRef = useRef({});
58
65
  // The answers given from a row's RSVP chip, by message uid: a row draws them at once, whichever row (the conversation's, or one of its messages) they were given from.
59
66
  const [answers, setAnswers] = useState({});
60
67
  function answered(updated) {
61
68
  setAnswers((prev) => ({ ...prev, [updated.uid]: updated.meetingResponse }));
62
69
  }
70
+ // New mail, a delete or a move changes a conversation's messages: what was fetched of it is out of date. An expanded thread is fetched again
71
+ // in place (the rows it shows stay until the new ones arrive); a collapsed one is forgotten, to be fetched when next opened. A conversation
72
+ // that left the list is forgotten too.
73
+ useEffect(() => {
74
+ const listed = new Set();
75
+ const stale = [];
76
+ for (const conversation of conversations) {
77
+ const id = conversationRowKey(conversation);
78
+ listed.add(id);
79
+ const fetchedFor = fetchedForRef.current[id];
80
+ if (fetchedFor === undefined || fetchedFor === conversation.messageUids.join(",")) {
81
+ continue;
82
+ }
83
+ if (expanded.has(id)) {
84
+ fetchedForRef.current[id] = conversation.messageUids.join(",");
85
+ listConversationMessages(conversation.mailboxUid ?? mailboxUid, conversation.conversationId, {}, client)
86
+ .then((loaded) => setMessagesById((prev) => ({ ...prev, [id]: loaded })))
87
+ .catch(() => undefined);
88
+ }
89
+ else {
90
+ stale.push(id);
91
+ }
92
+ }
93
+ for (const id of Object.keys(fetchedForRef.current)) {
94
+ if (!listed.has(id)) {
95
+ stale.push(id);
96
+ }
97
+ }
98
+ if (stale.length > 0) {
99
+ for (const id of stale) {
100
+ delete fetchedForRef.current[id];
101
+ }
102
+ setMessagesById((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => !stale.includes(id))));
103
+ }
104
+ }, [conversations]);
63
105
  function toggle(conversation) {
64
106
  const id = conversationRowKey(conversation);
65
107
  setExpanded((prev) => {
@@ -81,6 +123,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
81
123
  delete next[id];
82
124
  return next;
83
125
  });
126
+ fetchedForRef.current[id] = conversation.messageUids.join(",");
84
127
  listConversationMessages(conversation.mailboxUid ?? mailboxUid, conversation.conversationId, {}, client)
85
128
  .then((loaded) => setMessagesById((prev) => ({ ...prev, [id]: loaded })))
86
129
  .catch((err) => setErrorsById((prev) => ({
@@ -316,7 +316,7 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
316
316
  }
317
317
  },
318
318
  track: trackMessageChange,
319
- });
319
+ }, client);
320
320
  }
321
321
  }
322
322
  }, [expandedUids, messages, client]);
@@ -1,78 +1,2 @@
1
- import React, { ReactNode } from "react";
2
- /** What every row has. `checked` turns it into a radio/checkbox item (the caller says which via `role`) and
3
- * draws the checkmark column; without it the row is a plain command. */
4
- interface MenuItemBase {
5
- key: string;
6
- label: string;
7
- /** A one-line explanation under the label, for an item whose effect isn't obvious from its name. */
8
- description?: string;
9
- role?: "menuitem" | "menuitemradio" | "menuitemcheckbox";
10
- /** `"mixed"` is ARIA's third checkbox state - some of the things this row acts on have it and some
11
- * don't, which is what a label applied to only part of a multi-message selection looks like. */
12
- checked?: boolean | "mixed";
13
- /** A colour swatch before the label - a label's own colour. */
14
- swatchColor?: string;
15
- /** An icon before the label, for a menu whose rows are commands (the reading pane's "More actions"). */
16
- icon?: ReactNode;
17
- /** A tooltip for the row - the full text of a label the row truncates. */
18
- title?: string;
19
- disabled?: boolean;
20
- /** Leaves the menu open after choosing this row, for a multi-select list where several rows are ticked
21
- * before one command commits them all. */
22
- keepOpen?: boolean;
23
- }
24
- /** A row that does something when chosen. */
25
- export interface MenuCommandSpec extends MenuItemBase {
26
- onSelect: () => void;
27
- submenu?: never;
28
- }
29
- /** A row that opens a submenu instead: choosing it replaces the menu's contents with these sections, under
30
- * a Back row. One level deep, which is all any menu here needs - so it has nothing of its own to do. */
31
- export interface MenuSubmenuSpec extends MenuItemBase {
32
- submenu: MenuSectionSpec[];
33
- onSelect?: never;
34
- }
35
- export type MenuItemSpec = MenuCommandSpec | MenuSubmenuSpec;
36
- /** A labelled group of items. Groups after the first are drawn with a separator above them. */
37
- export interface MenuSectionSpec {
38
- key: string;
39
- label?: string;
40
- /** A short note under the group's items - e.g. why some of them are unavailable right now. */
41
- note?: string;
42
- items: MenuItemSpec[];
43
- }
44
- /** The height `PopoverPortal` is asked for - exact for a short menu, capped (the list scrolls) for a long one. */
45
- export declare function menuHeight(sections: MenuSectionSpec[], width?: number): number;
46
- export interface MenuButtonProps {
47
- /** The trigger's visible label. Not drawn (nor is the chevron) with `iconOnly`, where `aria-label` is all the trigger says. */
48
- label: ReactNode;
49
- /** The trigger is just its `icon` - a round icon button like the reading pane's own command row, whose `className` it takes over
50
- * completely. `aria-label` (and `title`) name it. */
51
- iconOnly?: boolean;
52
- /** The trigger's accessible name, which also names the menu itself - includes the current selection
53
- * where there is one ("Filter: Unread"), so the button says what it's set to, not just what it does. */
54
- "aria-label": string;
55
- icon?: ReactNode;
56
- disabled?: boolean;
57
- title?: string;
58
- sections: MenuSectionSpec[];
59
- width?: number;
60
- /** Extra classes for the trigger, on top of the shared toolbar-button styling. */
61
- className?: string;
62
- /** Told whenever the menu opens or closes - how a caller resets a draft it keeps for the menu's own
63
- * multi-select rows (see `useLabelDraft()`). */
64
- onOpenChange?: (open: boolean) => void;
65
- }
66
- /**
67
- * A toolbar button that opens an ARIA menu: `role="menu"` rows with checkmarks for the current choice,
68
- * full keyboard support (Enter/Space or Arrow Down to open, arrows/Home/End to move with wrap, Enter/Space
69
- * to choose, Escape or Tab to close with focus returning to the trigger) and a click outside to dismiss.
70
- *
71
- * Rendered through `PopoverPortal` rather than as an absolutely-positioned child: the mail list toolbar
72
- * lives inside the list's own `overflow-y-auto` scroll container, which clips an absolute popup however
73
- * high its `z-index` - the same reason Compose's own pickers portal out (see `PopoverPortal`'s own doc
74
- * comment). The portal supplies the positioning, the outside-click dismissal and the box; the menu
75
- * semantics, roving focus and keyboard handling are this component's own.
76
- */
77
- export default function MenuButton({ label, iconOnly, icon, disabled, title, sections, width, className, "aria-label": ariaLabel, onOpenChange, }: MenuButtonProps): React.JSX.Element;
78
- export {};
1
+ export { default, menuHeight } from "../../../../lib/components/menus/MenuButton.js";
2
+ export type { MenuCommandSpec, MenuSubmenuSpec, MenuItemSpec, MenuSectionSpec, MenuButtonProps } from "../../../../lib/components/menus/MenuButton.js";
@@ -1,215 +1,6 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
1
  ///////////////////////////////////////////////////////////////////////////////
3
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
3
  // SPDX-License-Identifier: MPL-2.0
5
4
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useEffect, useRef, useState } from "react";
7
- import { HiCheck, HiChevronDown, HiChevronLeft, HiChevronRight, HiMinus } from "react-icons/hi2";
8
- import PopoverPortal from "../../../../lib/components/overlays/PopoverPortal.js";
9
- // `PopoverPortal` positions a fixed-size box (it has no auto-height mode), so the menu's height is
10
- // computed from its own contents rather than measured - from whichever level is *shown*, so a submenu
11
- // isn't left standing in the parent menu's taller box. These are the exact heights the classes below
12
- // render at, so the box is never short enough to clip its last row: an item is `h-9`, a group label
13
- // `h-6`, a note as many `leading-4` lines as it wraps to, a separator a 1px rule inside `my-1`, and the
14
- // list itself `py-1`.
15
- const ITEM_HEIGHT = 36;
16
- const ITEM_DESCRIPTION_HEIGHT = 16;
17
- const GROUP_LABEL_HEIGHT = 24;
18
- const NOTE_LINE_HEIGHT = 16;
19
- const NOTE_PADDING = 4;
20
- /** A note wraps, so its height depends on how much of it fits a line: `text-xs` averages a little over 6px
21
- * a character, and the note sits inside the list's `px-3`. Rounded so the estimate is never *under* the
22
- * lines the browser actually draws - a box a few pixels too tall shows blank space, one too short clips. */
23
- const NOTE_CHAR_WIDTH = 6.4;
24
- const NOTE_PADDING_X = 24;
25
- const SEPARATOR_HEIGHT = 9;
26
- const LIST_PADDING = 8;
27
- const MENU_MAX_HEIGHT = 460;
28
- const DEFAULT_MENU_WIDTH = 248;
29
- /** How tall `note` renders at `width`, wrapped. */
30
- function noteHeight(note, width) {
31
- const charsPerLine = Math.max(1, Math.floor((width - NOTE_PADDING_X) / NOTE_CHAR_WIDTH));
32
- return Math.ceil(note.length / charsPerLine) * NOTE_LINE_HEIGHT + NOTE_PADDING;
33
- }
34
- /** The height `PopoverPortal` is asked for - exact for a short menu, capped (the list scrolls) for a long one. */
35
- export function menuHeight(sections, width = DEFAULT_MENU_WIDTH) {
36
- let height = LIST_PADDING;
37
- sections.forEach((section, index) => {
38
- if (index > 0) {
39
- height += SEPARATOR_HEIGHT;
40
- }
41
- if (section.label) {
42
- height += GROUP_LABEL_HEIGHT;
43
- }
44
- for (const item of section.items) {
45
- height += ITEM_HEIGHT + (item.description ? ITEM_DESCRIPTION_HEIGHT : 0);
46
- }
47
- if (section.note) {
48
- height += noteHeight(section.note, width);
49
- }
50
- });
51
- return Math.min(height, MENU_MAX_HEIGHT);
52
- }
53
- /**
54
- * A toolbar button that opens an ARIA menu: `role="menu"` rows with checkmarks for the current choice,
55
- * full keyboard support (Enter/Space or Arrow Down to open, arrows/Home/End to move with wrap, Enter/Space
56
- * to choose, Escape or Tab to close with focus returning to the trigger) and a click outside to dismiss.
57
- *
58
- * Rendered through `PopoverPortal` rather than as an absolutely-positioned child: the mail list toolbar
59
- * lives inside the list's own `overflow-y-auto` scroll container, which clips an absolute popup however
60
- * high its `z-index` - the same reason Compose's own pickers portal out (see `PopoverPortal`'s own doc
61
- * comment). The portal supplies the positioning, the outside-click dismissal and the box; the menu
62
- * semantics, roving focus and keyboard handling are this component's own.
63
- */
64
- export default function MenuButton({ label, iconOnly = false, icon, disabled, title, sections, width = DEFAULT_MENU_WIDTH, className = "", "aria-label": ariaLabel, onOpenChange, }) {
65
- const [open, setOpen] = useState(false);
66
- const [activeIndex, setActiveIndex] = useState(0);
67
- // The key of the item whose submenu is showing, or `null` at the top level. Held as a key, not as the
68
- // item itself, because `sections` is rebuilt on every render.
69
- const [submenuKey, setSubmenuKey] = useState(null);
70
- const triggerRef = useRef(null);
71
- const itemRefs = useRef([]);
72
- // State, not a ref: `PopoverPortal` renders nothing at all until its own positioning effect has run, so
73
- // the rows don't exist yet when this component's effects first fire. Keying the focus effect on the
74
- // menu node itself makes it run again once they do.
75
- const [menuNode, setMenuNode] = useState(null);
76
- const openSubmenu = sections
77
- .flatMap((section) => section.items)
78
- .find((item) => item.key === submenuKey && !!item.submenu);
79
- /** The row that leaves a submenu again - synthesized rather than asked of the caller, so every submenu
80
- * has the same way back however it was built. */
81
- const backItem = {
82
- key: "__back",
83
- label: `Back to ${ariaLabel}`,
84
- keepOpen: true,
85
- onSelect: () => leaveSubmenu(),
86
- };
87
- const shownSections = openSubmenu
88
- ? [{ key: "__back", items: [backItem] }, ...openSubmenu.submenu]
89
- : sections;
90
- const items = shownSections.flatMap((section) => section.items);
91
- const enabledIndexes = items.map((item, index) => (item.disabled ? -1 : index)).filter((index) => index !== -1);
92
- /** The row focus starts on: the current choice where there is one, else the first row that can take focus. */
93
- function initialActiveIndex(within) {
94
- const checked = within.findIndex((item) => item.checked === true && !item.disabled);
95
- const enabled = within.map((item, index) => (item.disabled ? -1 : index)).filter((index) => index !== -1);
96
- return checked === -1 ? (enabled[0] ?? 0) : checked;
97
- }
98
- function openMenu() {
99
- setSubmenuKey(null);
100
- setActiveIndex(initialActiveIndex(sections.flatMap((section) => section.items)));
101
- setOpen(true);
102
- onOpenChange?.(true);
103
- }
104
- function closeMenu(returnFocus) {
105
- setOpen(false);
106
- setSubmenuKey(null);
107
- onOpenChange?.(false);
108
- if (returnFocus) {
109
- triggerRef.current?.focus();
110
- }
111
- }
112
- function enterSubmenu(item) {
113
- setSubmenuKey(item.key);
114
- const rows = item.submenu.flatMap((section) => section.items);
115
- // Past the Back row, onto the first row of the submenu itself - or, when none of them can take focus (every row is unavailable right now), onto
116
- // the Back row, so the keyboard still has a place to be.
117
- setActiveIndex(rows.some((row) => !row.disabled) ? 1 + initialActiveIndex(rows) : 0);
118
- }
119
- function leaveSubmenu() {
120
- // Back on the row the submenu was opened from.
121
- const parentIndex = sections.flatMap((section) => section.items).findIndex((item) => item.key === submenuKey);
122
- setSubmenuKey(null);
123
- setActiveIndex(parentIndex);
124
- }
125
- /** What a row does when it is chosen: open its submenu, or run it and close unless it asked to stay. */
126
- function selectItem(item) {
127
- if (item.submenu) {
128
- enterSubmenu(item);
129
- return;
130
- }
131
- item.onSelect();
132
- if (!item.keepOpen) {
133
- closeMenu(true);
134
- }
135
- }
136
- // Roving focus: only the active row is tabbable, and it takes DOM focus whenever it changes, so the
137
- // arrow keys move the screen reader's cursor too rather than only a visual highlight.
138
- useEffect(() => {
139
- if (open && menuNode) {
140
- // A disabled row can't take focus, so a menu with nothing enabled focuses its own container -
141
- // otherwise the keys it handles would go to whatever had focus before it opened.
142
- if (enabledIndexes.length === 0) {
143
- menuNode.focus();
144
- }
145
- else {
146
- itemRefs.current[activeIndex]?.focus();
147
- }
148
- }
149
- // `submenuKey` too: drilling in or out can land on the same index in the other level's list, and
150
- // the row that index *means* is a different button, which the effect must move focus to.
151
- }, [open, activeIndex, submenuKey, menuNode]);
152
- function moveActive(delta) {
153
- if (enabledIndexes.length === 0) {
154
- return;
155
- }
156
- const position = enabledIndexes.indexOf(activeIndex);
157
- setActiveIndex(enabledIndexes[(position + delta + enabledIndexes.length) % enabledIndexes.length]);
158
- }
159
- function handleMenuKeyDown(e) {
160
- if (e.key === "ArrowDown") {
161
- e.preventDefault();
162
- moveActive(1);
163
- }
164
- else if (e.key === "ArrowUp") {
165
- e.preventDefault();
166
- moveActive(-1);
167
- }
168
- else if (e.key === "Home") {
169
- e.preventDefault();
170
- setActiveIndex(enabledIndexes[0] ?? 0);
171
- }
172
- else if (e.key === "End") {
173
- e.preventDefault();
174
- setActiveIndex(enabledIndexes[enabledIndexes.length - 1] ?? 0);
175
- }
176
- else if (e.key === "ArrowRight" && items[activeIndex]?.submenu) {
177
- e.preventDefault();
178
- enterSubmenu(items[activeIndex]);
179
- }
180
- else if ((e.key === "ArrowLeft" || e.key === "Escape") && openSubmenu) {
181
- // ARIA's own submenu behaviour: Escape leaves the submenu for its parent menu rather than
182
- // dismissing the whole thing.
183
- e.preventDefault();
184
- leaveSubmenu();
185
- }
186
- else if (e.key === "Escape" || e.key === "Tab") {
187
- e.preventDefault();
188
- closeMenu(true);
189
- }
190
- }
191
- function handleTriggerKeyDown(e) {
192
- if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
193
- e.preventDefault();
194
- openMenu();
195
- }
196
- }
197
- let itemIndex = -1;
198
- return (_jsxs(_Fragment, { children: [_jsxs("button", { ref: triggerRef, type: "button", "aria-haspopup": "menu", "aria-expanded": open, "aria-label": ariaLabel, title: title, disabled: disabled, onClick: () => (open ? closeMenu(false) : openMenu()), onKeyDown: handleTriggerKeyDown, className: iconOnly
199
- ? className
200
- : [
201
- // `min-w-0` so a long label ("Filter: Has attachments") truncates instead of pushing
202
- // whatever sits beside it in the toolbar off the edge of a 384px list column.
203
- "inline-flex min-w-0 items-center gap-1 px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent",
204
- className,
205
- ].join(" "), children: [icon, !iconOnly && _jsx("span", { className: "truncate", children: label }), !iconOnly && _jsx(HiChevronDown, { size: 14, "aria-hidden": "true", className: "shrink-0 text-text-muted" })] }), open && (_jsx(PopoverPortal, { anchorRef: triggerRef, onClose: () => closeMenu(false), width: width, height: menuHeight(shownSections, width), "aria-label": ariaLabel, children: _jsx("div", { ref: setMenuNode, role: "menu", tabIndex: -1, "aria-label": ariaLabel, onKeyDown: handleMenuKeyDown, className: "flex-1 overflow-y-auto py-1", children: shownSections.map((section, sectionIndex) => (_jsxs("div", { role: "group", "aria-label": section.label, className: sectionIndex > 0 ? "border-t border-border mt-1 pt-1" : "", children: [section.label && (_jsx("div", { "aria-hidden": "true", className: "h-6 flex items-center px-3 text-xs font-semibold uppercase tracking-wide text-text-muted", children: section.label })), section.items.map((item) => {
206
- itemIndex += 1;
207
- const index = itemIndex;
208
- const role = item.role ?? "menuitem";
209
- return (_jsxs("button", { ref: (node) => {
210
- itemRefs.current[index] = node;
211
- }, type: "button", role: role, disabled: item.disabled, title: item.title, tabIndex: index === activeIndex ? 0 : -1, ...(role === "menuitem"
212
- ? {}
213
- : { "aria-checked": item.checked === "mixed" ? "mixed" : !!item.checked }), ...(item.submenu ? { "aria-haspopup": "menu", "aria-expanded": false } : {}), onClick: () => selectItem(item), className: "w-full flex items-start gap-2 px-3 py-2 text-left text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent", children: [_jsxs("span", { className: "w-4 shrink-0 flex justify-center pt-0.5", children: [item.key === "__back" && (_jsx(HiChevronLeft, { size: 14, "aria-hidden": "true", className: "text-text-muted" })), item.checked === "mixed" ? (_jsx(HiMinus, { size: 14, "aria-hidden": "true", className: "text-primary-dark" })) : (item.checked && _jsx(HiCheck, { size: 14, "aria-hidden": "true", className: "text-primary-dark" }))] }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsxs("span", { className: "flex h-5 items-center gap-1.5", children: [item.icon && (_jsx("span", { "aria-hidden": "true", className: "shrink-0 text-text-muted", children: item.icon })), item.swatchColor && (_jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: item.swatchColor } })), _jsx("span", { className: "truncate", children: item.label })] }), item.description && (_jsx("span", { className: "block h-4 text-xs leading-4 text-text-muted truncate font-normal", children: item.description }))] }), item.submenu && (_jsx(HiChevronRight, { size: 14, "aria-hidden": "true", className: "shrink-0 mt-0.5 text-text-muted" }))] }, item.key));
214
- }), section.note && _jsx("p", { className: "px-3 py-1 text-xs leading-4 text-text-muted", children: section.note })] }, section.key))) }) }))] }));
215
- }
5
+ // `MenuButton` lives in `lib/components/menus` so shared form components (`ImageEditBadge`) can use it; this keeps the mail components' import path working.
6
+ export { default, menuHeight } from "../../../../lib/components/menus/MenuButton.js";
@@ -48,7 +48,7 @@ import LabelMenuButton from "./labelMenu.js";
48
48
  import Button from "../../../../lib/components/buttons/Button.js";
49
49
  import EncryptedBody from "./reading/EncryptedBody.js";
50
50
  import { ENCRYPTED_SUBJECT_PLACEHOLDER, displaySubject } from "./reading/EncryptedPreview.js";
51
- import { rememberDecrypted } from "../../mail/decryptedMessages.js";
51
+ import { mapWithConcurrency, rememberDecrypted } from "../../mail/decryptedMessages.js";
52
52
  import MessageBody, { BodySkeleton } from "./reading/MessageBody.js";
53
53
  import { BODY_FONT_STYLE, CardShell, SenderAvatar, SubjectCard } from "./reading/MessageCard.js";
54
54
  import MessageMoreMenu from "./reading/MessageMoreMenu.js";
@@ -210,6 +210,8 @@ function subjectCore(subject) {
210
210
  function sameSubject(subject, threadSubject) {
211
211
  return subjectCore(subject) === subjectCore(threadSubject);
212
212
  }
213
+ /** How many earlier messages of a conversation are loaded at once when printing it. */
214
+ const PRINT_CONCURRENCY = 4;
213
215
  function formatBytes(bytes) {
214
216
  if (bytes >= 1000000)
215
217
  return `${(bytes / 1000000).toFixed(1)} MB`;
@@ -779,10 +781,11 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
779
781
  (verifiedOrSealed || security.state === "encrypted" || security.state === "encrypted_unverified_signer" || security.state === "signature_failed")
780
782
  ? security.attachments
781
783
  : undefined;
782
- // Any state that involves a signature shows the address actually signed for (the protected From when the message
784
+ // Any state with a valid signature shows the address actually signed for (the protected From when the message
783
785
  // carries one - it equals the outer From's address, or verification would have failed) next to the badge, never a
784
- // display name alone: anyone can put "ceo@corp.com" in the name of a message sent from x@corp-pay.com.
785
- const signatureShown = security !== null && security.state !== "unprotected" && security.state !== "encrypted";
786
+ // display name alone: anyone can put "ceo@corp.com" in the name of a message sent from x@corp-pay.com. A failed
787
+ // signature vouches for nothing, so its message shows the outer address like an unsigned one.
788
+ const signatureShown = security !== null && (verifiedOrSealed || UNVERIFIED_SIGNER_STATES.has(security.state));
786
789
  const senderAddress = (signatureShown ? extractAddresses(security.protectedHeaders?.from)[0] : undefined) ?? message.from.address;
787
790
  const senderName = message.from.displayName;
788
791
  const senderNameCheck = checkSenderName(senderName, senderAddress);
@@ -841,11 +844,12 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
841
844
  try {
842
845
  const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
843
846
  const older = await olderConversationMessages(message, client).catch(() => []);
844
- const earlier = await Promise.all(older.map((other) => printableOlderMessage(other).catch(() => ({
847
+ // A few at a time: a long conversation is hundreds of messages, each of them several requests.
848
+ const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) => printableOlderMessage(other).catch(() => ({
845
849
  subject: other.subject || "(no subject)",
846
850
  headers: printHeaders(other, formatMailAddress(other.from)),
847
851
  content: { kind: "text", text: "This message could not be loaded, so it is not printed." },
848
- }))));
852
+ })));
849
853
  const printable = buildConversationPrintDocument(shownSubject, [
850
854
  {
851
855
  subject: shownSubject,